diff --git a/lib/design-lint/rules/ids.ts b/lib/design-lint/rules/ids.ts index 78c8d1ab8..4d24c0f2e 100644 --- a/lib/design-lint/rules/ids.ts +++ b/lib/design-lint/rules/ids.ts @@ -57,44 +57,44 @@ export const RULES: Record = { 'tiny-text': { tier: 'ask', gate: '75', title: 'running text under 12px, functional text under 11px, or prose leaded under 1.3', fix: 'body 16px at 1.5 to 1.65; labels one step smaller', render: true }, 'wide-tracking': { tier: 'blocks', gate: '67', title: 'the tracked-caps label: small text set uppercase or letter-spaced past 0.03em', fix: 'the body face, a step smaller, sentence case, untracked' }, // ── Structure and layout ── - 'template-cards': { tier: 'ask', gate: '3', title: 'three equal icon-above-heading tiles, an icon in a rounded tile, or a thick coloured side stripe', fix: 'vary widths and heights, inline icons, hairline or no border' }, + 'template-cards': { tier: 'ask', gate: '3', title: 'three equal icon-above-heading tiles, an icon in a rounded tile, or a thick coloured side stripe', fix: 'vary widths and heights, inline icons, hairline or no border', pageOnly: true }, 'nested-cards': { tier: 'blocks', gate: '4', title: 'a card inside a card', fix: 'one containment layer' }, 'vh-height': { tier: 'blocks', gate: '6a', title: 'a hero sized in vh', fix: 'dvh or svh' }, 'raw-spacing': { tier: 'blocks', gate: '24a', title: 'a raw padding, margin or gap length where a --space-* token exists', fix: 'the token' }, 'off-scale-spacing': { tier: 'ask', gate: '24b', title: 'spacing off the page\'s scale', fix: 'the scale; three optical nudges at most' }, - 'overflow-x-unclipped': { tier: 'ask', gate: '34', title: 'overflow-x missing on html and body, or a 100vw width', fix: 'overflow-x: clip on both; width: 100%' }, - 'horizontal-scroll': { tier: 'blocks', gate: '34', title: 'horizontal scroll at a width between 320 and 1920', fix: 'find the element wider than its viewport and cap it', render: true }, - 'hero-balance': { tier: 'ask', gate: '44a/44c', title: 'the hero\'s content sits off centre for its screen, or its visual stands apart from the text', fix: 'centre the content in its screen; put the visual beside the text', render: true }, - 'hero-fold': { tier: 'blocks', gate: '44b', title: 'headline, lede, primary action and visual not all visible at 1280x800', fix: 'pull the display clamp down, tighten leading, a two-line lede, trim padding', render: true }, - 'wide-container': { tier: 'blocks', gate: '44d', title: 'at 1920x1080 the container has no max-width, gutters under 120px, or a grid gains columns', fix: 'cap 1360 to 1520px and centre; hold column counts; cap vw visuals' }, - 'bare-fr-track': { tier: 'blocks', gate: '50', title: 'an image-bearing grid track on bare 1fr', fix: 'minmax(0, 1fr)' }, - 'display-head-unwrapped': { tier: 'blocks', gate: '50', title: 'a display head without overflow-wrap: break-word and min-width: 0', fix: 'overflow-wrap: break-word; min-width: 0 (never anywhere)' }, + 'overflow-x-unclipped': { tier: 'ask', gate: '34', title: 'overflow-x missing on html and body, or a 100vw width', fix: 'overflow-x: clip on both; width: 100%', pageOnly: true }, + 'horizontal-scroll': { tier: 'blocks', gate: '34', title: 'horizontal scroll at a width between 320 and 1920', fix: 'find the element wider than its viewport and cap it', render: true, pageOnly: true }, + 'hero-balance': { tier: 'ask', gate: '44a/44c', title: 'the hero\'s content sits off centre for its screen, or its visual stands apart from the text', fix: 'centre the content in its screen; put the visual beside the text', render: true, pageOnly: true }, + 'hero-fold': { tier: 'blocks', gate: '44b', title: 'headline, lede, primary action and visual not all visible at 1280x800', fix: 'pull the display clamp down, tighten leading, a two-line lede, trim padding', render: true, pageOnly: true }, + 'wide-container': { tier: 'blocks', gate: '44d', title: 'at 1920x1080 the container has no max-width, gutters under 120px, or a grid gains columns', fix: 'cap 1360 to 1520px and centre; hold column counts; cap vw visuals', pageOnly: true }, + 'bare-fr-track': { tier: 'blocks', gate: '50', title: 'an image-bearing grid track on bare 1fr', fix: 'minmax(0, 1fr)', pageOnly: true }, + 'display-head-unwrapped': { tier: 'blocks', gate: '50', title: 'a display head without overflow-wrap: break-word and min-width: 0', fix: 'overflow-wrap: break-word; min-width: 0 (never anywhere)', pageOnly: true }, 'radio-tab-jump': { tier: 'blocks', gate: '53', title: 'CSS-only radio tabs at position: absolute; top: 0 with no JS guard', fix: 'radios in flow at zero size, or a handler with preventScroll' }, - 'double-sticky': { tier: 'blocks', gate: '56', title: 'two position: sticky; top: 0 elements', fix: 'top: var(--banner-height) on the second' }, - 'screen-word-budget': { tier: 'ask', gate: '58', title: 'more than about 80 words in one 900px window, or a hero with more than two text blocks before its actions', fix: 'headline, one sentence, actions; a section is a head block, then its composition' }, + 'double-sticky': { tier: 'blocks', gate: '56', title: 'two position: sticky; top: 0 elements', fix: 'top: var(--banner-height) on the second', pageOnly: true }, + 'screen-word-budget': { tier: 'ask', gate: '58', title: 'more than about 80 words in one 900px window, or a hero with more than two text blocks before its actions', fix: 'headline, one sentence, actions; a section is a head block, then its composition', pageOnly: true }, 'section-gap': { tier: 'ask', gate: '59', title: 'a gap under 120px between sections at 1440, every gap the same, or more than nine sections', fix: '120px at least, varied; six to nine sections a page', pageOnly: true, render: true }, - 'heading-rhythm': { tier: 'ask', gate: '71', title: 'a heading nearer what precedes it than what it heads', fix: 'at least twice the space above a head as below it', render: true }, + 'heading-rhythm': { tier: 'ask', gate: '71', title: 'a heading nearer what precedes it than what it heads', fix: 'at least twice the space above a head as below it', render: true, pageOnly: true }, 'cramped-padding': { tier: 'ask', gate: '72', title: 'text closer than 8px (or half an em) to the side of its filled box', fix: 'pad the box; a 16px gutter', render: true }, - 'thin-section': { tier: 'ask', gate: '77', title: 'a section below the hero with no mass: a narrow body on a wide canvas', fix: 'widen the body, add a visual, set a statement, or merge the section', render: true }, - 'small-section-heads': { tier: 'ask', gate: '78', title: 'the median h2 below the hero renders under 40px at 1440', fix: 'h2 at the section size, item titles at the title size', render: true }, - 'dead-band': { tier: 'ask', gate: '79', title: 'more than 160px of nothing between the hero\'s lowest content and the fold', fix: 'centre the content and grow the visual or the head; never fill with copy', render: true }, - 'hand-drawn-svg': { tier: 'ask', gate: '80', title: 'an inline SVG of 120px or more with three curves or fills and no text: a drawn illustration', fix: 'a photograph, the product built, or type at scale', render: true }, - 'empty-picture': { tier: 'ask', gate: '81', title: 'a slot or gradient block of 300x200px or more showing nothing but its wash', fix: 'the user\'s image, a credited photograph, or the product at full fidelity', render: true }, - 'counting-marks': { tier: 'ask', gate: '82', title: '24 or more identical marks standing for a quantity', fix: 'set the number in type', render: true }, - 'photo-wall': { tier: 'ask', gate: '83', title: 'photographs cover more than a third of the page below the hero', fix: 'keep the strongest pictures, set big; carry the rest in type, figures, the product, a table', render: true }, + 'thin-section': { tier: 'ask', gate: '77', title: 'a section below the hero with no mass: a narrow body on a wide canvas', fix: 'widen the body, add a visual, set a statement, or merge the section', render: true, pageOnly: true }, + 'small-section-heads': { tier: 'ask', gate: '78', title: 'the median h2 below the hero renders under 40px at 1440', fix: 'h2 at the section size, item titles at the title size', render: true, pageOnly: true }, + 'dead-band': { tier: 'ask', gate: '79', title: 'more than 160px of nothing between the hero\'s lowest content and the fold', fix: 'centre the content and grow the visual or the head; never fill with copy', render: true, pageOnly: true }, + 'hand-drawn-svg': { tier: 'ask', gate: '80', title: 'an inline SVG of 120px or more with three curves or fills and no text: a drawn illustration', fix: 'a photograph, the product built, or type at scale', render: true, pageOnly: true }, + 'empty-picture': { tier: 'ask', gate: '81', title: 'a slot or gradient block of 300x200px or more showing nothing but its wash', fix: 'the user\'s image, a credited photograph, or the product at full fidelity', render: true, pageOnly: true }, + 'counting-marks': { tier: 'ask', gate: '82', title: '24 or more identical marks standing for a quantity', fix: 'set the number in type', render: true, pageOnly: true }, + 'photo-wall': { tier: 'ask', gate: '83', title: 'photographs cover more than a third of the page below the hero', fix: 'keep the strongest pictures, set big; carry the rest in type, figures, the product, a table', render: true, pageOnly: true }, 'email-band-close': { tier: 'ask', gate: '85', title: 'the page ends on an email sign-up form on a page whose job is not subscribe, join, attend, listen or visit', fix: 'end on what the visitor came to do, or on the last strong section', pageOnly: true }, // ── Chrome and copy ── 'aphoristic-cadence': { tier: 'blocks', gate: '19', title: 'the rebuttal cadence three or more times ("Not a feature. A platform.")', fix: 'say what the product does, in its own nouns' }, 'generic-hero-copy': { tier: 'blocks', gate: '19', title: 'a filler opener ("Built for the modern team", "In today\'s landscape") or "quietly trusted by"', fix: 'say what the product does, in its own nouns' }, - 'generic-cta-copy': { tier: 'ask', gate: '19/42', title: 'a stock second action beside the primary (Learn more, Get started, Book a demo)', fix: 'one action, or a second that names where it goes' }, + 'generic-cta-copy': { tier: 'ask', gate: '19/42', title: 'a stock second action beside the primary (Learn more, Get started, Book a demo)', fix: 'one action, or a second that names where it goes', pageOnly: true }, 'em-dash-overuse': { tier: 'ask', gate: '19', title: 'em or en dashes carrying the prose', fix: 'a comma, a colon, a full stop or a line break' }, 'template-footer-line': { tier: 'ask', gate: '19', title: 'a stock footer line (All rights reserved)', fix: 'the page\'s own line, or nothing' }, 'template-nav': { tier: 'ask', gate: '42', title: 'the reflex nav: wordmark left, inline links, a button right, hairline under', fix: 'decide the nav and build another shape, or keep it on the record', pageOnly: true }, 'template-footer': { tier: 'ask', gate: '42', title: 'the reflex footer: four link columns, a social row, a copyright', fix: 'decide the footer and build another shape, or keep it on the record', pageOnly: true }, 'invented-proof': { tier: 'blocks', gate: '46a', title: 'an invented metric, testimonial, logo wall or case-study count', fix: 'the user\'s number, a text slot, or nothing' }, 'drawn-browser-chrome': { tier: 'blocks', gate: '47a', title: 'a hand-drawn browser bar (traffic lights, URL pill) or OS window title bar', fix: 'a real screenshot in a
, or no chrome' }, - 'device-frame': { tier: 'ask', gate: '47b', title: 'a phone, laptop, terminal or editor frame', fix: 'one frame with real content inside, or none' }, - 'imitation-material': { tier: 'ask', gate: '47c', title: 'styled divs standing in for a product, a photo edge or a physical object', fix: 'the product built inside a slot, a real asset, or nothing' }, + 'device-frame': { tier: 'ask', gate: '47b', title: 'a phone, laptop, terminal or editor frame', fix: 'one frame with real content inside, or none', pageOnly: true }, + 'imitation-material': { tier: 'ask', gate: '47c', title: 'styled divs standing in for a product, a photo edge or a physical object', fix: 'the product built inside a slot, a real asset, or nothing', pageOnly: true }, 'kicker-above-heading': { tier: 'ask', gate: '54', title: 'the eyebrow: short inert type before a heading that only announces it', fix: 'open the section another way; where the heading is figurative, the label becomes the heading', pageOnly: true }, 'template-punctuation': { tier: 'blocks', gate: '63', title: 'template punctuation: middle-dot meta strings, an arrow glued to link text, decorative status dots', fix: 'line breaks, columns, hairlines; a verb as the label' }, 'wireframe-slots': { tier: 'ask', gate: '68', title: 'more than five empty slots on one page, or four or more in one row', fix: 'set the set in type; a slot that stays holds a real photo or the product', pageOnly: true }, @@ -111,9 +111,9 @@ export const RULES: Record = { 'missing-focus-visible': { tier: 'blocks', gate: '26a', title: 'an interactive element with no :focus-visible ring, or an outline removed with nothing in its place', fix: 'a ring at 3:1 or better, instant' }, 'missing-states': { tier: 'ask', gate: '26b', title: 'no :active or :disabled state', fix: 'all the states a control can be in' }, 'missing-reduced-motion': { tier: 'blocks', gate: '27', title: 'a keyframe or transform transition without a prefers-reduced-motion fallback', fix: 'add @media (prefers-reduced-motion: reduce)' }, - 'autoplay-unmuted': { tier: 'blocks', gate: '28a', title: 'a video autoplaying without muted', fix: 'autoplay muted loop playsinline' }, - 'lazy-lcp': { tier: 'blocks', gate: '28a', title: 'loading="lazy" on the first image or the hero video', fix: 'fetchpriority="high" on the LCP image' }, - 'video-without-poster': { tier: 'ask', gate: '28b', title: 'a video without poster or a priority hint', fix: 'add them' }, + 'autoplay-unmuted': { tier: 'blocks', gate: '28a', title: 'a video autoplaying without muted', fix: 'autoplay muted loop playsinline', pageOnly: true }, + 'lazy-lcp': { tier: 'blocks', gate: '28a', title: 'loading="lazy" on the first image or the hero video', fix: 'fetchpriority="high" on the LCP image', pageOnly: true }, + 'video-without-poster': { tier: 'ask', gate: '28b', title: 'a video without poster or a priority hint', fix: 'add them', pageOnly: true }, 'input-state-shift': { tier: 'blocks', gate: '39a', title: 'input states that break: a border that changes width, a focus ring built from border, disabled by opacity alone', fix: 'constant border, outline, shared height, three disabled channels' }, 'control-height': { tier: 'ask', gate: '39b', title: 'control height outside 44 to 52px on a marketing page', fix: 'the number', pageOnly: true }, 'content-hidden-at-rest': { tier: 'blocks', gate: '62', title: 'a reveal class hiding content until JavaScript runs', fix: 'visible by default; hide only under a .js class, or animate from visible with @starting-style' }, @@ -127,8 +127,8 @@ export const RULES: Record = { 'low-contrast': { tier: 'blocks', gate: '40', title: 'text, an icon, a focus ring or a field edge under its contrast threshold', fix: 'fix the pair; body 4.5:1, large text and edges 3:1' }, 'gray-on-color': { tier: 'blocks', gate: '41', title: 'button text within a step of its fill, or grey text on a coloured surface', fix: 'the paired ink token' }, 'literal-outside-tokens': { tier: 'blocks', gate: '48', title: 'a colour or font-family literal outside the token block', fix: 'lift it into the tokens' }, - 'button-label-wraps': { tier: 'blocks', gate: '49a', title: 'a button label or primary nav link that wraps', fix: 'shorten it; white-space: nowrap' }, - 'link-wraps': { tier: 'ask', gate: '49b', title: 'a footer link, tab or breadcrumb that wraps', fix: 'shorten it' }, + 'button-label-wraps': { tier: 'blocks', gate: '49a', title: 'a button label or primary nav link that wraps', fix: 'shorten it; white-space: nowrap', pageOnly: true }, + 'link-wraps': { tier: 'ask', gate: '49b', title: 'a footer link, tab or breadcrumb that wraps', fix: 'shorten it', pageOnly: true }, // ── Finish (polish) ── 'straight-quotes': { tier: 'polish', gate: 'F1', title: 'straight quotes in prose', fix: 'curly quotes' }, 'three-dot-ellipsis': { tier: 'polish', gate: 'F2', title: 'three dots for an ellipsis', fix: 'the ellipsis character' }, @@ -136,16 +136,16 @@ export const RULES: Record = { 'two-primary-buttons': { tier: 'polish', gate: 'F4', title: 'two filled primary buttons on one page, or side by side', fix: 'one filled primary; the second as a text or outline action' }, 'icon-coin': { tier: 'polish', gate: 'F5', title: 'an icon in a coloured coin', fix: 'the icon inline, bare' }, 'display-figures-tabular': { tier: 'polish', gate: 'F6', title: 'display-size figures without tabular numerals', fix: 'font-variant-numeric: tabular-nums' }, - 'stamped-recipe': { tier: 'polish', gate: 'F7', title: 'one container recipe stamped on six or more selectors, or the pill radius on five or more non-controls', fix: 'vary the recipe; pills on controls, tags and badges only' }, - 'scroll-choreography-mix': { tier: 'polish', gate: 'F8', title: 'more than one scroll-choreography pattern', fix: 'one pattern a page' }, + 'stamped-recipe': { tier: 'polish', gate: 'F7', title: 'one container recipe stamped on six or more selectors, or the pill radius on five or more non-controls', fix: 'vary the recipe; pills on controls, tags and badges only', pageOnly: true }, + 'scroll-choreography-mix': { tier: 'polish', gate: 'F8', title: 'more than one scroll-choreography pattern', fix: 'one pattern a page', pageOnly: true }, 'fake-avatar': { tier: 'blocks', gate: 'F9', title: 'a fake-human avatar hotlink', fix: 'the user\'s people, initials, or no portrait' }, 'stock-person-photo': { tier: 'polish', gate: 'F9', title: 'a person-keyword stock photo URL', fix: 'the user\'s photograph' }, - 'page-invariants': { tier: 'polish', gate: 'F15', title: 'page invariants broken: primary button style, container width, column gap or radius drifting', fix: 'one recipe for each' }, - 'unthemed-browser-surfaces': { tier: 'polish', gate: 'F16', title: 'no ::selection, caret-color or scrollbar-color on a page with a committed palette', fix: 'theme the browser surfaces' }, + 'page-invariants': { tier: 'polish', gate: 'F15', title: 'page invariants broken: primary button style, container width, column gap or radius drifting', fix: 'one recipe for each', pageOnly: true }, + 'unthemed-browser-surfaces': { tier: 'polish', gate: 'F16', title: 'no ::selection, caret-color or scrollbar-color on a page with a committed palette', fix: 'theme the browser surfaces', pageOnly: true }, 'text-sliver': { tier: 'polish', gate: 'F17', title: 'a text block squeezed into a sliver at 1440', fix: 'give the block room or merge the columns', render: true }, 'tiny-slot': { tier: 'polish', gate: 'F18', title: 'a slot rendering under 40px in either dimension', fix: 'size the slot for what it will hold', render: true }, 'font-weight-count': { tier: 'polish', gate: 'F19', title: 'more than three font weights in running use', fix: 'three weights', render: true }, - 'lonely-grid-item': { tier: 'polish', gate: 'F20', title: 'a grid or wrapping row leaving one item alone on its last row', fix: 'change the count or the columns', render: true }, + 'lonely-grid-item': { tier: 'polish', gate: 'F20', title: 'a grid or wrapping row leaving one item alone on its last row', fix: 'change the count or the columns', render: true, pageOnly: true }, 'skipped-heading': { tier: 'polish', gate: 'F21', title: 'a heading level skipped', fix: 'step the levels one at a time' }, 'justified-text': { tier: 'polish', gate: 'F22', title: 'justified text without hyphens: auto', fix: 'hyphens: auto, or left-aligned' }, 'missing-lang': { tier: 'polish', gate: 'F23', title: 'a document without lang', fix: 'lang on ' }, @@ -162,11 +162,11 @@ export const RULES: Record = { 'dead-link': { tier: 'ask', gate: 'craft', title: 'a dead link (href="#")', fix: 'link it, or render the control disabled' }, 'alert': { tier: 'ask', gate: 'craft', title: 'alert() for a message', fix: 'show the message inline' }, 'img-no-alt': { tier: 'blocks', gate: 'craft', title: 'an without alt', fix: 'add alt text (alt="" when decorative)' }, - 'missing-title': { tier: 'blocks', gate: 'craft', title: 'a document without a ', fix: 'add a <title>' }, - 'missing-meta-description': { tier: 'ask', gate: 'craft', title: 'no meta description', fix: 'add <meta name="description">' }, - 'missing-favicon': { tier: 'polish', gate: 'craft', title: 'a document without a favicon', fix: 'add a favicon <link rel="icon">' }, - 'missing-skip-link': { tier: 'ask', gate: 'craft', title: 'no skip link reaching <main>', fix: 'add a "Skip to content" link first in <body> whose #id exists on <main>' }, - 'missing-aria-current': { tier: 'ask', gate: 'craft', title: 'route navigation without aria-current', fix: 'mark the current nav link aria-current="page"' }, + 'missing-title': { tier: 'blocks', gate: 'craft', title: 'a document without a <title>', fix: 'add a <title>', pageOnly: true }, + 'missing-meta-description': { tier: 'ask', gate: 'craft', title: 'no meta description', fix: 'add <meta name="description">', pageOnly: true }, + 'missing-favicon': { tier: 'polish', gate: 'craft', title: 'a document without a favicon', fix: 'add a favicon <link rel="icon">', pageOnly: true }, + 'missing-skip-link': { tier: 'ask', gate: 'craft', title: 'no skip link reaching <main>', fix: 'add a "Skip to content" link first in <body> whose #id exists on <main>', pageOnly: true }, + 'missing-aria-current': { tier: 'ask', gate: 'craft', title: 'route navigation without aria-current', fix: 'mark the current nav link aria-current="page"', pageOnly: true }, // ── The lint's own bookkeeping ── 'waiver': { tier: 'ask', gate: 'waiver', title: 'a waiver the lint refused', fix: 'fix the finding, or waive it with the rule id and a reason about this page' }, 'house-style': { tier: 'polish', gate: 'waiver', title: 'more than three rules waived on one page', fix: 'lock the choices into DESIGN.md so later pages inherit them instead of re-arguing them' },