mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 06:54:50 +01:00
feat(design-lint): port the structure and motion rule families from sloplint
Structure: template-cards, nested-cards, vh-height, raw-spacing, off-scale-spacing, overflow-x-unclipped (static half), bare-fr-track, display-head-unwrapped, radio-tab-jump, double-sticky, screen-word-budget (static half), and a conservative static half of wide-container. Motion: layout-transition, spacing-transition, hover-tells, bounce-easing, focus-ring-fade, tooltip-focus-delay, autorotate-no-pause, missing-focus-visible, missing-states, missing-reduced-motion, autoplay-unmuted, lazy-lcp, video-without-poster, input-state-shift, control-height (static half), content-hidden-at-rest. Every rule has a planted positive and a negative control; the clean fixture lede drops to eleven words so the hero-paragraph budget holds. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
1ba2ae0425
commit
3113f54d55
5 files changed
+1252
-11
No files matched your search
@@ -1,10 +1,366 @@
|
||||
/**
|
||||
* Motion rules of the design lint (sloplint gates 10a 10b 11 12 15 17 18 26a 26b 27 28a 28b 39a 39b 62; Nutlope/hallmark,
|
||||
* MIT, see NOTICE.md), rewritten in gstack's voice. Rule ids: layout-transition, spacing-transition, hover-tells, bounce-easing, focus-ring-fade, tooltip-focus-delay, autorotate-no-pause, missing-focus-visible, missing-states, missing-reduced-motion, autoplay-unmuted, lazy-lcp, video-without-poster, input-state-shift, control-height, content-hidden-at-rest.
|
||||
* Motion and interaction rules of the design lint (sloplint gates 10a 10b 11 12 15 17 18 26a 26b 27 28a 28b 39a 39b 62;
|
||||
* Nutlope/hallmark, MIT, see NOTICE.md), rewritten in gstack's voice. Rule ids: layout-transition, spacing-transition,
|
||||
* hover-tells, bounce-easing, focus-ring-fade, tooltip-focus-delay, autorotate-no-pause, missing-focus-visible,
|
||||
* missing-states, missing-reduced-motion, autoplay-unmuted, lazy-lcp, video-without-poster, input-state-shift,
|
||||
* control-height, content-hidden-at-rest.
|
||||
*/
|
||||
import type { LintContext } from '../context';
|
||||
import type { Reporter } from '../report';
|
||||
import { isTokenRule, pxOf, resolveVars, selectorIsInteractive, stripComments } from '../css';
|
||||
import { mergedRules, type LintContext, type SharedStyles } from '../context';
|
||||
import { getAttr, hasAttr } from '../html';
|
||||
import { trunc, type Reporter } from '../report';
|
||||
import { twGroup, twVariants, type TwItem } from '../tailwind';
|
||||
|
||||
const baseSelector = (sel: string): string => sel.replace(/::?[a-z-]+(\([^)]*\))?/gi, '').replace(/\s+/g, ' ').trim();
|
||||
|
||||
const stripVariants = (cls: string): string => cls.replace(/^(?:[\w-]+:)+/, '');
|
||||
|
||||
const firstFile = (ctx: LintContext): string => ctx.cssFiles[0]?.file || ctx.docs[0]?.file || '?';
|
||||
|
||||
const SHARED_ONCE = new WeakMap<SharedStyles, Set<string>>();
|
||||
|
||||
/** True the first time a key is seen across the contexts that share one project's styles; always true when nothing is shared. */
|
||||
function once(ctx: LintContext, key: string): boolean {
|
||||
if (!ctx.shared) return true;
|
||||
let seen = SHARED_ONCE.get(ctx.shared);
|
||||
if (!seen) { seen = new Set(); SHARED_ONCE.set(ctx.shared, seen); }
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
const HARD_PROP = /(?<![\w-])(width|height|top|left|right|bottom|inset(?:-[a-z-]+)?|flex-basis|grid-template-columns)(?![\w-])/;
|
||||
const SOFT_PROP = /(?<![\w-])(margin(?:-[a-z]+)?|padding(?:-[a-z]+)?)(?![\w-])/;
|
||||
|
||||
// sloplint check10 and check10Motion (gates 10a, 10b)
|
||||
function layoutTransitions(ctx: LintContext, r: Reporter): void {
|
||||
const fix = 'animate transform or opacity instead';
|
||||
for (const rule of ctx.rules) {
|
||||
for (const d of rule.decls) {
|
||||
if (d.prop === 'transition' && /(^|,)\s*all[\s,]|^all$|^all\s/.test(d.value.trim())) r.report('layout-transition', rule.file, d.line, `transition: all on ${rule.selector}`, { fix: 'list the transitioned properties' });
|
||||
if (d.prop === 'transition-property' && /\ball\b/.test(d.value)) r.report('layout-transition', rule.file, d.line, `transition-property: all on ${rule.selector}`, { fix: 'list the transitioned properties' });
|
||||
}
|
||||
if (/skip|sr-only|visually-hidden/i.test(rule.selector)) continue;
|
||||
if (rule.keyframes) {
|
||||
for (const d of rule.decls) {
|
||||
if (/^(min|max)-/.test(d.prop)) continue;
|
||||
if (HARD_PROP.test(d.prop)) r.report('layout-transition', rule.file, d.line, `@keyframes ${rule.keyframes} animates ${d.prop}`, { fix });
|
||||
else if (SOFT_PROP.test(d.prop)) r.report('spacing-transition', rule.file, d.line, `@keyframes ${rule.keyframes} animates ${d.prop}`, { fix });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (const d of rule.decls) {
|
||||
if (d.prop !== 'transition' && d.prop !== 'transition-property') continue;
|
||||
const hard = HARD_PROP.exec(d.value);
|
||||
const soft = SOFT_PROP.exec(d.value);
|
||||
if (hard) r.report('layout-transition', rule.file, d.line, `transitioning ${hard[1]} on ${rule.selector}`, { fix });
|
||||
else if (soft) r.report('spacing-transition', rule.file, d.line, `transitioning ${soft[1]} on ${rule.selector}`, { fix });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const IMAGE_SEL = /(^|[\s>+~,])(img|picture|figure|video)\b|__(img|image|photo|thumb|cover)\b|\.(img|image|photo|thumb|cover)\b/i;
|
||||
|
||||
// sloplint check11 and check11Lift (gate 11)
|
||||
function hoverTells(ctx: LintContext, r: Reporter): void {
|
||||
const byScale = new Map<string, Array<{ sel: string; file: string; line: number }>>();
|
||||
for (const rule of ctx.rules) {
|
||||
if (!/:hover/.test(rule.selector)) continue;
|
||||
for (const d of rule.decls) {
|
||||
if (d.prop !== 'transform') continue;
|
||||
const m = /scale\(\s*([\d.]+)\s*\)/.exec(d.value);
|
||||
if (!m || parseFloat(m[1]) === 1) continue;
|
||||
if (!byScale.has(m[1])) byScale.set(m[1], []);
|
||||
byScale.get(m[1])!.push({ sel: rule.selector, file: rule.file, line: d.line });
|
||||
}
|
||||
}
|
||||
for (const [k, list] of byScale) {
|
||||
const distinct = new Set(list.map((x) => x.sel));
|
||||
if (distinct.size >= 2) r.report('hover-tells', list[0].file, list[0].line, `the same hover scale(${k}) on ${distinct.size} selectors`, { fix: 'one hover signal per element' });
|
||||
}
|
||||
for (const rule of ctx.rules) {
|
||||
if (!/:hover/.test(rule.selector) || !IMAGE_SEL.test(rule.selector)) continue;
|
||||
const t = rule.decls.find((d) => d.prop === 'transform' && /scale|rotate/.test(d.value));
|
||||
if (t) r.report('hover-tells', rule.file, t.line, `an image transforms on hover (${rule.selector})`, { fix: 'imagery sits still; give the container the feedback' });
|
||||
}
|
||||
const lifts = new Map<string, { file: string; line: number }>();
|
||||
for (const m of mergedRules(ctx)) {
|
||||
if (!/:hover/.test(m.selector)) continue;
|
||||
const t = m.decls.find((d) => (d.prop === 'transform' && /translateY\(\s*-\s*\d/.test(resolveVars(d.value, ctx.tokens))) || (d.prop === 'translate' && /^0(px)?\s+-\d/.test(resolveVars(d.value, ctx.tokens))));
|
||||
if (!t || !m.decls.some((d) => d.prop === 'box-shadow')) continue;
|
||||
for (const part of m.selector.split(',')) lifts.set(baseSelector(part), { file: m.file, line: t.line });
|
||||
}
|
||||
if (lifts.size < 3) return;
|
||||
const third = [...lifts.values()][2];
|
||||
r.report('hover-tells', third.file, third.line, `the hover lift (translateY plus shadow) on ${lifts.size} selectors (${trunc([...lifts.keys()].slice(0, 3).join(', '), 44)})`, { fix: 'one signal per element; the same lift on every card is the template' });
|
||||
}
|
||||
|
||||
const UI_SEL = /:hover|:active|:focus|\b(btn|button|modal|dialog|tooltip|dropdown|menu|popover|toast|tab|input|select|switch|toggle|accordion)\b/i;
|
||||
|
||||
// sloplint check12 (gate 12), with no genre exemption: every spring on UI state is reported
|
||||
function bounceEasing(ctx: LintContext, r: Reporter): void {
|
||||
for (const rule of ctx.rules) {
|
||||
if (!UI_SEL.test(rule.selector)) continue;
|
||||
for (const d of rule.decls) {
|
||||
if (!/transition|animation/.test(d.prop)) continue;
|
||||
for (const m of resolveVars(d.value, ctx.tokens).matchAll(/cubic-bezier\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)/g)) {
|
||||
const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
|
||||
if (y1 > 1.05 || y2 > 1.05 || y1 < -0.05 || y2 < -0.05) r.report('bounce-easing', rule.file, d.line, `overshoot easing ${m[0]} on ${rule.selector}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sloplint check15 and check15Fade (gate 15)
|
||||
function focusRingFade(ctx: LintContext, r: Reporter): void {
|
||||
for (const rule of ctx.rules) {
|
||||
if (!/:focus(-visible|-within)?\b/.test(rule.selector)) continue;
|
||||
const withinOnly = !/:focus(-visible)?(?![\w-])/.test(rule.selector);
|
||||
for (const d of rule.decls) {
|
||||
if (d.prop === 'transition' && (withinOnly ? /outline|box-shadow|\ball\b/ : /outline|box-shadow|opacity|\ball\b/).test(d.value)) {
|
||||
r.report('focus-ring-fade', rule.file, d.line, `the focus ring transitions in on ${rule.selector}`, { fix: 'remove the transition; a ring appears instantly' });
|
||||
}
|
||||
}
|
||||
}
|
||||
const focusProps = new Map<string, Set<string>>();
|
||||
for (const rule of ctx.rules) {
|
||||
if (!/:focus-visible/.test(rule.selector)) continue;
|
||||
for (const part of rule.selector.split(',')) {
|
||||
if (!/:focus-visible/.test(part)) continue;
|
||||
const base = baseSelector(part).replace(/^\*$/, '');
|
||||
const set = focusProps.get(base) || new Set<string>();
|
||||
for (const d of rule.decls) {
|
||||
if (d.prop === 'box-shadow') set.add('box-shadow');
|
||||
if (/^outline/.test(d.prop)) set.add('outline');
|
||||
}
|
||||
focusProps.set(base, set);
|
||||
}
|
||||
}
|
||||
if (!focusProps.size) return;
|
||||
const global = focusProps.get('') || new Set<string>();
|
||||
for (const rule of ctx.rules) {
|
||||
if (rule.keyframes || /:focus|:hover|:active/.test(rule.selector)) continue;
|
||||
for (const d of rule.decls) {
|
||||
if (d.prop !== 'transition' && d.prop !== 'transition-property') continue;
|
||||
for (const prop of ['box-shadow', 'outline']) {
|
||||
if (!new RegExp(`(^|[\\s,])${prop}(-[a-z]+)?\\b`).test(d.value)) continue;
|
||||
const own = rule.selector.split(',').some((p) => (focusProps.get(baseSelector(p)) || new Set<string>()).has(prop));
|
||||
if (!global.has(prop) && !own) continue;
|
||||
r.report('focus-ring-fade', rule.file, d.line, `${trunc(rule.selector, 32)} transitions ${prop} while :focus-visible sets it, so the ring fades in`, { fix: 'transition the hover property only, or draw the ring with a property that is not transitioned' });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sloplint check17 (gate 17)
|
||||
function tooltipDelay(ctx: LintContext, r: Reporter): void {
|
||||
const tips = ctx.rules.filter((rule) => /tooltip|tip\b/i.test(rule.selector));
|
||||
const delayed = (sel: RegExp) => tips.filter((rule) => sel.test(rule.selector)).map((rule) => rule.decls.find((d) => /^transition(-delay)?$/.test(d.prop) && /(\d{3,})ms|([1-9][\d.]*)s\b/.test(d.value))).find(Boolean);
|
||||
const hover = delayed(/:hover/);
|
||||
const focus = delayed(/:focus/);
|
||||
if (hover && focus) r.report('tooltip-focus-delay', tips[0].file, focus.line, 'the tooltip waits as long on focus as on hover', { suspected: true });
|
||||
}
|
||||
|
||||
// sloplint check18 (gate 18)
|
||||
function autorotate(ctx: LintContext, r: Reporter): void {
|
||||
const paused = ctx.rules.some((rule) => /:hover|:focus|\[data-loop\]/.test(rule.selector) && rule.decls.some((d) => d.prop === 'animation-play-state' && /paused/.test(d.value)));
|
||||
if (paused) return;
|
||||
for (const rule of ctx.rules) {
|
||||
if (rule.keyframes || !/carousel|marquee|ticker|rotate|slider|scroll|auto/i.test(rule.selector)) continue;
|
||||
for (const d of rule.decls) {
|
||||
if (/^animation(-iteration-count)?$/.test(d.prop) && /\binfinite\b/.test(d.value)) {
|
||||
r.report('autorotate-no-pause', rule.file, d.line, `an infinite animation on ${rule.selector} with no paused state on hover or focus`, { suspected: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sloplint check26 (gates 26a, 26b); ctx.shared carries a project's global state styles
|
||||
function focusAndStates(ctx: LintContext, r: Reporter): void {
|
||||
const hasInteractive = ctx.docs.some((doc) => /<(a|button|input|select|textarea)\b|role\s*=\s*["']button/i.test(doc.src))
|
||||
|| ctx.rules.some((rule) => selectorIsInteractive(rule.selector));
|
||||
if (!hasInteractive) return;
|
||||
const has = (pseudo: string) => ctx.rules.some((rule) => rule.selector.includes(pseudo));
|
||||
const tv = twVariants(ctx);
|
||||
const shared = ctx.shared;
|
||||
const file = firstFile(ctx);
|
||||
if (!has(':focus-visible') && !has(':focus') && !shared?.focus && ![...tv].some((v) => /^focus(?:-visible|-within)?$/.test(v)) && once(ctx, '26a')) {
|
||||
r.report('missing-focus-visible', file, 1, 'no :focus-visible style anywhere: keyboard users cannot see where they are');
|
||||
}
|
||||
if (!has(':active') && !shared?.active && !tv.has('active') && once(ctx, '26b:active')) {
|
||||
r.report('missing-states', file, 1, 'no :active style anywhere', { fix: 'every state a control can be in: hover, active, focus-visible, disabled' });
|
||||
}
|
||||
const hasFormish = ctx.docs.some((doc) => /<(button|input|select|textarea)\b/i.test(doc.src));
|
||||
if (hasFormish && !has(':disabled') && !shared?.disabled && !tv.has('disabled') && !tv.has('aria-disabled') && !ctx.rules.some((rule) => /\[disabled\]|aria-disabled/.test(rule.selector)) && once(ctx, '26b:disabled')) {
|
||||
r.report('missing-states', file, 1, 'no :disabled style anywhere', { fix: 'every state a control can be in: hover, active, focus-visible, disabled' });
|
||||
}
|
||||
}
|
||||
|
||||
/** The first Tailwind element that moves: an animate-* utility, or a transition on a transform utility. */
|
||||
function twMotion(ctx: LintContext): { item: TwItem; what: string } | null {
|
||||
for (const item of twGroup(ctx).items) {
|
||||
const base = item.info.classes.map(stripVariants);
|
||||
const animate = base.find((c) => /^animate-(?!none$)/.test(c));
|
||||
if (animate) return { item, what: animate };
|
||||
if (base.some((c) => /^transition(-transform|-all)?$/.test(c)) && base.some((c) => /^-?(scale|translate|rotate|skew)-/.test(c))) return { item, what: 'a transition on a transform' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// sloplint check27 (gate 27); ctx.shared.reducedMotion is the project's fallback
|
||||
function reducedMotion(ctx: LintContext, r: Reporter): void {
|
||||
const moves = (decls: Array<{ prop: string; value: string }>) => decls.some((d) => /^(transform|translate|scale|rotate)$/.test(d.prop) && !/^none\b/.test(d.value.trim()));
|
||||
const hasMotion = ctx.rules.some((rule) => rule.keyframes)
|
||||
|| ctx.rules.some((rule) => rule.decls.some((d) => /^transition/.test(d.prop) && /transform/.test(d.value)))
|
||||
|| (ctx.rules.some((rule) => rule.decls.some((d) => /^transition/.test(d.prop) && /(^|[\s,])all\b/.test(d.value))) && ctx.rules.some((rule) => /:(hover|active|focus)/.test(rule.selector) && moves(rule.decls)));
|
||||
const tw = twMotion(ctx);
|
||||
if (!hasMotion && !tw) return;
|
||||
const tv = twVariants(ctx);
|
||||
const hasFallback = ctx.rules.some((rule) => rule.media && /prefers-reduced-motion/i.test(rule.media))
|
||||
|| ctx.cssFiles.some((f) => /prefers-reduced-motion/i.test(stripComments(f.raw)))
|
||||
|| ctx.docs.some((doc) => doc.styles.some((s) => /prefers-reduced-motion/i.test(stripComments(s.css))))
|
||||
|| tv.has('motion-reduce') || tv.has('motion-safe') || !!ctx.shared?.reducedMotion;
|
||||
if (hasFallback || !once(ctx, '27')) return;
|
||||
if (!hasMotion && tw) {
|
||||
r.report('missing-reduced-motion', tw.item.file, tw.item.line, `motion utilities (${tw.what}) with no motion-reduce: or motion-safe: variant anywhere`, { fix: 'add motion-reduce: fallbacks, or put the motion behind motion-safe:' });
|
||||
return;
|
||||
}
|
||||
const kf = ctx.rules.find((rule) => rule.keyframes);
|
||||
r.report('missing-reduced-motion', kf ? kf.file : firstFile(ctx), kf ? kf.line : 1, 'motion present and no prefers-reduced-motion block anywhere');
|
||||
}
|
||||
|
||||
// sloplint check28 (gates 28a, 28b)
|
||||
function media(ctx: LintContext, r: Reporter): void {
|
||||
for (const doc of ctx.docs) {
|
||||
let firstMedia = true, seenH1 = false, heroPassed = false;
|
||||
for (const t of doc.tags) {
|
||||
if (t.close) { if (seenH1 && (t.name === 'section' || t.name === 'header')) heroPassed = true; continue; }
|
||||
if (t.name === 'h1') seenH1 = true;
|
||||
if (t.name === 'video') {
|
||||
if (hasAttr(t.attrs, 'autoplay') && !hasAttr(t.attrs, 'muted')) r.report('autoplay-unmuted', doc.file, t.line, 'a video autoplays without muted, so browsers block it or it plays sound unasked');
|
||||
if (!getAttr(t.attrs, 'poster')) r.report('video-without-poster', doc.file, t.line, 'a video with no poster frame', { fix: 'a poster frame; below the fold it may also load lazily' });
|
||||
if ((getAttr(t.attrs, 'loading') || '').toLowerCase() === 'lazy' && firstMedia) r.report('lazy-lcp', doc.file, t.line, 'the first video loads lazily and is likely the largest paint', { fix: 'never lazy on the LCP video; preload its poster with fetchpriority="high"', suspected: true });
|
||||
firstMedia = false;
|
||||
}
|
||||
if (t.name === 'img') {
|
||||
if (firstMedia && !heroPassed && (getAttr(t.attrs, 'loading') || '').toLowerCase() === 'lazy') r.report('lazy-lcp', doc.file, t.line, 'the first image loads lazily and is likely the largest paint', { suspected: true });
|
||||
firstMedia = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const INPUT_SEL = /(^|[\s,>+~])(input|textarea|select)\b|__input|__field|\.(input|field)\b/;
|
||||
|
||||
// sloplint check39 (gate 39a)
|
||||
function inputStates(ctx: LintContext, r: Reporter): void {
|
||||
const hasInputs = ctx.docs.some((doc) => /<(input|textarea|select)\b/i.test(doc.src)) || ctx.rules.some((rule) => /(^|[\s,>+~])(input|textarea|select)\b/.test(rule.selector));
|
||||
if (!hasInputs) return;
|
||||
const widthOf = (v: string) => { const m = /(\d+(?:\.\d+)?)px/.exec(v); return m ? parseFloat(m[1]) : null; };
|
||||
let baseW: number | null = null;
|
||||
for (const rule of ctx.rules) {
|
||||
if (!INPUT_SEL.test(rule.selector) || /:hover|:focus|:active|:disabled|\[|:checked/.test(rule.selector)) continue;
|
||||
for (const d of rule.decls) {
|
||||
if (d.prop !== 'border' && d.prop !== 'border-width') continue;
|
||||
const w = widthOf(d.value);
|
||||
if (w != null && baseW == null) baseW = w;
|
||||
}
|
||||
}
|
||||
for (const rule of ctx.rules) {
|
||||
if (!INPUT_SEL.test(rule.selector) || !/:hover|:focus|\.is-error|\[aria-invalid/.test(rule.selector)) continue;
|
||||
for (const d of rule.decls) {
|
||||
if (d.prop !== 'border' && d.prop !== 'border-width') continue;
|
||||
const w = widthOf(d.value);
|
||||
if (w != null && baseW != null && w !== baseW) r.report('input-state-shift', rule.file, d.line, `the border goes from ${baseW}px to ${w}px on ${rule.selector}, so the field shifts`, { fix: 'keep the border width constant; move the state to outline' });
|
||||
}
|
||||
if (/:focus/.test(rule.selector) && rule.decls.some((d) => /^border(-color)?$/.test(d.prop)) && !rule.decls.some((d) => /^outline/.test(d.prop))) {
|
||||
r.report('input-state-shift', rule.file, rule.line, `the focus state is drawn with border on ${rule.selector}`, { fix: 'a focus ring with outline, not border' });
|
||||
}
|
||||
}
|
||||
for (const rule of ctx.rules) {
|
||||
if (!/:disabled|\[disabled\]/.test(rule.selector)) continue;
|
||||
if (rule.decls.some((d) => d.prop === 'opacity') && !rule.decls.some((d) => d.prop === 'cursor' && /not-allowed/.test(d.value))) {
|
||||
r.report('input-state-shift', rule.file, rule.line, `disabled by opacity alone on ${rule.selector}`, { fix: 'three disabled channels: fill, ink and cursor: not-allowed, plus the disabled attribute' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const CONTROL_SEL = /(^|[\s,>+~(])(button|input|select|textarea)\b|\.(btn|button|cta|input|field|control)\b/i;
|
||||
const CONTROL_SKIP = /:hover|:focus|:active|:disabled|:checked|\[type=["']?(radio|checkbox|range|hidden|color|file)|sr-only|visually-hidden|icon|close|toggle|switch|chip|tag|badge|pill|__dot|__mark/i;
|
||||
|
||||
// gate 39b, static half (sloplint measures 39b in the render tier only): a control whose CSS fixes its height outside 44 to 52px
|
||||
function controlHeight(ctx: LintContext, r: Reporter): void {
|
||||
if (ctx.scope !== 'page') return;
|
||||
const seen = new Set<string>();
|
||||
for (const rule of ctx.rules) {
|
||||
if (rule.keyframes || isTokenRule(rule) || !CONTROL_SEL.test(rule.selector) || CONTROL_SKIP.test(rule.selector)) continue;
|
||||
if (rule.media && /max-width/i.test(rule.media)) continue;
|
||||
for (const d of rule.decls) {
|
||||
if (!/^(min-)?height$/.test(d.prop)) continue;
|
||||
const px = pxOf(resolveVars(d.value, ctx.tokens));
|
||||
if (px == null || (px >= 44 && px <= 52) || seen.has(rule.selector)) continue;
|
||||
seen.add(rule.selector);
|
||||
r.report('control-height', rule.file, d.line, `${d.prop}: ${trunc(d.value, 20)} (${px}px) on ${rule.selector}: controls stand 44 to 52px`, { fix: 'min-height: 48px' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sloplint HIDE_OK (gate 62): states and parts that are hidden at rest by nature
|
||||
const HIDE_OK = /tooltip|popover|menu|dropdown|modal|dialog|toast|overlay|backdrop|sheet|drawer|panel|:hover|:focus|:checked|:target|\[open|\[aria-(?:expanded|selected|current|pressed)|::before|::after|\.is-|\[hidden|\[aria-hidden|sr-only|visually-hidden|skip|placeholder|::marker|\[data-slot/i;
|
||||
const REVEALISH = /reveal|fade|animate|aos|in-view|inview|appear|enter|rise|slide|stagger|observe|lazy/i;
|
||||
|
||||
// sloplint startingStyleText (gate 62): every @starting-style block with the selector it sits under
|
||||
function startingStyleText(css: string): string {
|
||||
let out = '';
|
||||
for (const m of css.matchAll(/@starting-style\s*\{/gi)) {
|
||||
let depth = 1, k = m.index! + m[0].length;
|
||||
while (k < css.length && depth > 0) { if (css[k] === '{') depth++; else if (css[k] === '}') depth--; k++; }
|
||||
out += css.slice(m.index!, k) + ' ';
|
||||
const open = css.lastIndexOf('{', m.index! - 1), close = css.lastIndexOf('}', m.index! - 1);
|
||||
if (open > close) out += css.slice(Math.max(close + 1, 0), open) + ' ';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// sloplint check62 (gate 62)
|
||||
function hiddenAtRest(ctx: LintContext, r: Reporter): void {
|
||||
const starting = startingStyleText(ctx.allCss);
|
||||
const jsScoped = (sel: string) => /(^|[\s,])(html|body)?\.js\b|\[data-js|\.no-js|html\.has-js/i.test(sel);
|
||||
const animated = new Map<string, Array<{ selector: string; decls: Array<{ prop: string; value: string }> }>>();
|
||||
for (const rule of ctx.rules) if (rule.keyframes) {
|
||||
if (!animated.has(rule.keyframes)) animated.set(rule.keyframes, []);
|
||||
animated.get(rule.keyframes)!.push(rule);
|
||||
}
|
||||
const animatesVisible = (name: string) => (animated.get(name) || []).some((rule) => /to|100%/.test(rule.selector) && rule.decls.some((d) => d.prop === 'opacity' && parseFloat(d.value) === 1));
|
||||
const seen = new Set<string>();
|
||||
for (const rule of ctx.rules) {
|
||||
if (rule.keyframes || (rule.media && /\bprint\b/i.test(rule.media)) || rule.selector === '@font-face') continue;
|
||||
const hidden = rule.decls.find((d) => (d.prop === 'opacity' && parseFloat(resolveVars(d.value, ctx.tokens)) === 0) || (d.prop === 'visibility' && /hidden/.test(d.value)));
|
||||
if (!hidden) continue;
|
||||
if (HIDE_OK.test(rule.selector.replace(/:not\([^)]*\)/g, '')) || jsScoped(rule.selector) || (starting && starting.includes(rule.selector))) continue;
|
||||
const revealish = REVEALISH.test(rule.selector) || rule.decls.some((d) => d.prop === 'transform' && /translate|scale/.test(d.value));
|
||||
if (!revealish) continue;
|
||||
const anim = rule.decls.find((d) => d.prop === 'animation' || d.prop === 'animation-name');
|
||||
const animName = anim ? anim.value.split(/\s+/).find((w) => animated.has(w)) : null;
|
||||
if (animName && animatesVisible(animName)) continue;
|
||||
if (seen.has(rule.selector)) continue;
|
||||
seen.add(rule.selector);
|
||||
r.report('content-hidden-at-rest', rule.file, hidden.line, `${rule.selector} is hidden at rest (${hidden.prop}: ${hidden.value}) and needs a script to appear`);
|
||||
}
|
||||
}
|
||||
|
||||
export function run(ctx: LintContext, r: Reporter): void {
|
||||
void ctx; void r;
|
||||
layoutTransitions(ctx, r);
|
||||
hoverTells(ctx, r);
|
||||
bounceEasing(ctx, r);
|
||||
focusRingFade(ctx, r);
|
||||
tooltipDelay(ctx, r);
|
||||
autorotate(ctx, r);
|
||||
focusAndStates(ctx, r);
|
||||
reducedMotion(ctx, r);
|
||||
media(ctx, r);
|
||||
inputStates(ctx, r);
|
||||
controlHeight(ctx, r);
|
||||
hiddenAtRest(ctx, r);
|
||||
}
|
||||
@@ -1,10 +1,420 @@
|
||||
/**
|
||||
* Structure rules of the design lint (sloplint gates 3 4 6a 24a 24b 34 44d 50 53 56 58 59; Nutlope/hallmark,
|
||||
* MIT, see NOTICE.md), rewritten in gstack's voice. Rule ids: template-cards, nested-cards, vh-height, raw-spacing, off-scale-spacing, overflow-x-unclipped, wide-container, bare-fr-track, display-head-unwrapped, radio-tab-jump, double-sticky, screen-word-budget, section-gap.
|
||||
* Structure rules of the design lint (sloplint gates 3 4 6a 24a 24b 34 44d 50 53 56 58; Nutlope/hallmark,
|
||||
* MIT, see NOTICE.md), rewritten in gstack's voice. Rule ids: template-cards, nested-cards, vh-height, raw-spacing,
|
||||
* off-scale-spacing, overflow-x-unclipped, wide-container, bare-fr-track, display-head-unwrapped, radio-tab-jump,
|
||||
* double-sticky, screen-word-budget. Gate 59 (section-gap) has no static check: the render tier owns it, as it owns
|
||||
* the render halves of 34 (horizontal-scroll) and 44d.
|
||||
*/
|
||||
import type { LintContext } from '../context';
|
||||
import type { Reporter } from '../report';
|
||||
import { isTokenRule, pxOf, resolveVars } from '../css';
|
||||
import { mergedRules, type LintContext } from '../context';
|
||||
import { getAttr, innerOf, plainText, wordsOf, type Doc } from '../html';
|
||||
import { trunc, type Reporter } from '../report';
|
||||
import { twGroup } from '../tailwind';
|
||||
|
||||
const SPACING_PROP = /^(padding|margin|gap|row-gap|column-gap)(-[a-z]+)?$/;
|
||||
|
||||
const selectorClasses = (sel: string): string[] => (sel.match(/\.[a-zA-Z][\w-]*/g) || []).map((c) => c.slice(1));
|
||||
|
||||
const stripVariants = (cls: string): string => cls.replace(/^(?:[\w-]+:)+/, '');
|
||||
|
||||
/** The inner HTML of every open element in the context whose class attribute carries one of the classes. */
|
||||
function innerHtmlWithClass(ctx: LintContext, classes: string[]): string[] {
|
||||
const out: string[] = [];
|
||||
if (!classes.length) return out;
|
||||
for (const doc of ctx.docs) {
|
||||
for (let i = 0; i < doc.tags.length; i++) {
|
||||
const t = doc.tags[i];
|
||||
if (t.close || t.selfClosing) continue;
|
||||
const have = (getAttr(t.attrs, 'class') || '').split(/\s+/);
|
||||
if (!classes.some((c) => have.includes(c))) continue;
|
||||
const inner = innerOf(doc, i);
|
||||
if (inner) out.push(inner.inner);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const EQUAL_TRACK = String.raw`(?:1fr|minmax\(\s*0(?:px)?\s*,\s*1fr\s*\))`;
|
||||
const THREE_EQUAL = [new RegExp(String.raw`^repeat\(\s*3\s*,\s*${EQUAL_TRACK}\s*\)$`), new RegExp(String.raw`^${EQUAL_TRACK}(?:\s+${EQUAL_TRACK}){2}$`)];
|
||||
|
||||
// sloplint check3 (gate 3)
|
||||
function iconTiles(ctx: LintContext, r: Reporter): void {
|
||||
for (const rule of ctx.rules) {
|
||||
for (const d of rule.decls) {
|
||||
if (d.prop !== 'grid-template-columns') continue;
|
||||
const v = resolveVars(d.value, ctx.tokens).trim();
|
||||
if (!THREE_EQUAL.some((re) => re.test(v))) continue;
|
||||
const classes = selectorClasses(rule.selector);
|
||||
const tiles = (!ctx.docs.length && !classes.length)
|
||||
|| innerHtmlWithClass(ctx, classes).some((inner) => (/class\s*=\s*["'][^"']*icon/i.test(inner) || /<svg\b/i.test(inner)) && /<h[2-6]\b/i.test(inner));
|
||||
if (tiles) r.report('template-cards', rule.file, d.line, `three equal columns of icon-above-heading tiles on ${rule.selector}`, { fix: 'vary the column widths, or set the icons inline and drop the tiles' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sloplint check3Stripe (gate 3)
|
||||
function sideStripes(ctx: LintContext, r: Reporter): void {
|
||||
for (const rule of ctx.rules) {
|
||||
for (const d of rule.decls) {
|
||||
if (!/^border-(left|right|inline-start|inline-end)$/.test(d.prop)) continue;
|
||||
const m = /(\d+(?:\.\d+)?)px/.exec(d.value);
|
||||
if (m && parseFloat(m[1]) >= 3 && !/transparent|none/i.test(d.value)) {
|
||||
r.report('template-cards', rule.file, d.line, `a ${m[1]}px coloured side stripe (${d.prop}: ${trunc(d.value, 32)}) on ${rule.selector}`, { fix: 'a hairline all round, no border, or an accent square' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const CARD_WORD = /(^|-)card(--[\w-]+)?$/i;
|
||||
const CONTAINERISH = /(grid|list|rail|row|wrap|deck|group|stack|container|section|scroller|track)/i;
|
||||
|
||||
// sloplint check4 (gate 4)
|
||||
function nestedCards(ctx: LintContext, r: Reporter): void {
|
||||
for (const doc of ctx.docs) {
|
||||
const stack: Array<{ name: string; isCard: boolean }> = [];
|
||||
for (const t of doc.tags) {
|
||||
if (t.close) {
|
||||
for (let i = stack.length - 1; i >= 0; i--) if (stack[i].name === t.name) { stack.splice(i); break; }
|
||||
continue;
|
||||
}
|
||||
if (t.selfClosing) continue;
|
||||
const cls = getAttr(t.attrs, 'class') || '';
|
||||
const isCard = cls.split(/\s+/).some((c) => { const base = c.split('--')[0]; return !!base && CARD_WORD.test(base) && !CONTAINERISH.test(base); });
|
||||
if (isCard && stack.some((s) => s.isCard)) r.report('nested-cards', doc.file, t.line, `a card inside a card: class="${trunc(cls, 40)}"`);
|
||||
stack.push({ name: t.name, isCard });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sloplint heroClasses (gate 6a): the classes of the section or header that holds the h1
|
||||
function heroClasses(ctx: LintContext): Set<string> {
|
||||
const out = new Set<string>();
|
||||
for (const doc of ctx.docs) {
|
||||
const h1 = doc.tags.findIndex((t) => !t.close && t.name === 'h1');
|
||||
if (h1 < 0) continue;
|
||||
for (let i = h1 - 1; i >= 0; i--) {
|
||||
const t = doc.tags[i];
|
||||
if (t.close || !/^(section|header)$/.test(t.name)) continue;
|
||||
for (const c of (getAttr(t.attrs, 'class') || '').split(/\s+/)) if (c) out.add(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// sloplint check6a (gate 6a)
|
||||
function vhHero(ctx: LintContext, r: Reporter): void {
|
||||
const heroCls = heroClasses(ctx);
|
||||
const isHero = (sel: string) => /hero/i.test(sel) || [...heroCls].some((c) => sel.includes('.' + c));
|
||||
const seen = new Set<string>();
|
||||
for (const rule of ctx.rules) {
|
||||
if (rule.keyframes || isTokenRule(rule) || !isHero(rule.selector)) continue;
|
||||
for (const d of rule.decls) {
|
||||
if (!/^(min-)?height$/.test(d.prop)) continue;
|
||||
const v = resolveVars(d.value, ctx.tokens);
|
||||
if (!/\d\s*vh\b/i.test(v) || seen.has(rule.selector)) continue;
|
||||
seen.add(rule.selector);
|
||||
r.report('vh-height', rule.file, d.line, `the hero is sized in vh: ${d.prop}: ${trunc(v, 24)} on ${rule.selector}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sloplint check24 (gate 24b)
|
||||
function offScaleSpacing(ctx: LintContext, r: Reporter): void {
|
||||
for (const rule of ctx.rules) {
|
||||
for (const d of rule.decls) {
|
||||
if (!SPACING_PROP.test(d.prop)) continue;
|
||||
for (const m of d.value.matchAll(/(-?\d+(?:\.\d+)?)px/g)) {
|
||||
const v = Math.abs(parseFloat(m[1]));
|
||||
if (v > 2 && (v % 4 !== 0 || !Number.isInteger(v))) {
|
||||
r.report('off-scale-spacing', rule.file, d.line, `${d.prop}: ${trunc(d.value, 28)} on ${rule.selector} is off the 4px scale`, { fix: 'a step of the 4px spacing scale' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sloplint check24a (gate 24a)
|
||||
function rawSpacing(ctx: LintContext, r: Reporter): void {
|
||||
const spaceVals = new Map<number, string>();
|
||||
for (const [k, v] of Object.entries(ctx.tokens)) {
|
||||
if (!/^--space-/.test(k)) continue;
|
||||
const px = pxOf(resolveVars(v, ctx.tokens));
|
||||
if (px != null && px > 0) spaceVals.set(px, k);
|
||||
}
|
||||
if (!spaceVals.size) return;
|
||||
const counts = new Map<string, number>();
|
||||
for (const rule of ctx.rules) {
|
||||
if (rule.keyframes || isTokenRule(rule)) continue;
|
||||
for (const d of rule.decls) {
|
||||
if (!SPACING_PROP.test(d.prop)) continue;
|
||||
const raw = d.value.replace(/var\([^)]*\)/g, ' ');
|
||||
for (const m of raw.matchAll(/(?<![\w.-])(-?\d+(?:\.\d+)?)(px|rem)\b/g)) {
|
||||
const px = Math.abs(pxOf(m[1] + m[2]) ?? 0);
|
||||
const token = spaceVals.get(px);
|
||||
if (!px || !token) continue;
|
||||
const n = (counts.get(rule.file) || 0) + 1;
|
||||
counts.set(rule.file, n);
|
||||
if (n <= 8) r.report('raw-spacing', rule.file, d.line, `${d.prop}: ${trunc(d.value, 28)} on ${trunc(rule.selector, 32)} where ${token} exists`, { fix: `var(${token})` });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [file, n] of counts) if (n > 8) r.report('raw-spacing', file, 1, `${n - 8} more raw spacing lengths in this file have a matching --space-* token`, { fix: 'the tokens' });
|
||||
}
|
||||
|
||||
/** An HTML file that is only an app shell: no styles, a module script, no local stylesheet. */
|
||||
const isShell = (d: Doc): boolean => d.kind === 'html' && !d.styles.length
|
||||
&& /<script\b[^>]*type\s*=\s*["']module["'][^>]*\bsrc\s*=\s*["'][^"']+\.(?:tsx?|jsx?|mjs|vue|svelte)["']/i.test(d.raw)
|
||||
&& ![...d.raw.matchAll(/<link\b[^>]*>/gi)].some((m) => /rel\s*=\s*["']stylesheet/i.test(m[0]) && !/href\s*=\s*["']https?:/i.test(m[0]));
|
||||
|
||||
// sloplint check34, static half (gate 34); the render tier owns horizontal-scroll
|
||||
function overflowX(ctx: LintContext, r: Reporter): void {
|
||||
const clipped = new Set<string>();
|
||||
let hidden: { file: string; line: number; sel: string } | null = null;
|
||||
for (const rule of ctx.rules) {
|
||||
if (!/(^|[\s,])(html|body)\b/.test(rule.selector)) continue;
|
||||
const targets = rule.selector.split(',').map((p) => (/^\s*(html|body)\s*$/.exec(p) || [])[1]).filter((t): t is string => !!t);
|
||||
for (const d of rule.decls) {
|
||||
if (d.prop !== 'overflow-x' && d.prop !== 'overflow') continue;
|
||||
if (/\bclip\b/.test(d.value)) for (const t of targets) clipped.add(t);
|
||||
else if (/\bhidden\b/.test(d.value)) hidden = { file: rule.file, line: d.line, sel: rule.selector };
|
||||
}
|
||||
}
|
||||
const hiddenUtil: Array<{ file: string; line: number; tag: string; util: string }> = [];
|
||||
for (const it of twGroup(ctx).items) {
|
||||
if (it.idx < 0 || !/^(html|body)$/.test(it.tag)) continue;
|
||||
const base = it.info.classes.map(stripVariants);
|
||||
if (base.some((c) => c === 'overflow-x-clip' || c === 'overflow-clip')) clipped.add(it.tag);
|
||||
const util = base.find((c) => c === 'overflow-x-hidden');
|
||||
if (util) hiddenUtil.push({ file: it.file, line: it.line, tag: it.tag, util });
|
||||
}
|
||||
for (const it of hiddenUtil) r.report('overflow-x-unclipped', it.file, it.line, `${it.util} on <${it.tag}>: hidden breaks position: sticky`, { fix: 'overflow-x-clip on both html and body, never hidden' });
|
||||
const pageish = ctx.docs.some((d) => (d.kind === 'html' && !isShell(d)) || (d.kind !== 'html' && d.tags.some((t) => !t.close && (t.name === 'html' || t.name === 'body'))));
|
||||
const file = ctx.cssFiles[0]?.file || ctx.docs[0]?.file;
|
||||
const both = clipped.has('html') && clipped.has('body');
|
||||
if (!both && hidden) {
|
||||
r.report('overflow-x-unclipped', hidden.file, hidden.line, `overflow-x: hidden on ${hidden.sel}: hidden breaks position: sticky`, { fix: 'overflow-x: clip on both html and body, never hidden' });
|
||||
} else if (!both && file && ctx.docs.length && pageish && !hiddenUtil.length) {
|
||||
r.report('overflow-x-unclipped', file, 1, clipped.size ? `overflow-x: clip on ${[...clipped][0]} only` : 'no overflow-x: clip on html and body', { fix: 'overflow-x: clip on both html and body' });
|
||||
}
|
||||
for (const rule of ctx.rules) {
|
||||
for (const d of rule.decls) {
|
||||
if (/^(width|min-width)$/.test(d.prop) && /(^|[^\w.-])100vw\b/.test(d.value)) {
|
||||
r.report('overflow-x-unclipped', rule.file, d.line, `${d.prop}: ${trunc(d.value, 28)} on ${rule.selector}: 100vw counts the scrollbar and overflows`, { fix: 'width: 100% with the container\'s padding' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sloplint check50 (gate 50)
|
||||
function bareFrTrack(ctx: LintContext, r: Reporter): void {
|
||||
for (const rule of ctx.rules) {
|
||||
for (const d of rule.decls) {
|
||||
if (d.prop !== 'grid-template-columns') continue;
|
||||
let flat = resolveVars(d.value, ctx.tokens);
|
||||
for (let k = 0; k < 4 && /\((?:[^()]*)\)/.test(flat); k++) flat = flat.replace(/(calc|min|max|clamp|var|fit-content)\(([^()]*)\)/g, 'X');
|
||||
flat = flat.replace(/minmax\([^()]*\)/g, 'MM');
|
||||
const bare = /(?<![\w.-])\d*\.?\d+fr\b/.exec(flat);
|
||||
if (!bare) continue;
|
||||
const holdsImages = innerHtmlWithClass(ctx, selectorClasses(rule.selector)).some((inner) => /<(img|picture|video)\b/i.test(inner));
|
||||
if (holdsImages) r.report('bare-fr-track', rule.file, d.line, `a bare ${bare[0]} track holds images on ${rule.selector}: the image's min-content width can push the grid past its box`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sloplint check50Wrap (gate 50)
|
||||
function displayHeadUnwrapped(ctx: LintContext, r: Reporter): void {
|
||||
const wrapCovered = (cls: string, tag: string) => ctx.rules.some((rule) => {
|
||||
const has = rule.decls.some((d) =>
|
||||
(d.prop === 'overflow-wrap' && /anywhere|break-word/.test(d.value))
|
||||
|| (d.prop === 'word-break' && /break-word|break-all/.test(d.value))
|
||||
|| (d.prop === 'hyphens' && /auto/.test(d.value)));
|
||||
if (!has) return false;
|
||||
if (/^\*|:where\(|html|body/.test(rule.selector)) return true;
|
||||
return (!!cls && cls.split(/\s+/).some((c) => c && rule.selector.includes('.' + c)))
|
||||
|| new RegExp(`(^|[\\s,>+~])${tag}\\b`).test(rule.selector);
|
||||
});
|
||||
for (const doc of ctx.docs) {
|
||||
for (let i = 0; i < doc.tags.length; i++) {
|
||||
const t = doc.tags[i];
|
||||
if (t.close || t.selfClosing) continue;
|
||||
const cls = getAttr(t.attrs, 'class') || '';
|
||||
if (!/^h[12]$/.test(t.name) && !/hero__(title|display)|section__title|__display/.test(cls)) continue;
|
||||
const inner = innerOf(doc, i);
|
||||
if (!inner) continue;
|
||||
const text = inner.inner.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const risky = text.split(' ').find((word) => word.split('-').some((seg) => seg.replace(/[^\w]/g, '').length >= 16));
|
||||
if (risky && !wrapCovered(cls, t.name)) {
|
||||
r.report('display-head-unwrapped', doc.file, t.line, `the display head holds "${trunc(risky, 30)}" with no overflow-wrap: at 320px that word overflows`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sloplint check53 (gate 53)
|
||||
function radioTabJump(ctx: LintContext, r: Reporter): void {
|
||||
const guarded = ctx.docs.some((doc) => /preventScroll/.test(doc.scripts));
|
||||
for (const rule of ctx.rules) {
|
||||
if (!/input\[type=["']?radio["']?\]|\.(radio|tab-input|tab-radio)\b/i.test(rule.selector)) continue;
|
||||
const abs = rule.decls.some((d) => d.prop === 'position' && /absolute|fixed/.test(d.value));
|
||||
const topZero = rule.decls.some((d) => d.prop === 'top' && /^0(px|rem)?$/.test(d.value.trim()));
|
||||
if (abs && topZero && !guarded) {
|
||||
r.report('radio-tab-jump', rule.file, rule.line, `radio tabs at position: absolute; top: 0 on ${rule.selector} with no script guard: focusing a radio scrolls the page to the top`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sloplint check56 (gate 56)
|
||||
function doubleSticky(ctx: LintContext, r: Reporter): void {
|
||||
const sticky = mergedRules(ctx).filter((m) =>
|
||||
m.decls.some((d) => d.prop === 'position' && /sticky/.test(d.value))
|
||||
&& m.decls.some((d) => d.prop === 'top' && /^(0|0px|0rem|-1px)$/.test(resolveVars(d.value, ctx.tokens).trim())));
|
||||
if (sticky.length < 2) return;
|
||||
const sels = sticky.map((m) => m.selector).join(' and ');
|
||||
r.report('double-sticky', sticky[1].file, sticky[1].line, `two elements stick at top: 0 (${trunc(sels, 52)}): the second slides under the first`);
|
||||
}
|
||||
|
||||
// sloplint heroIndexOf (gate 58): the section or header enclosing the h1
|
||||
function heroIndexOf(doc: Doc, h1Idx: number): number {
|
||||
for (let i = h1Idx - 1; i >= 0; i--) {
|
||||
const t = doc.tags[i];
|
||||
if (t.close || !/^(section|header)$/.test(t.name)) continue;
|
||||
const inner = innerOf(doc, i);
|
||||
if (inner && inner.endPos > doc.tags[h1Idx].pos) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
const countWords = (t: string): number => t.split(/\s+/).filter((w) => /[A-Za-z0-9]/.test(w)).length;
|
||||
|
||||
/** Words the visitor never sees: screen-reader-only, aria-hidden, hidden and credits blocks. */
|
||||
function hiddenWordCount(doc: Doc): number {
|
||||
let words = 0, hiddenEnd = -1;
|
||||
for (let i = 0; i < doc.tags.length; i++) {
|
||||
const t = doc.tags[i];
|
||||
if (t.close || t.selfClosing || t.pos < hiddenEnd) continue;
|
||||
const cls = getAttr(t.attrs, 'class') || '';
|
||||
const hiddenish = /(^|\s)(sr-only|visually-hidden|screen-reader-text|skip|skip-link)(\s|$)/i.test(cls)
|
||||
|| /aria-hidden\s*=\s*["']?true/i.test(t.attrs) || /(^|\s)hidden(\s|=|$)/i.test(t.attrs) || /(^|\s)data-credits(\s|=|$)/i.test(t.attrs);
|
||||
if (!hiddenish) continue;
|
||||
const inner = innerOf(doc, i);
|
||||
if (!inner) continue;
|
||||
words += countWords(plainText(inner.inner));
|
||||
hiddenEnd = inner.endPos;
|
||||
}
|
||||
return words;
|
||||
}
|
||||
|
||||
// sloplint check58Static (gate 58); the render tier measures the 900px window
|
||||
function wordBudget(ctx: LintContext, r: Reporter): void {
|
||||
for (const doc of ctx.docs) {
|
||||
const h1 = doc.tags.findIndex((t) => !t.close && t.name === 'h1');
|
||||
if (h1 >= 0) {
|
||||
const words = wordsOf(innerOf(doc, h1)?.inner || '');
|
||||
if (words.length > 7) r.report('screen-word-budget', doc.file, doc.tags[h1].line, `the h1 runs ${words.length} words ("${trunc(words.join(' '), 40)}")`, { fix: 'seven words or fewer, 50 characters or fewer' });
|
||||
const heroIdx = heroIndexOf(doc, h1);
|
||||
const hero = heroIdx >= 0 ? innerOf(doc, heroIdx) : null;
|
||||
if (hero) {
|
||||
for (let i = h1 + 1; i < doc.tags.length && doc.tags[i].pos < hero.endPos; i++) {
|
||||
const t = doc.tags[i];
|
||||
if (t.close || t.name !== 'p') continue;
|
||||
const pw = wordsOf(innerOf(doc, i)?.inner || '');
|
||||
if (pw.length > 12) r.report('screen-word-budget', doc.file, t.line, `the hero paragraph runs ${pw.length} words`, { fix: 'one sentence, twelve words or fewer' });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const sections = doc.tags.filter((t) => !t.close && t.name === 'section').length;
|
||||
const fullPage = sections >= 5 || doc.tags.some((t) => !t.close && t.name === 'footer');
|
||||
if (!fullPage || doc.kind !== 'html') continue;
|
||||
const total = countWords(doc.text) - hiddenWordCount(doc);
|
||||
if (total > 450) r.report('screen-word-budget', doc.file, 1, `${total} visible words on the page (450 at most)`, { fix: 'cut copy: one idea per screen; sections become objects' });
|
||||
}
|
||||
}
|
||||
|
||||
/** The px of a media query's min-width clause (px, em or rem), or null when it has none. */
|
||||
function minWidthPx(media: string | null): number | null {
|
||||
if (!media) return null;
|
||||
const m = /min-width\s*:\s*(\d+(?:\.\d+)?)(px|em|rem)|width\s*>=\s*(\d+(?:\.\d+)?)(px|em|rem)/i.exec(media);
|
||||
if (!m) return null;
|
||||
return pxOf((m[1] ?? m[3]) + (m[2] ?? m[4]));
|
||||
}
|
||||
|
||||
/** How many columns a grid-template-columns value lays down; null when the count depends on the viewport (auto-fit, auto-fill) or on a var. */
|
||||
function columnCount(value: string): number | null {
|
||||
if (/auto-fi(t|ll)|var\(/.test(value)) return null;
|
||||
let v = value;
|
||||
for (const m of value.matchAll(/repeat\(\s*(\d+)\s*,\s*((?:[^()]|\([^()]*\))*)\)/g)) {
|
||||
const inner = m[2].replace(/\([^()]*\)/g, 'X').trim().split(/\s+/).length;
|
||||
v = v.replace(m[0], Array(parseInt(m[1], 10) * inner).fill('X').join(' '));
|
||||
}
|
||||
if (/repeat\(/.test(v)) return null;
|
||||
return v.replace(/\([^()]*\)/g, 'X').replace(/\[[^\]]*\]/g, ' ').trim().split(/\s+/).filter(Boolean).length;
|
||||
}
|
||||
|
||||
/** Whether some container is capped: a max-width paired with auto margins, a max-width of 900px or more, or a Tailwind container or max-w utility. */
|
||||
function containerCapped(ctx: LintContext): boolean {
|
||||
for (const m of mergedRules(ctx)) {
|
||||
const cap = m.decls.find((d) => d.prop === 'max-width' || d.prop === 'max-inline-size');
|
||||
if (!cap || /^(none|100%|100vw)$/.test(cap.value.trim())) continue;
|
||||
const px = pxOf(resolveVars(cap.value, ctx.tokens));
|
||||
const centred = m.decls.some((d) => /^margin(-inline|-left|-right)?$/.test(d.prop) && /\bauto\b/.test(d.value));
|
||||
if (centred || (px != null && px >= 900)) return true;
|
||||
}
|
||||
return twGroup(ctx).items.some((it) => it.info.classes.map(stripVariants).some((c) => c === 'container' || /^max-w-(?!none$|full$|screen$|\[100)/.test(c)));
|
||||
}
|
||||
|
||||
const VISUAL_SEL = /(^|[\s,>+~(])(img|picture|video|figure|svg|canvas)\b|__(media|visual|image|img|photo|figure|art|cover|illustration)\b|\.(media|visual|image|img|photo|figure|art|cover|illustration|orb|blob)\b/i;
|
||||
|
||||
// gate 44d, static half (sloplint measures 44d in the render tier only): what the CSS alone proves about 1920px
|
||||
function wideContainer(ctx: LintContext, r: Reporter): void {
|
||||
if (!ctx.docs.some((d) => d.kind === 'html') || containerCapped(ctx)) return;
|
||||
const merged = mergedRules(ctx);
|
||||
const columnsBelow = (selector: string): number => {
|
||||
let n = 0;
|
||||
for (const m of merged) {
|
||||
if (m.selector !== selector || (minWidthPx(m.media) ?? 0) > 1440) continue;
|
||||
for (const d of m.decls) if (d.prop === 'grid-template-columns') n = Math.max(n, columnCount(resolveVars(d.value, ctx.tokens)) ?? 0);
|
||||
}
|
||||
return n;
|
||||
};
|
||||
const seen = new Set<string>();
|
||||
for (const rule of ctx.rules) {
|
||||
if (rule.keyframes || isTokenRule(rule)) continue;
|
||||
const wide = minWidthPx(rule.media);
|
||||
for (const d of rule.decls) {
|
||||
if (d.prop === 'grid-template-columns' && wide != null && wide > 1440) {
|
||||
const n = columnCount(resolveVars(d.value, ctx.tokens));
|
||||
const below = columnsBelow(rule.selector);
|
||||
if (n != null && below > 0 && n > below && !seen.has('cols:' + rule.selector)) {
|
||||
seen.add('cols:' + rule.selector);
|
||||
r.report('wide-container', rule.file, d.line, `no container has a max-width, and ${rule.selector} gains columns at ${rule.media} (${below} to ${n})`, { fix: 'cap the container at 1360 to 1520px and centre it; hold the column count' });
|
||||
}
|
||||
}
|
||||
if (!/^(width|height|inline-size|block-size|flex-basis)$/.test(d.prop) || !VISUAL_SEL.test(rule.selector)) continue;
|
||||
const v = resolveVars(d.value, ctx.tokens);
|
||||
const capped = /clamp\(|min\(/.test(v) || rule.decls.some((x) => /^max-(width|height|inline-size|block-size)$/.test(x.prop));
|
||||
if (/\d\s*vw\b/i.test(v) && !capped && !seen.has('vw:' + rule.selector)) {
|
||||
seen.add('vw:' + rule.selector);
|
||||
r.report('wide-container', rule.file, d.line, `no container has a max-width, and ${rule.selector} is sized in vw with no cap (${d.prop}: ${trunc(v, 24)})`, { fix: 'cap the container at 1360 to 1520px; give the visual a max-width or a clamp()' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function run(ctx: LintContext, r: Reporter): void {
|
||||
void ctx; void r;
|
||||
iconTiles(ctx, r);
|
||||
sideStripes(ctx, r);
|
||||
nestedCards(ctx, r);
|
||||
vhHero(ctx, r);
|
||||
offScaleSpacing(ctx, r);
|
||||
rawSpacing(ctx, r);
|
||||
overflowX(ctx, r);
|
||||
bareFrTrack(ctx, r);
|
||||
displayHeadUnwrapped(ctx, r);
|
||||
radioTabJump(ctx, r);
|
||||
doubleSticky(ctx, r);
|
||||
wordBudget(ctx, r);
|
||||
wideContainer(ctx, r);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* The motion and interaction rule family of the design lint (lib/design-lint/rules/motion.ts): for every rule id, a
|
||||
* planted positive with its line and grade, and a negative control at the edge of the threshold. The shared fixtures
|
||||
* (slopped.html, clean/) are checked for this family's rules too.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { formatFinding, lint, type Finding } from '../lib/design-lint/index';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const FIXTURES = path.join(ROOT, 'test', 'fixtures', 'design-lint');
|
||||
const FAMILY = ['layout-transition', 'spacing-transition', 'hover-tells', 'bounce-easing', 'focus-ring-fade', 'tooltip-focus-delay', 'autorotate-no-pause', 'missing-focus-visible', 'missing-states', 'missing-reduced-motion', 'autoplay-unmuted', 'lazy-lcp', 'video-without-poster', 'input-state-shift', 'control-height', 'content-hidden-at-rest'];
|
||||
|
||||
const TOKENS = ':root { --color-paper: oklch(97% 0.01 80); --color-ink: oklch(20% 0.02 250); --font-display: "Fraunces", serif; --font-body: "Source Serif 4", serif; }\nhtml, body { overflow-x: clip; }';
|
||||
const STATES = 'a:focus-visible, .button:focus-visible { outline: 2px solid var(--color-ink); }\n.button:active { transform: translateY(1px); }\n.button:disabled { opacity: 0.5; cursor: not-allowed; }';
|
||||
const REDUCE = '@media (prefers-reduced-motion: reduce) { * { animation: none; transition: none; } }';
|
||||
|
||||
function page(css: string, body: string, opts: { states?: boolean; reduce?: boolean } = {}): string {
|
||||
const base = [TOKENS, opts.states === false ? '' : STATES, opts.reduce ? REDUCE : ''].filter(Boolean).join('\n');
|
||||
return `<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<title>Fixture</title>\n<style>\n${base}\n${css}\n</style>\n</head>\n<body>\n<main>\n${body}\n</main>\n</body>\n</html>\n`;
|
||||
}
|
||||
|
||||
async function lintPage(html: string, opts: { scope?: 'page' | 'component' } = {}): Promise<Finding[]> {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-motion-'));
|
||||
const file = path.join(dir, 'index.html');
|
||||
fs.writeFileSync(file, html);
|
||||
return (await lint([file], { cwd: dir, scope: opts.scope, warn: () => {} })).findings;
|
||||
}
|
||||
|
||||
const only = (findings: Finding[], rule: string) => findings.filter((f) => f.rule === rule);
|
||||
const lineOf = (html: string, needle: string): number => html.split('\n').findIndex((l) => l.includes(needle)) + 1;
|
||||
const hits = (findings: Finding[], rule: string) => only(findings, rule).map((f) => [f.grade, f.line]);
|
||||
const none = (findings: Finding[], rule: string) => expect(only(findings, rule).map(formatFinding)).toEqual([]);
|
||||
const BUTTON = '<a class="button" href="/book">Book the walkthrough</a>';
|
||||
|
||||
describe('design-lint motion: layout-transition (gate 10a)', () => {
|
||||
test('transition: all, a transitioned width, and a keyframe animating height each block', async () => {
|
||||
const html = page('.card { transition: all 0.3s ease; }\n.drawer { transition: width 200ms; }\n@keyframes grow {\n from { height: 0; }\n to { height: 10rem; }\n}', BUTTON, { reduce: true });
|
||||
const f = await lintPage(html);
|
||||
expect(hits(f, 'layout-transition')).toEqual([['blocks', lineOf(html, 'all 0.3s')], ['blocks', lineOf(html, 'width 200ms')], ['blocks', lineOf(html, 'height: 0')], ['blocks', lineOf(html, 'height: 10rem')]]);
|
||||
expect(only(f, 'layout-transition').map((x) => x.evidence)).toEqual(['transition: all on .card', 'transitioning width on .drawer', '@keyframes grow animates height', '@keyframes grow animates height']);
|
||||
});
|
||||
test('transform and opacity transitions, and a max-height keyframe, are not layout transitions', async () => {
|
||||
const html = page('.card { transition: transform 200ms, opacity 200ms; }\n@keyframes open { from { max-height: 0; opacity: 0; } to { max-height: 20rem; opacity: 1; } }', BUTTON, { reduce: true });
|
||||
none(await lintPage(html), 'layout-transition');
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint motion: spacing-transition (gate 10b)', () => {
|
||||
test('transitioning padding or animating margin reports at ask', async () => {
|
||||
const html = page('.card { transition: padding 200ms; }\n@keyframes nudge { to { margin-left: 1rem; } }', BUTTON, { reduce: true });
|
||||
const f = await lintPage(html);
|
||||
expect(hits(f, 'spacing-transition')).toEqual([['ask', lineOf(html, 'padding 200ms')], ['ask', lineOf(html, 'margin-left: 1rem')]]);
|
||||
});
|
||||
test('a transform transition is clean', async () => {
|
||||
none(await lintPage(page('.card { transition: transform 200ms; }', BUTTON, { reduce: true })), 'spacing-transition');
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint motion: hover-tells (gate 11)', () => {
|
||||
test('one hover scale stamped on two selectors, an image scaling on hover, and the lift on three cards each report at ask', async () => {
|
||||
const html = page([
|
||||
'.a:hover { transform: scale(1.05); }',
|
||||
'.b:hover { transform: scale(1.05); }',
|
||||
'.card__img:hover { transform: scale(1.1); }',
|
||||
'.c1:hover { transform: translateY(-4px); box-shadow: 0 8px 24px rgba(0 0 0 / 0.1); }',
|
||||
'.c2:hover { transform: translateY(-4px); box-shadow: 0 8px 24px rgba(0 0 0 / 0.1); }',
|
||||
'.c3:hover { transform: translateY(-4px); box-shadow: 0 8px 24px rgba(0 0 0 / 0.1); }',
|
||||
].join('\n'), BUTTON, { reduce: true });
|
||||
const f = await lintPage(html);
|
||||
expect(hits(f, 'hover-tells')).toEqual([['ask', lineOf(html, '.a:hover')], ['ask', lineOf(html, '.card__img:hover')], ['ask', lineOf(html, '.c3:hover')]]);
|
||||
expect(only(f, 'hover-tells').map((x) => x.evidence)).toEqual([
|
||||
'the same hover scale(1.05) on 2 selectors',
|
||||
'an image transforms on hover (.card__img:hover)',
|
||||
'the hover lift (translateY plus shadow) on 3 selectors (.c1, .c2, .c3)',
|
||||
]);
|
||||
});
|
||||
test('one hover scale on one selector, a colour change on an image, and a lift on two cards are within bounds', async () => {
|
||||
const html = page('.a:hover { transform: scale(1.02); }\n.card__img:hover { opacity: 0.9; }\n.c1:hover, .c2:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0 0 0 / 0.1); }', BUTTON, { reduce: true });
|
||||
none(await lintPage(html), 'hover-tells');
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint motion: bounce-easing (gate 12)', () => {
|
||||
test('an overshooting cubic-bezier on a UI state reports at ask, through a token too', async () => {
|
||||
const html = page(':root { --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); }\n.button:hover { transition: transform 300ms var(--ease-spring); }\n.modal { animation: pop 300ms cubic-bezier(0.2, -0.3, 0.4, 1); }', BUTTON, { reduce: true });
|
||||
const f = await lintPage(html);
|
||||
expect(hits(f, 'bounce-easing')).toEqual([['ask', lineOf(html, '.button:hover')], ['ask', lineOf(html, '.modal')]]);
|
||||
expect(only(f, 'bounce-easing')[0].evidence).toBe('overshoot easing cubic-bezier(0.34, 1.56, 0.64, 1) on .button:hover');
|
||||
});
|
||||
test('an exponential ease-out is clean', async () => {
|
||||
none(await lintPage(page('.button:hover { transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1); }', BUTTON, { reduce: true })), 'bounce-easing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint motion: focus-ring-fade (gate 15)', () => {
|
||||
test('a transition on the focus rule, and a resting transition of the property the ring is drawn with, both block', async () => {
|
||||
const html = page('.cta:focus-visible { outline: 2px solid var(--color-ink); transition: outline 200ms ease; }\n.pill { transition: box-shadow 200ms; }\n.pill:focus-visible { box-shadow: 0 0 0 3px var(--color-ink); }', '<button class="cta">Go</button><button class="pill">Go</button>');
|
||||
const f = await lintPage(html);
|
||||
expect(hits(f, 'focus-ring-fade')).toEqual([['blocks', lineOf(html, 'transition: outline')], ['blocks', lineOf(html, 'transition: box-shadow')]]);
|
||||
expect(only(f, 'focus-ring-fade').map((x) => x.evidence)).toEqual(['the focus ring transitions in on .cta:focus-visible', '.pill transitions box-shadow while :focus-visible sets it, so the ring fades in']);
|
||||
});
|
||||
test('a ring drawn with outline while only background transitions is instant', async () => {
|
||||
none(await lintPage(page('.cta { transition: background 200ms; }\n.cta:focus-visible { outline: 2px solid var(--color-ink); }', '<button class="cta">Go</button>')), 'focus-ring-fade');
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint motion: tooltip-focus-delay (gate 17)', () => {
|
||||
test('a tooltip delayed as long on focus as on hover reports as a suspected tell, at ask', async () => {
|
||||
const html = page('.tooltip:hover .tip { transition-delay: 800ms; opacity: 1; }\n.tooltip:focus-within .tip { transition-delay: 800ms; opacity: 1; }', '<span class="tooltip" tabindex="0">?<span class="tip">Help</span></span>');
|
||||
const f = await lintPage(html);
|
||||
expect(only(f, 'tooltip-focus-delay').map((x) => [x.grade, x.tier, x.line])).toEqual([['ask', 'ask', lineOf(html, ':focus-within')]]);
|
||||
});
|
||||
test('an instant focus tooltip is clean', async () => {
|
||||
none(await lintPage(page('.tooltip:hover .tip { transition-delay: 800ms; opacity: 1; }\n.tooltip:focus-within .tip { transition-delay: 0ms; opacity: 1; }', '<span class="tooltip" tabindex="0">?<span class="tip">Help</span></span>')), 'tooltip-focus-delay');
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint motion: autorotate-no-pause (gate 18)', () => {
|
||||
test('an infinite animation on a marquee with no paused state reports as a suspected tell, at ask', async () => {
|
||||
const html = page('.marquee { animation: slide 20s linear infinite; }\n@keyframes slide { to { transform: translateX(-50%); } }', '<div class="marquee">Logos</div>', { reduce: true });
|
||||
const f = await lintPage(html);
|
||||
expect(only(f, 'autorotate-no-pause').map((x) => [x.grade, x.line, x.evidence])).toEqual([['ask', lineOf(html, 'infinite'), 'an infinite animation on .marquee with no paused state on hover or focus']]);
|
||||
});
|
||||
test('a marquee that pauses on hover is clean', async () => {
|
||||
none(await lintPage(page('.marquee { animation: slide 20s linear infinite; }\n.marquee:hover, .marquee:focus-within { animation-play-state: paused; }\n@keyframes slide { to { transform: translateX(-50%); } }', '<div class="marquee">Logos</div>', { reduce: true })), 'autorotate-no-pause');
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint motion: missing-focus-visible and missing-states (gates 26a, 26b)', () => {
|
||||
test('a page with a button and no :focus-visible, :active or :disabled style reports all three at the stylesheet', async () => {
|
||||
const html = page('.button { color: var(--color-ink); }', '<button class="button">Go</button>', { states: false });
|
||||
const f = await lintPage(html);
|
||||
expect(only(f, 'missing-focus-visible').map((x) => [x.grade, x.line, x.evidence])).toEqual([['blocks', 1, 'no :focus-visible style anywhere: keyboard users cannot see where they are']]);
|
||||
expect(only(f, 'missing-states').map((x) => [x.grade, x.line, x.evidence])).toEqual([['ask', 1, 'no :active style anywhere'], ['ask', 1, 'no :disabled style anywhere']]);
|
||||
});
|
||||
test('a ring, an active state and a disabled state satisfy both; a page with nothing interactive asks for none', async () => {
|
||||
const f = await lintPage(page('', BUTTON));
|
||||
none(f, 'missing-focus-visible');
|
||||
none(f, 'missing-states');
|
||||
const inert = await lintPage(page('', '<p>Only prose.</p>', { states: false }));
|
||||
none(inert, 'missing-focus-visible');
|
||||
none(inert, 'missing-states');
|
||||
});
|
||||
test('a link-only page needs no :disabled, and an aria-disabled selector counts as the disabled state', async () => {
|
||||
none(await lintPage(page('a:focus-visible { outline: 2px solid; }\na:active { color: red; }', '<a href="/">Home</a>', { states: false })), 'missing-states');
|
||||
none(await lintPage(page('.button:focus-visible { outline: 2px solid; }\n.button:active { color: red; }\n.button[aria-disabled="true"] { opacity: 0.5; cursor: not-allowed; }', '<button class="button">Go</button>', { states: false })), 'missing-states');
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint motion: missing-reduced-motion (gate 27)', () => {
|
||||
test('keyframes with no prefers-reduced-motion block anywhere block at the keyframes', async () => {
|
||||
const html = page('.bar { animation: grow 2s ease infinite; }\n@keyframes grow { from { opacity: 0; } to { opacity: 1; } }', BUTTON);
|
||||
const f = await lintPage(html);
|
||||
expect(only(f, 'missing-reduced-motion').map((x) => [x.grade, x.line, x.evidence])).toEqual([['blocks', lineOf(html, 'from { opacity: 0'), 'motion present and no prefers-reduced-motion block anywhere']]);
|
||||
const transition = page('.card { transition: transform 200ms; }', BUTTON);
|
||||
expect(only(await lintPage(transition), 'missing-reduced-motion').map((x) => x.line)).toEqual([1]);
|
||||
});
|
||||
test('a prefers-reduced-motion block answers it, and a page with no motion needs none', async () => {
|
||||
none(await lintPage(page('.bar { animation: grow 2s ease infinite; }\n@keyframes grow { from { opacity: 0; } to { opacity: 1; } }', BUTTON, { reduce: true })), 'missing-reduced-motion');
|
||||
none(await lintPage(page('.card { transition: background 200ms; }', BUTTON)), 'missing-reduced-motion');
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint motion: autoplay-unmuted, lazy-lcp and video-without-poster (gates 28a, 28b)', () => {
|
||||
test('an autoplaying video without muted blocks; a video without poster asks; a lazy first image or video is a suspected LCP tell at ask', async () => {
|
||||
const html = page('', '<section class="hero">\n<h1>Two days to close</h1>\n<video autoplay loop playsinline src="a.mp4"></video>\n<img loading="lazy" src="a.png" alt="A">\n</section>');
|
||||
const f = await lintPage(html);
|
||||
expect(only(f, 'autoplay-unmuted').map((x) => [x.grade, x.line])).toEqual([['blocks', lineOf(html, '<video')]]);
|
||||
expect(only(f, 'video-without-poster').map((x) => [x.grade, x.line])).toEqual([['ask', lineOf(html, '<video')]]);
|
||||
none(f, 'lazy-lcp');
|
||||
const lazyImg = page('', '<section class="hero">\n<h1>Two days to close</h1>\n<img loading="lazy" src="a.png" alt="A">\n</section>');
|
||||
expect(only(await lintPage(lazyImg), 'lazy-lcp').map((x) => [x.grade, x.tier, x.line])).toEqual([['ask', 'ask', lineOf(lazyImg, '<img')]]);
|
||||
const lazyVideo = page('', '<video muted loading="lazy" poster="p.jpg" src="a.mp4"></video>');
|
||||
expect(only(await lintPage(lazyVideo), 'lazy-lcp').map((x) => x.evidence)).toEqual(['the first video loads lazily and is likely the largest paint']);
|
||||
});
|
||||
test('autoplay muted with a poster, an eager first image and a lazy second image are clean', async () => {
|
||||
const f = await lintPage(page('', '<section class="hero"><h1>Two days to close</h1><video autoplay muted loop playsinline poster="p.jpg" src="a.mp4"></video><img src="a.png" alt="A"></section>\n<img loading="lazy" src="b.png" alt="B">'));
|
||||
none(f, 'autoplay-unmuted');
|
||||
none(f, 'video-without-poster');
|
||||
none(f, 'lazy-lcp');
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint motion: input-state-shift (gate 39a)', () => {
|
||||
const field = '<form><label for="e">Email</label><input id="e" type="email"><button class="button" type="submit">Send</button></form>';
|
||||
test('a border that changes width on focus, a focus state drawn with border, and disabled by opacity alone each block', async () => {
|
||||
const html = page('input { border: 1px solid var(--color-ink); }\ninput:focus { border: 2px solid var(--color-ink); }\n.field:focus { border-color: var(--color-ink); }\n.button:disabled { opacity: 0.5; }', field, { states: false });
|
||||
const f = await lintPage(html);
|
||||
expect(only(f, 'input-state-shift').map((x) => [x.grade, x.line, x.evidence])).toEqual([
|
||||
['blocks', lineOf(html, 'input:focus'), 'the border goes from 1px to 2px on input:focus, so the field shifts'],
|
||||
['blocks', lineOf(html, 'input:focus'), 'the focus state is drawn with border on input:focus'],
|
||||
['blocks', lineOf(html, '.field:focus'), 'the focus state is drawn with border on .field:focus'],
|
||||
['blocks', lineOf(html, '.button:disabled'), 'disabled by opacity alone on .button:disabled'],
|
||||
]);
|
||||
});
|
||||
test('a constant border with the focus state on outline, and a disabled state with cursor: not-allowed, are clean', async () => {
|
||||
none(await lintPage(page('input { border: 1px solid var(--color-ink); }\ninput:focus { border-color: var(--color-ink); outline: 2px solid var(--color-ink); outline-offset: 2px; }', field)), 'input-state-shift');
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint motion: control-height (gate 39b, static half)', () => {
|
||||
test('a control whose CSS fixes its height outside 44 to 52px reports at ask on a page', async () => {
|
||||
const html = page('.button { height: 36px; }\ninput { min-height: 3.5rem; }', '<form><input type="text" aria-label="Name">' + BUTTON + '</form>');
|
||||
const f = await lintPage(html);
|
||||
expect(only(f, 'control-height').map((x) => [x.grade, x.line, x.evidence])).toEqual([['ask', lineOf(html, 'height: 36px'), 'height: 36px (36px) on .button: controls stand 44 to 52px'], ['ask', lineOf(html, '3.5rem'), 'min-height: 3.5rem (56px) on input: controls stand 44 to 52px']]);
|
||||
none(await lintPage(html, { scope: 'component' }), 'control-height');
|
||||
});
|
||||
test('48px controls, a hidden radio, and an icon button are not reported', async () => {
|
||||
none(await lintPage(page('.button { min-height: 48px; }\ninput[type="radio"] { height: 1px; }\n.icon-button { height: 32px; }', '<form><input type="radio" aria-label="A"><button class="icon-button" aria-label="Close"></button>' + BUTTON + '</form>')), 'control-height');
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint motion: content-hidden-at-rest (gate 62)', () => {
|
||||
test('a reveal class hidden at rest with a transform, waiting for a script, blocks at the hiding declaration', async () => {
|
||||
const html = page('.reveal {\n opacity: 0;\n transform: translateY(20px);\n transition: opacity 400ms, transform 400ms;\n}\n.reveal.is-visible { opacity: 1; transform: none; }', '<p class="reveal">Hidden until observed.</p>', { reduce: true });
|
||||
const f = await lintPage(html);
|
||||
expect(only(f, 'content-hidden-at-rest').map((x) => [x.grade, x.line, x.evidence])).toEqual([['blocks', lineOf(html, 'opacity: 0;'), '.reveal is hidden at rest (opacity: 0) and needs a script to appear']]);
|
||||
});
|
||||
test('hiding under a .js class, animating from hidden to visible, and a hidden tooltip are clean', async () => {
|
||||
none(await lintPage(page('.js .reveal { opacity: 0; transform: translateY(20px); }', '<p class="reveal">Hidden until observed.</p>', { reduce: true })), 'content-hidden-at-rest');
|
||||
none(await lintPage(page('.rise { opacity: 0; animation: rise 600ms ease-out forwards; }\n@keyframes rise { to { opacity: 1; } }', '<p class="rise">Rises.</p>', { reduce: true })), 'content-hidden-at-rest');
|
||||
none(await lintPage(page('.tooltip { opacity: 0; transform: translateY(4px); }', '<span class="tooltip">Help</span>', { reduce: true })), 'content-hidden-at-rest');
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint motion: the shared fixtures', () => {
|
||||
test('slopped.html trips the planted motion tells at their lines', async () => {
|
||||
const f = (await lint([path.join(FIXTURES, 'slopped.html')], { cwd: ROOT, warn: () => {} })).findings;
|
||||
const mine = f.filter((x) => FAMILY.includes(x.rule)).map((x) => [x.rule, x.grade, x.line]);
|
||||
expect(mine).toEqual([
|
||||
['focus-ring-fade', 'blocks', 85],
|
||||
['layout-transition', 'blocks', 79],
|
||||
['layout-transition', 'blocks', 97],
|
||||
['layout-transition', 'blocks', 98],
|
||||
['missing-reduced-motion', 'blocks', 97],
|
||||
['missing-states', 'ask', 1],
|
||||
['missing-states', 'ask', 1],
|
||||
]);
|
||||
});
|
||||
test('the clean page raises no motion finding', async () => {
|
||||
const f = (await lint([path.join(FIXTURES, 'clean')], { cwd: ROOT, warn: () => {} })).findings;
|
||||
expect(f.filter((x) => FAMILY.includes(x.rule)).map(formatFinding)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* The structure rule family of the design lint (lib/design-lint/rules/structure.ts): for every rule id, a planted
|
||||
* positive with its line and grade, and a negative control at the edge of the threshold. The shared fixtures
|
||||
* (slopped.html, clean/) are checked for this family's rules too.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { formatFinding, lint, type Finding } from '../lib/design-lint/index';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const FIXTURES = path.join(ROOT, 'test', 'fixtures', 'design-lint');
|
||||
const FAMILY = ['template-cards', 'nested-cards', 'vh-height', 'raw-spacing', 'off-scale-spacing', 'overflow-x-unclipped', 'wide-container', 'bare-fr-track', 'display-head-unwrapped', 'radio-tab-jump', 'double-sticky', 'screen-word-budget'];
|
||||
|
||||
const TOKENS = ':root { --color-paper: oklch(97% 0.01 80); --color-ink: oklch(20% 0.02 250); --font-display: "Fraunces", serif; --font-body: "Source Serif 4", serif; }';
|
||||
const CLIP = 'html, body { overflow-x: clip; }';
|
||||
const STATES = 'a:focus-visible, .button:focus-visible { outline: 2px solid var(--color-ink); }\n.button:active { transform: translateY(1px); }\n.button:disabled { opacity: 0.5; cursor: not-allowed; }';
|
||||
|
||||
function page(css: string, body: string, opts: { clip?: boolean } = {}): string {
|
||||
const base = [TOKENS, opts.clip === false ? '' : CLIP, STATES].filter(Boolean).join('\n');
|
||||
return `<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<title>Fixture</title>\n<style>\n${base}\n${css}\n</style>\n</head>\n<body>\n<main>\n${body}\n</main>\n</body>\n</html>\n`;
|
||||
}
|
||||
|
||||
async function lintPage(html: string, opts: { scope?: 'page' | 'component'; name?: string } = {}): Promise<Finding[]> {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-structure-'));
|
||||
const file = path.join(dir, opts.name ?? 'index.html');
|
||||
fs.writeFileSync(file, html);
|
||||
return (await lint([file], { cwd: dir, scope: opts.scope, warn: () => {} })).findings;
|
||||
}
|
||||
|
||||
const only = (findings: Finding[], rule: string) => findings.filter((f) => f.rule === rule);
|
||||
const lineOf = (html: string, needle: string): number => html.split('\n').findIndex((l) => l.includes(needle)) + 1;
|
||||
const hits = (findings: Finding[], rule: string) => only(findings, rule).map((f) => [f.grade, f.line]);
|
||||
const none = (findings: Finding[], rule: string) => expect(only(findings, rule).map(formatFinding)).toEqual([]);
|
||||
|
||||
describe('design-lint structure: template-cards (gate 3)', () => {
|
||||
test('three equal icon-above-heading tiles and a thick coloured side stripe report at ask', async () => {
|
||||
const html = page('.features { display: grid; grid-template-columns: repeat(3, 1fr); }\n.note { border-left: 4px solid var(--color-ink); }',
|
||||
'<section class="features"><div><span class="icon"></span><h3>Sync</h3></div><div><span class="icon"></span><h3>Share</h3></div><div><span class="icon"></span><h3>Ship</h3></div></section>\n<p class="note">A note</p>');
|
||||
const f = await lintPage(html);
|
||||
expect(hits(f, 'template-cards')).toEqual([['ask', lineOf(html, 'repeat(3, 1fr)')], ['ask', lineOf(html, 'border-left: 4px')]]);
|
||||
expect(only(f, 'template-cards')[0].evidence).toBe('three equal columns of icon-above-heading tiles on .features');
|
||||
});
|
||||
test('three text columns without icons and a hairline side border are not the template', async () => {
|
||||
const html = page('.features { display: grid; grid-template-columns: repeat(3, 1fr); }\n.note { border-left: 1px solid var(--color-ink); }',
|
||||
'<section class="features"><div><h3>Sync</h3><p>Once.</p></div><div><h3>Share</h3><p>Twice.</p></div><div><h3>Ship</h3><p>Daily.</p></div></section>\n<p class="note">A note</p>');
|
||||
none(await lintPage(html), 'template-cards');
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint structure: nested-cards (gate 4)', () => {
|
||||
test('a card inside a card blocks at the inner card', async () => {
|
||||
const html = page('.card { border: 1px solid var(--color-ink); }', '<div class="card">\n <h3>Outer</h3>\n <div class="card feature-card">\n <p>Inner</p>\n </div>\n</div>');
|
||||
const f = await lintPage(html);
|
||||
expect(hits(f, 'nested-cards')).toEqual([['blocks', lineOf(html, 'card feature-card')]]);
|
||||
expect(only(f, 'nested-cards')[0].evidence).toBe('a card inside a card: class="card feature-card"');
|
||||
});
|
||||
test('a card inside a card grid, and sibling cards, are one containment layer', async () => {
|
||||
const html = page('.card { border: 1px solid var(--color-ink); }', '<div class="card-grid"><div class="card"><p>One</p></div><div class="card"><p>Two</p></div></div>');
|
||||
none(await lintPage(html), 'nested-cards');
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint structure: vh-height (gate 6a)', () => {
|
||||
test('a hero sized in vh blocks, by the hero word or by the class of the section holding the h1', async () => {
|
||||
const html = page('.hero { min-height: 100vh; }\n.intro { height: 80vh; }', '<section class="intro"><h1>Two days to close</h1></section>\n<section class="hero"><p>More</p></section>');
|
||||
const f = await lintPage(html);
|
||||
expect(hits(f, 'vh-height')).toEqual([['blocks', lineOf(html, '100vh')], ['blocks', lineOf(html, '80vh')]]);
|
||||
});
|
||||
test('svh and dvh pass, and vh on a non-hero rule is not this rule', async () => {
|
||||
const html = page('.hero { min-height: 100svh; }\n.sheet { height: 100dvh; }\n.band { min-height: 40vh; }', '<section class="hero"><h1>Two days to close</h1></section>\n<div class="band"></div>');
|
||||
none(await lintPage(html), 'vh-height');
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint structure: raw-spacing (gate 24a)', () => {
|
||||
test('a raw length that matches a --space-* token blocks with that token as the fix; past eight in one file the rest fold into one line', async () => {
|
||||
const rules = Array.from({ length: 10 }, (_, i) => `.r${i} { padding: 32px; }`).join('\n');
|
||||
const html = page(':root { --space-4: 2rem; --space-2: 1rem; }\n.box { margin: 0 1rem; }\n' + rules, '<div class="box">x</div>');
|
||||
const f = only(await lintPage(html), 'raw-spacing');
|
||||
expect(f.find((x) => x.line === lineOf(html, 'margin: 0 1rem'))).toMatchObject({ grade: 'blocks', evidence: 'margin: 0 1rem on .box where --space-2 exists', fix: 'var(--space-2)' });
|
||||
expect(f).toHaveLength(9);
|
||||
expect(f.filter((x) => x.line === 1).map((x) => x.evidence)).toEqual(['3 more raw spacing lengths in this file have a matching --space-* token']);
|
||||
});
|
||||
test('tokens in use, a length no token matches, and a page with no spacing tokens are all clean', async () => {
|
||||
const withTokens = page(':root { --space-4: 2rem; }\n.box { padding: var(--space-4); margin: 0 36px; }', '<div class="box">x</div>');
|
||||
none(await lintPage(withTokens), 'raw-spacing');
|
||||
const noTokens = page('.box { padding: 32px; }', '<div class="box">x</div>');
|
||||
none(await lintPage(noTokens), 'raw-spacing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint structure: off-scale-spacing (gate 24b)', () => {
|
||||
test('a px spacing off the 4px scale reports at ask', async () => {
|
||||
const html = page('.nav { padding: 17px; }\n.gap { gap: 10px 8px; }', '<div class="nav">x</div>');
|
||||
const f = await lintPage(html);
|
||||
expect(hits(f, 'off-scale-spacing')).toEqual([['ask', lineOf(html, '17px')], ['ask', lineOf(html, '10px 8px')]]);
|
||||
expect(only(f, 'off-scale-spacing')[0].evidence).toBe('padding: 17px on .nav is off the 4px scale');
|
||||
});
|
||||
test('multiples of four, 1px and 2px nudges, and rem values are on the scale', async () => {
|
||||
const html = page('.nav { padding: 16px 2px; margin: 1px 0 1.25rem; gap: 24px; }', '<div class="nav">x</div>');
|
||||
none(await lintPage(html), 'off-scale-spacing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint structure: overflow-x-unclipped (gate 34, static half)', () => {
|
||||
test('overflow-x: hidden, a 100vw width, clip on one of html and body, and no clip at all each report at ask', async () => {
|
||||
const hidden = page('html { overflow-x: hidden; }\n.full { width: 100vw; }', '<div class="full">x</div>', { clip: false });
|
||||
const f = await lintPage(hidden);
|
||||
expect(hits(f, 'overflow-x-unclipped')).toEqual([['ask', lineOf(hidden, 'overflow-x: hidden')], ['ask', lineOf(hidden, '100vw')]]);
|
||||
expect(only(f, 'overflow-x-unclipped').map((x) => x.evidence)).toEqual(['overflow-x: hidden on html: hidden breaks position: sticky', 'width: 100vw on .full: 100vw counts the scrollbar and overflows']);
|
||||
const one = page('html { overflow-x: clip; }', '<p>x</p>', { clip: false });
|
||||
expect(only(await lintPage(one), 'overflow-x-unclipped').map((x) => [x.line, x.evidence])).toEqual([[1, 'overflow-x: clip on html only']]);
|
||||
const neither = page('', '<p>x</p>', { clip: false });
|
||||
expect(only(await lintPage(neither), 'overflow-x-unclipped').map((x) => [x.line, x.evidence])).toEqual([[1, 'no overflow-x: clip on html and body']]);
|
||||
});
|
||||
test('overflow-x: clip on both html and body with width: 100% is clean', async () => {
|
||||
const html = page('.full { width: 100%; }', '<div class="full">x</div>');
|
||||
none(await lintPage(html), 'overflow-x-unclipped');
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint structure: wide-container (gate 44d, static half)', () => {
|
||||
const grid = '.grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }\n@media (min-width: 1600px) { .grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }\n.hero__visual { width: 60vw; }';
|
||||
const body = '<section class="hero"><h1>Two days to close</h1><div class="hero__visual"></div></section>\n<ul class="grid"><li>a</li><li>b</li><li>c</li></ul>';
|
||||
test('with no capped container, a grid that gains a column past 1440px and a vw-sized visual with no cap block', async () => {
|
||||
const html = page(grid, body);
|
||||
const f = await lintPage(html);
|
||||
expect(hits(f, 'wide-container')).toEqual([['blocks', lineOf(html, 'repeat(4')], ['blocks', lineOf(html, '60vw')]]);
|
||||
expect(only(f, 'wide-container').map((x) => x.evidence)).toEqual([
|
||||
'no container has a max-width, and .grid gains columns at (min-width: 1600px) (3 to 4)',
|
||||
'no container has a max-width, and .hero__visual is sized in vw with no cap (width: 60vw)',
|
||||
]);
|
||||
});
|
||||
test('a centred max-width container, a clamp() on the visual, or a column count that holds are clean', async () => {
|
||||
none(await lintPage(page('.wrap { max-width: 1360px; margin: 0 auto; }\n' + grid, '<div class="wrap">' + body + '</div>')), 'wide-container');
|
||||
const held = page('.grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }\n@media (min-width: 1600px) { .grid { gap: 2rem; grid-template-columns: repeat(3, minmax(0, 1fr)); } }\n.hero__visual { width: clamp(320px, 40vw, 720px); }', body);
|
||||
none(await lintPage(held), 'wide-container');
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint structure: bare-fr-track (gate 50)', () => {
|
||||
test('a bare 1fr grid track whose grid holds images blocks', async () => {
|
||||
const html = page('.gallery { display: grid; grid-template-columns: 1fr 1fr; }', '<section class="gallery"><img src="a.png" alt="A"><img src="b.png" alt="B"></section>');
|
||||
const f = await lintPage(html);
|
||||
expect(hits(f, 'bare-fr-track')).toEqual([['blocks', lineOf(html, '1fr 1fr')]]);
|
||||
expect(only(f, 'bare-fr-track')[0].evidence).toMatch(/^a bare 1fr track holds images on \.gallery/);
|
||||
});
|
||||
test('minmax(0, 1fr) tracks with images, and bare 1fr tracks holding only text, are clean', async () => {
|
||||
none(await lintPage(page('.gallery { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }', '<section class="gallery"><img src="a.png" alt="A"><img src="b.png" alt="B"></section>')), 'bare-fr-track');
|
||||
none(await lintPage(page('.cols { display: grid; grid-template-columns: 1fr 2fr; }', '<section class="cols"><p>Left</p><p>Right</p></section>')), 'bare-fr-track');
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint structure: display-head-unwrapped (gate 50)', () => {
|
||||
test('a display head holding a word of 16 letters or more, with no overflow-wrap anywhere that reaches it, blocks', async () => {
|
||||
const html = page('h1 { font-family: var(--font-display); }', '<section class="hero">\n<h1>Hyperproductivity for every crew</h1>\n</section>');
|
||||
const f = await lintPage(html);
|
||||
expect(hits(f, 'display-head-unwrapped')).toEqual([['blocks', lineOf(html, '<h1>')]]);
|
||||
expect(only(f, 'display-head-unwrapped')[0].evidence).toBe('the display head holds "Hyperproductivity" with no overflow-wrap: at 320px that word overflows');
|
||||
});
|
||||
test('overflow-wrap: break-word on the heading, or a short-word heading, is clean', async () => {
|
||||
none(await lintPage(page('h1 { overflow-wrap: break-word; min-width: 0; }', '<h1>Hyperproductivity for every crew</h1>')), 'display-head-unwrapped');
|
||||
none(await lintPage(page('', '<h1>Two days to close the books</h1>')), 'display-head-unwrapped');
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint structure: radio-tab-jump (gate 53)', () => {
|
||||
const tabs = '<div class="tabs"><input type="radio" name="t" id="a"><label for="a">A</label><input type="radio" name="t" id="b"><label for="b">B</label></div>';
|
||||
test('radio tabs parked at position: absolute; top: 0 with no preventScroll in a script block', async () => {
|
||||
const html = page('.tabs input[type="radio"] { position: absolute; top: 0; opacity: 0; }', tabs);
|
||||
const f = await lintPage(html);
|
||||
expect(hits(f, 'radio-tab-jump')).toEqual([['blocks', lineOf(html, 'position: absolute; top: 0')]]);
|
||||
});
|
||||
test('radios kept in flow at zero size, or a focus handler with preventScroll, are clean', async () => {
|
||||
none(await lintPage(page('.tabs input[type="radio"] { position: absolute; width: 0; height: 0; opacity: 0; }', tabs)), 'radio-tab-jump');
|
||||
none(await lintPage(page('.tabs input[type="radio"] { position: absolute; top: 0; opacity: 0; }', tabs + '\n<script>document.querySelector("#a").focus({ preventScroll: true });</script>')), 'radio-tab-jump');
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint structure: double-sticky (gate 56)', () => {
|
||||
test('two elements sticking at top: 0 block at the second', async () => {
|
||||
const html = page('.nav { position: sticky; top: 0; }\n.subnav { position: sticky; top: 0px; }', '<nav class="nav">n</nav><div class="subnav">s</div>');
|
||||
const f = await lintPage(html);
|
||||
expect(hits(f, 'double-sticky')).toEqual([['blocks', lineOf(html, '.subnav')]]);
|
||||
expect(only(f, 'double-sticky')[0].evidence).toBe('two elements stick at top: 0 (.nav and .subnav): the second slides under the first');
|
||||
});
|
||||
test('the second sticky offset by the banner height is clean', async () => {
|
||||
const html = page(':root { --banner-height: 64px; }\n.nav { position: sticky; top: 0; }\n.subnav { position: sticky; top: var(--banner-height); }', '<nav class="nav">n</nav><div class="subnav">s</div>');
|
||||
none(await lintPage(html), 'double-sticky');
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint structure: screen-word-budget (gate 58, static half)', () => {
|
||||
test('an h1 past seven words, a hero paragraph past twelve, and a full page past 450 visible words report at ask', async () => {
|
||||
const html = page('', '<section class="hero">\n<h1>A very long headline that keeps on going and going</h1>\n<p>Halvorsen Freight reconciles 1,140 invoices a month and their books now close before the third.</p>\n</section>');
|
||||
const f = await lintPage(html);
|
||||
expect(hits(f, 'screen-word-budget')).toEqual([['ask', lineOf(html, '<h1>')], ['ask', lineOf(html, '<p>Halvorsen')]]);
|
||||
expect(only(f, 'screen-word-budget').map((x) => x.evidence)).toEqual(['the h1 runs 10 words ("A very long headline that keeps on goin…")', 'the hero paragraph runs 15 words']);
|
||||
const words = Array.from({ length: 460 }, (_, i) => 'word' + i).join(' ');
|
||||
const long = page('', `<h1>Short head</h1>\n<p>${words}</p>\n<footer>End</footer>`);
|
||||
expect(only(await lintPage(long), 'screen-word-budget').map((x) => [x.line, x.evidence])).toEqual([[1, '464 visible words on the page (450 at most)']]);
|
||||
});
|
||||
test('seven words of h1, twelve of lede, and a page under 450 words are within the budget', async () => {
|
||||
const html = page('', '<section class="hero">\n<h1>October closed in two days, not nine</h1>\n<p>1,140 invoices a month, and the books now close before the third.</p>\n</section>\n<footer>End</footer>');
|
||||
none(await lintPage(html), 'screen-word-budget');
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint structure: the shared fixtures', () => {
|
||||
test('slopped.html trips the planted structure tells at their lines', async () => {
|
||||
const f = (await lint([path.join(FIXTURES, 'slopped.html')], { cwd: ROOT, warn: () => {} })).findings;
|
||||
const mine = f.filter((x) => FAMILY.includes(x.rule)).map((x) => [x.rule, x.grade, x.line]);
|
||||
expect(mine).toEqual([
|
||||
['bare-fr-track', 'blocks', 93],
|
||||
['display-head-unwrapped', 'blocks', 112],
|
||||
['double-sticky', 'blocks', 59],
|
||||
['nested-cards', 'blocks', 128],
|
||||
['off-scale-spacing', 'ask', 57],
|
||||
['overflow-x-unclipped', 'ask', 47],
|
||||
]);
|
||||
});
|
||||
test('the clean page raises no structure finding', async () => {
|
||||
const f = (await lint([path.join(FIXTURES, 'clean')], { cwd: ROOT, warn: () => {} })).findings;
|
||||
expect(f.filter((x) => FAMILY.includes(x.rule)).map(formatFinding)).toEqual([]);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -65,7 +65,7 @@ footer { padding: var(--space-4) 0 var(--space-6); color: var(--color-muted); }
|
||||
<section class="hero">
|
||||
<div>
|
||||
<h1>October closed in two days</h1>
|
||||
<p class="lede">Halvorsen Freight reconciles 1,140 invoices a month. Their books now close before the third.</p>
|
||||
<p class="lede">1,140 invoices a month. The books now close before the third.</p>
|
||||
<div class="actions">
|
||||
<a class="button" href="/book">Book the walkthrough</a>
|
||||
<a class="quiet" href="/method">Read how the close works</a>
|
||||
|
||||
Reference in new issue
Block a user