Files
gstack/docs/design-lint.md
T
Garry Tanandcapy-ai[bot] fa3a2400b5 v1.91.251.0 feat(design-lint): bundled free design lint (bin/gstack-design-lint + lib/design-lint/), hallmark sloplint port (#3459)
* feat(design-lint): core of the bundled free design lint (parsers, colour, tokens, reporter, waivers, CLI)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(design-lint): CHANGELOG entry, NOTICE and MIT-hallmark licence, module-size ratchet entries

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(design-lint): port the colour and type rule families from sloplint

rules/color.ts: gates 2a 2b 7a 7b 22 23 29 48 65 74 (check2, check7, check22,
check23, check29Glow, check48, check65, check65Tailwind). rules/type.ts: gates
1 25 37 38i 38ii 38iii 55a 55b 64 67 (check1, check25, check37, check38a,
check38c, check55, check64, check67, check67Html). Thresholds and heuristics
are hallmark's; evidence and fixes are rewritten in gstack's voice; the stamp,
genre and posture guards are gone, so every check runs in its strict form.

The clean fixture carried two real tells hallmark's own sloplint also flags:
an oklch literal on .button:hover (48) and the cream paper + Fraunces + clay
accent look (65). The hover colour is now a token and the display face is
Literata.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(design-lint): port the Tailwind reader and component rules

Replaces the tailwind.ts stub with sloplint's utility reader (twParse,
twInfo, twGroup, variants, stops, sizes, tracking, families, shared
project tokens) and the tells it feeds, and fills rules/component.ts
with checkTailwind and checkComponentText: layout-transition,
bounce-easing, purple-blue-gradient, gradient-text, glow-wash,
literal-outside-tokens, hover-tells, wide-tracking, kicker-above-heading,
monospace-costume, framework-palette, vh-height, template-cards,
template-punctuation and emoji-decoration over class strings in HTML,
JSX, Vue, Svelte and Astro files and cn()/clsx()/cva() calls.
test/design-lint-tailwind.test.ts holds a negative control and a planted
tell per rule plus the tailwind-fail, tailwind-pass and clean fixtures.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(design-lint): mark line-length, cream-palette and the caps leading rules page-only

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* 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>

* fix(design-lint): the Tailwind gradient half of 2a/2b lives in the component family only

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore(design-lint): tidy the colour test imports; control-height page-only; lazy-lcp title

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore(design-lint): drop unused colour test imports

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(design-lint): port the chrome, assets and finish rule families from sloplint

Chrome (gates 19 42 46a 47 54 63 68 69 70 85): gate 19 split by tell onto
aphoristic-cadence, generic-hero-copy, generic-cta-copy, em-dash-overuse and
template-footer-line in rules/chrome-copy.ts (with the two-filled-buttons half
of F4 and the shared prose mask); the eyebrow in rules/chrome-eyebrow.ts; the
nav and footer shapes, invented proof, drawn chrome, placeholder services,
template punctuation, wireframe slots, repeated facts, the fake send and a
static version of the email-band close in rules/chrome.ts.

Assets (gates 30 33 40 41 49 76): emoji icons, mixed icon libraries, unlabelled
svg/canvas, contrast pairs and surface inks (suspected at ask in the static
tier), wrapping labels split into button-label-wraps and link-wraps, broken
images and stock hotlinks.

Finish (F1-F9 F15 F16 F21-F24): all polish except fake-avatar.

Tests cover every id with a negative control and a planted positive; the clean
fixture gains scrollbar-color so its committed palette themes every browser
surface.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(design-lint): chrome, assets and finish families merged; maskedProse moves to html.ts; isTokenRule takes a Pick; ratchet watches every lint module

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(design-lint): the eyebrow-pass and tailwind-pass fixtures carry a skip link, main id and aria-current so the copy tier passes them too; waiver fixture gets a meta description

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(design-lint): render tier on gstack's own browser (sloplint-render port)

Ports hallmark's sloplint-render.mjs onto lib/aside-render: one tab per
page, the in-page probes installed once and run at each viewport, a second
pass with full-page screenshots only when a contrast claim needs the painted
pixels (a small PNG reader replaces canvas sampling). Gates 23, 25, 26a, 34,
40, 41, 44a-d, 49a/b, 58, 71, 72, 73, 75, 77-83, F17-F20, plus the page that
did not render. The stamp's posture, hero-shape, photo-shape and genre
guards are gone: the strict form applies. Focus rings are read by focusing
each control in turn (programmatic focus matches :focus-visible on the
daemon with no prior pointer input; a stylesheet scan is the fallback).

lib/aside-render: an eval step takes width/height like a screenshot step
(device metrics on Aside, viewport on browse, cleared after), and spec.file
may be an http(s) URL, opened as it is with nothing served.

The clean fixture gained border-box on its hero, a centred, taller hero
visual, a wrapping actions row, a statement-sized close head and a shorter
story paragraph: the browser showed the action below the 1280x800 fold, a
dead band under the hero, a 600px image spilling past a phone screen and a
thin close section.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(design-lint): render tier merged; four probe modules join the ratchet; the clean hero sits at a fixed measure so text and visual stand together; CHANGELOG names the aside-render eval viewport

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(design-lint): register the lint fixtures as free fixtures and the hallmark licence texts as free-only PR files, so the PR paid lane stays narrow and test/free-fixtures.test.ts passes

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(design-lint): section-gap measured in the render tier (gate 59, with the six-to-nine section count) and the hero action count; a free test over the recorded review slop page; usage, render glue, file walk, multi-page ownership and copy-tier tests; NOTICE and licences leave FREE_ONLY_PR_FILES so the paid-free boundary test stands

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: mark hallmark PAGE_ONLY gates and craft page checks pageOnly so --scope component drops them

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: ship review fixes

Waivers reach render findings; a blocking rule the lint only suspected is
waivable like any ask, a measured or absent Floor stays refused. A stylesheet
a page links from another directory belongs to that page and is not linted
again on its own; a stylesheet linted alone keeps its waivers. Root-relative
hrefs resolve against the lint root. Stylesheets are read and parsed once per
run. A rule family that throws reports internal-error at ask instead of a
silent pass; aria-labelledby and probe tag names are escaped before they
reach a RegExp; a probe result naming an unknown rule is a render finding,
not a crash; fix strings are one line. overused-font follows gstack's
catalog of display faces; one MONO_FACE list for every monospace check.
parseCss caps nesting depth; medianRgb uses histograms; glHide batches its
layout reads. Dead exports removed (isPage, openTags, over, ComponentTag,
PixelRect, the ids re-exports).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: name the tracked-caps thresholds once, string-aware brace scan, drop an unused parameter, clean up test tmpdirs

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: second review cycle

Waivers settle once per page after both tiers, so a blocking rule only the
render tier suspects is waivable and one it measures is refused; a claim in a
stylesheet answers for that stylesheet on every page that links it (no
shared-word check), so a sheet shared by two pages is no longer waived on one
and refused on the other. Every printed field is one line with control
characters stripped; the waiver regex is linear and ends at a carriage
return; a root-relative href cannot climb out of the lint root. The ancestor
map closes optional end tags implicitly and pops to the matching open tag, so
unclosed <li> no longer makes it quadratic. The static tier reports outline:
none with no ring anywhere as missing-focus-visible at its line;
literal-outside-tokens needs a --color-* or --font-* token to exist; the
banned gradient includes indigo-to-violet; a new banned-font rule reads
gstack's BANNED_FONTS; MONO_FACE matches whole words; bold keywords and
tokens count as weight 700 for the contrast threshold. stylesFor reads only
the entries that can match; glOcclusion memoises the pinned walk; the
confirm screenshot is taken at 1x; a page that throws in the render tier is a
render finding and the CLI exits 3 on an internal error. Docs name the token
conventions and what the lint does not cover.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: pair open and close tags once per document, scan markup in linear time, keep commented-out style and script dead

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: third review cycle fixes

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: third review cycle tests; render keeps the measured horizontal-scroll when merging widths

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: document the third-cycle limits and waiver scope

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: fourth review cycle fixes

Commented-out markup is dead for every rule (Doc.src is the comment-blanked text; craft copy, the module-script check and the border+shadow scan read it). Headings, buttons, links, labels, selects, forms and an open <p> close the way the browser closes them, a tree nests at most 512 deep, and the heading, footer-column, sign-up-button and span readers walk the shared pairing instead of lazy regexes, with each element's text computed once (textOf). Tailwind frames and wireframe slots answer from treeOf. resolveVars stops on a cycle and caps its size; every parseCss at-rule branch honours the depth guard and an inline <style> that fails to parse is skipped with a warning. cwd and lint roots are read through their real paths, a skipped source file is one internal-error finding, relative refs stay inside the project, a linked stylesheet over 2 MB is skipped. overused-font and banned-font match a catalog stem to a family name's first words; width: min()/clamp() caps a container; a box-shadow focus ring beside a border-colour change is not input-state-shift; Meet and triad headings below the hero are suspected; the stylesheet-waiver key goes through oneLine; pxOf reads leading-dot lengths; check67 precomputes selector bases; the render tier reports a wrong-shaped answer per probe and keeps every measured finding, flags a bad grade as tampering, and never repeats the kept width when merging horizontal-scroll.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: fourth review cycle tests and docs

test/design-lint-fourth-cycle.test.ts pins commented-out markup dead for every rule, unclosed <li> Tailwind frames and wireframe slots, linear-time unclosed runs and component scripts, cyclic var() and nested @media, leading-dot lengths, a symlinked lint root, oversize files reported, a whitespace-named stylesheet's waiver, the overused-font stems, width: min() caps, box-shadow rings, Meet headings below the hero, wrong-shaped render answers and the never-waivable refusals by class; the settle golden no longer repeats the kept width and expects the bad grade as tampering. docs/design-lint.md describes the caps, implicit closes, skipped-file findings and the matching rules.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: fourth-cycle simplifications and the pixel pass pinned without a browser

heroClasses reads the hero through heroIndexOf, check64 uses twMonoIsMaterial, check67Html imports capsRatio from chrome-eyebrow, sideStripes loses its bare block; a settle test drives confirmPixels with the swatch fixture (a claim dropped by the pixels behind it, one rewritten, a weak ring confirmed).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: heroIndexOf carries its own doc comment

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: fifth review cycle fixes

The placeholder emoji scan reads the comment-blanked text, so markup inside <!-- --> is dead for every rule. A digit run, a long mono word and a run of scheme prefixes cost their length (FACT and UNSPLASH_URL only start at a token, twDataText bounds its text); a 2 MB sheet of 699k rules no longer blows the stack (rules are appended in loops); JsxScanner spends one failure budget across jsScan and el; the Tailwind had() lookup is a Set; accentedHeads indexes rules by class once; contexts drop their caches once their families have run; the parsed-token cache is bounded. treeOf gives a close tag its parent so a fact after an inline close is counted in its section. Waivers are keyed per rule for a page claim and per rule@sheet for a stylesheet claim, so two linked sheets each answer for their own findings. The render tier validates every page-supplied field (at.cls, ring colour and ratio, probe fg and threshold, PAGE_ERRORS entries, a malformed entry naming the render rule) and keeps its measured findings; lineOf answers from one line index. Rule judgement: a textarea's min-height is not control-height, attribute-selected inputs are read for input-state-shift, a glyph-only <i> is not an italic word, a bounce keyframe by name is bounce-easing, single-quoted inline styles are read, a project root is a package.json or .git. Housekeeping: Probes typed from PROBE_FNS, EYEBROW_* shared from chrome-eyebrow, WaiverClaim.raw and dead imports removed, evidence wording for tracked-only controls and black glass, emoji icon tiles capped like every other emoji path, unreadable files get their own fix text.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: fifth review cycle tests and docs

test/design-lint-fifth-cycle.test.ts (21 tests) pins the comment-dead placeholder, the linear-time digit run, 699k-rule sheet, unclosed JSX and tracked-label pages, per-sheet waivers and the remaining state-matrix cells, every wrong-shaped render field, and the rule judgements; the fourth-cycle shell test asserts the observable effect, the RSS assertion is structural, time bounds are widened for shared CI shards, the stale empty block is gone, and the FREE_FIXTURES inventory names both cycle files. docs/design-lint.md states the project-root bound on relative hrefs and the three narrow readings (whole-page focus ring, unconditional 4px scale, scroll-container-blind double-sticky).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: sixth review cycle fixes

line-length computes its floor only for a measure between 28 and 45ch and
answers shortProse once per class; wide-tracking resolves inherited case
through a prefix index instead of a scan of every rule; the JSX scanner
fails every ancestor of an element that ran to the end of the file at once;
the hairline-under-shadow pair reads class tokens instead of a two-sided
regex. tooltip-focus-delay names the sheet that holds the :focus delay;
the placeholder emoji scan reads the comment-blanked body for every kind;
fake-send finds its line in a component's script text. A stylesheet href is
bounded by its real path too. The LINT line's separator is kept out of
page-supplied text. Waivers: a component claim answers for its own file,
weighed against its own text; the Waiver shape is a reason string; refusals
go through Reporter. Render: a later fail replaces a warn with the same
evidence, a non-string fix is dropped, the timeout is a constant. Rules:
a meta label in mono is a costume, a leading-dot length reads as a fraction,
a percentage leading collides like a unitless one, an italic word after an
icon is still read, an italic rule on the word is not a display system, en
dashes between digits are ranges, and the catalog's two hero lines are
generic-hero-copy. The Tailwind parse cache and its eviction are gone.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: sixth review cycle tests and docs

test/design-lint-sixth-cycle.test.ts (18 tests; 13 red at 3f0c62582) pins
the per-page work, the right-place findings, the rule judgements and the
waiver and render cells the state matrix named. The emoji-tile cap is
asserted exactly, the pixel-pass test says what it checks, the recorded slop
page now names its generic hero copy. docs/design-lint.md: the real-path
bound, the separator note, page and component claim scopes, hero-fold's
measured half, two more items the lint does not cover, the heading-pattern
exemption and the digit-range dash.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: seventh review cycle fixes

A tag start the scanner finds no '>' for is spaced out of doc.src, so every
'[^>]*>' scan over a page (the lint's and the craft check's) costs the page
its length; render's line lookup reads the same text. The JSX scanner skips
a generic element's type arguments and closes every element under a close
tag that names an open ancestor, as a browser would; a closer naming nothing
open is text. The token table is read in one colour mode: the base block's
last declaration wins and a prefers-color-scheme or data-theme override only
fills tokens the base never declares; a custom property declared in any
scope is a token definition, not a literal; a token finding points at the
declaration that won. In a directory of components, under either scope,
each file's claims answer for that file alone with its own suspected rules.
The Tailwind dedupe keys files the way Reporter prints them. outline: none is
not a ring for input-state-shift; a form with a password or a sign-in button
is not an email band; one CONTROL_TRACK_MAX_EM (0.04) for both halves of
wide-tracking; one CONTRAST_SLACK; hairline-under-shadow splits layers with
splitTop; a missing linked stylesheet is warned about; TwItem.toks and the
controlHeight scope guard are gone; zero-chroma-neutrals is titled for what
it reads. aside-render sets the device override or viewport only when a
sized step's size changes and clears it before an un-sized step.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: seventh review cycle tests and docs

test/design-lint-seventh-cycle.test.ts (13 tests) pins the stray-tag tail,
the shadow-layer split, the JSX generic and ancestor-closing cases, the
one-mode token table, the per-file component claims under both scopes with
their own suspected rules, the stylesheet beside components, the Reporter
file key, outline: none, the sign-in form, the shared tracking ceiling and
the missing-stylesheet warning. docs/design-lint.md: the token block and its
mode overrides, the stray-tag note, the component-directory claim scope,
the email-band exclusions, the missing-sheet warning and zero-chroma-neutrals
as a fourth narrow reading.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: eighth review cycle fixes

Waivers follow the context's shape, not the --scope flag: one html page keeps
page semantics under either scope (its claim reaches the sheet it links) and a
lone component beside an unlinked stylesheet answers for its own file only.
Every '<' that starts no tag is a stray spaced out of doc.src (a '<!' or '<?'
with a '>' ahead stays), an unclosed comment runs to the end of the file, the
three bare /<[^>]*>/ strips use stripTags, the copy tier drops open script,
style, template and nav tags nothing closes, component tags are paired once
per document, columnCount counts repeat() arithmetically, an unclosed
@starting-style block is read once, the import scan matches import shapes
only, the render locator refuses a non-letter tag and reads the class
attribute linearly, tickers are answered from the tree, a tooltip duration is
not a delay, class tokens are appended in a loop, the emoji dedupe is keyed by
the icon, the emoji classes spell U+FE0F and U+200D as escapes, the contrast
and focus viewports are named once, and the hero-fold title matches what the
probe measures.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: eighth review cycle tests and docs

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: ninth review cycle fixes

nestSelector stops expanding a comma list nested in a comma list past an
8 KB selector, resolveVars never substitutes a token over MAX_RESOLVED and
stops growing a value past it, a stylesheet or <style> block the lint could
not parse (or a linked sheet over the size cap) is an internal-error finding
through the Skipped list, which now carries a kind; stripTags takes a visible
character limit so the emoji and label reads do not pay for an element that
runs to the end of the file; assets.ts records every quoted emoji finding
and component.ts splices the one at its line; columnCount drops named lines
inside repeat(); doc.text is built from the stray-blanked source;
Reporter.report returns the finding; the 1280 probes read their viewport
from the page; openTags loses its unused limit; the CHANGELOG hero-fold note
matches what the probe measures.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: ninth review cycle tests

The fourth-cycle unclosed-run test states its own timeout: its per-fixture
bound is 8 s and the four fixtures together pass bun's 5 s default.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: ninth review cycle performance fixes

css.ts selectorNames reads the tag and class names a selector list mentions
once, so nowrap coverage, wrapping coverage and the roman-em check answer per
element in constant time; shortProseOf reads elements by class from an index
built once per document; font-family-count keeps a Set of family keys; the
import scan gives the optional paren its own whitespace; a JSX expression that
runs to the end of the file fails every ancestor at once; the render locator
reads class and not data-class; tooltip-focus-delay reads the delay slot of
the transition shorthand, so 200ms is a duration like 0.2s.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: ninth review cycle performance tests and docs

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: tenth review cycle fixes

tooltip-focus-delay splits the transition value at top-level commas, so a
delay after cubic-bezier() or steps() is still read; nestSelector budgets one
substitution per '&' before expanding; stripTags with a limit stops reading
at the limit instead of after the next tag; accentedHeads reads each span's
text once and covers classes from one map; a JSX close tag with no '>' is
text, not a failure every ancestor rescans; selectorNames reads the tag that
opens an :is()/:where() list; a linked sheet that cannot be read is
unreadable, not unparseable; the space probes read their viewport from the
page; the internal-error title covers a page or stylesheet the lint could not
read.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: tenth review cycle tests and docs

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: eleventh review cycle fixes

A nested rule under a parent longer than the selector cap falls back to the
parent's first part cut to 256 characters, the :is() wrapper is built only on
the expanding path, and every stored selector is cut at MAX_SELECTOR; the
limited stripTags counts visibility with the whitespace class its callers
collapse on and never cuts a surrogate pair; glWords slides its window by
innerHeight.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: eleventh review cycle tests

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: twelfth review cycle fixes

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: twelfth review cycle tests and docs

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: thirteenth review cycle fix

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: thirteenth review cycle tests

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: adversarial review fixes

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: adversarial review tests and docs

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-26 17:35:04 +00:00

33 KiB

design-lint: the free, deterministic design check

bin/gstack-design-lint reads a page (or a directory of pages, stylesheets and components) and prints every machine-checkable design tell it finds, with a file and line, in under a second. With --render it also measures the page in gstack's own browser: the fold, the word budget per screen, contrast against the painted pixels, focus rings by tabbing, crowded headings, text painted under other elements, the share of the page that is photographs. Nothing is installed and nothing is paid for: the static tier is TypeScript over the files, and the render tier drives the same engine gstack-render picks (Aside when it is running, otherwise the browse daemon ./setup built).

The rules are ported from Nutlope/hallmark's sloplint (MIT; see NOTICE.md and licenses/MIT-hallmark.txt), rewritten in gstack's voice and mapped onto gstack's severity words. The copy tier is gstack-design-craft's content-realism and ship-floor check under the same contract. Phase 1 (this document) is the tool alone; /design-html and /design-review call it in Phase 2.

Usage

gstack-design-lint <file-or-dir ...> [--render] [--url <http://…>] [--scope page|component] [--job <job>] [--json] [--rules]
  • Reads .html, .css, .jsx, .tsx, .vue, .svelte and .astro. Tailwind class strings in components are read too, including cn(), clsx(), cva() and their kin. node_modules, dot folders and build output below the top level are skipped; a symlink is followed once and lint roots and the working directory are read through their real paths; a file over 2 MB is a bundle, not a page, and is skipped with a warning on stderr. A page or stylesheet the lint skipped (over the cap, unreadable, unparseable; a linked sheet or an inline <style> block included) is also one internal-error finding at ask, so a page whose CSS the lint could not read is not a pass; only a linked stylesheet that does not exist is warned about and otherwise ignored (its tokens are simply absent). A relative stylesheet href may not climb out of the project (the nearest directory holding a package.json or .git), by its path and by its real path, so a symlink inside the project that points outside it is not read either; outside any project it is bounded by the working directory the lint runs in, and only .css files are read.
  • Markup inside an HTML comment is dead for every rule, a commented-out <style> or <script> included; a comment that never closes runs to the end of the file, as it does in the browser. A tag start with no > (a stray <img ) and any < that starts no tag (<1, a < b) are read as text and dropped from the markup, so a page whose tail is ten thousand of them costs its length; a <!doctype> or <?xml?> declaration stays. A tree nests at most 512 elements deep (the browser's own cap); a start tag deeper than that is read as empty. An open heading is closed by the next heading start tag, an open <p> by the next block start, and a repeated <li>, <button>, <a>, <label>, <select>, <form> or table cell by its next start tag, as the browser does, so a page of unclosed elements costs its length to lint. A nested CSS selector is expanded against its parent (& replaced, a bare selector descendant-joined) up to 8192 characters, and a sheet may store at most four times its length plus 64 KB of expanded selectors; past either bound the nested selector stands as written, so a sheet costs its length too. Nesting is read 64 levels deep; a rule nested below that is dropped, not parsed.
  • A page is linted with the stylesheets it links (<link rel=stylesheet>, @import, and anything they reach); a directory of components shares the project's tokens and global state styles, so a focus ring declared once in globals.css is not reported missing on every component.
  • --render measures every HTML page found; --url measures a running dev server (and implies --render). When no browser is available the render tier is skipped and the summary says why.
  • --scope component skips the page-only rules (the nav, the footer, the eyebrow, section gaps, the wireframe count) when linting a component alone.
  • --job <job> names what the visitor came to do (book, buy, subscribe, attend, visit, hire, listen, join, try, sign up): an email sign-up as the page's close is only a finding when the job is not one where a mailing list is native.
  • --json prints { summary, files, findings }; --rules prints the table below.

Output

One line per finding, then one summary line:

LINT: <blocks|ask|polish|waived> <rule-id> <file>:<line> <evidence> | fix: <fix>
LINT_SUMMARY: blocks=N ask=N waived=N polish=N render=<ran|skipped: reason>

Page-supplied text in a finding never holds the line's own | fix: separator (it is printed as / fix:), so splitting a line on its first | fix: always reads the fix; a program reading many fields should take --json.

Exit 2 when anything blocks, 1 when an ask is unanswered, 0 otherwise (3 on a usage error). Findings sort blocks, ask, waived, polish, then by rule, file and line.

Tiers

The lint uses gstack's severity words, not a fourth scale:

Tier Meaning hallmark's word
blocks Never waivable. Correctness, access, honesty and the bans: a card in a card, a purple-blue gradient, a missing focus ring, contrast under threshold, a reveal that hides content until JavaScript runs. Floor
ask A default a model falls into. Fix it, or waive it on the record with a reason about this page. Reflex
polish Craft detail invisible at arm's length. Printed, never blocks. Finish

waived marks an ask or polish finding a waiver answered. Blocking and waived are never summed: blocks=0 is always a true sentence about the Floor. Three house rules hallmark holds at Floor stay at ask here on purpose: the eyebrow (kicker-above-heading; a figure caption or a plan name above a heading is often right), the 80-word screen (screen-word-budget; a Read-mode page legitimately exceeds it) and the dash (em-dash-overuse; gstack's rule is overuse, not a ban). A blocking rule the lint can only suspect (an invented metric it cannot prove invented) reports at ask so a person confirms it.

Waivers

A finding is answered in a comment that names the rule and gives a reason about this page:

<!-- gstack-lint-disable kicker-above-heading: the chapter number is the reader's bookmark through a forty-page spec -->
/* gstack-lint-disable section-gap: the ledger separates its rows with rules, not air */
// gstack-lint-disable template-nav: the three links are the product's three modes

impeccable-disable <rule>: <reason> is accepted as an alias, so a comment that already silences the impeccable engine answers the same tell here. The lint refuses, each as its own waiver finding at ask: a rule it does not know; the lint's own rules (render, waiver, house-style, internal-error: a fallback face or a failed measurement is never signed off); a blocking rule (the Floor is never waivable; the one exception is a blocking rule the lint could only suspect on this page, which it reports at ask and which a waiver answers like any ask); a reason under 24 characters, boilerplate ("by design", "intentional", "looks better"), reused word for word on another waiver, or sharing no word of four letters or more with the page's own text; a second waiver of one rule. More than three waivers on one page is a house style and belongs in DESIGN.md: reported once at polish as house-style.

A claim written in a stylesheet answers for that stylesheet on every page that links it, and only for findings located in it: the shared-word check does not apply (a stylesheet has no prose), so the claim cannot reach a finding in the page's own markup. A claim written in the page answers for that page, including a finding in a stylesheet it links; a second page linking the same sheet without answering still reports it, so the sheet's finding may print once waived and once at ask, each from its own page. Which of these a context gets follows its shape, not the --scope flag: one html page keeps page semantics under either scope. In a directory of components, or a lone component file beside a stylesheet (under either scope), each file's claim answers for that file alone, weighed against that file's own text and its own suspected rules, so a sibling component is never covered and a sibling that measures a blocking rule does not turn this file's suspected finding into the Floor; a stylesheet beside the components answers for itself as on a page. Two claims on one line are both read. A blocking copy tell (generic-hero-copy) is the Floor inside the hero, the section or header that holds the h1; the same line, or a "Meet " or three-word triad heading, in body copy further down is suspected and reported at ask, so a waiver can answer it.

Rules

Rule Tier Source gate The tell The fix
purple-blue-gradient blocks 2a purple-to-blue, purple-to-pink or cyan-on-dark gradient one anchor hue and solid ink
gradient-text ask 2b gradient text solid ink; carry emphasis with weight or a display face
pure-black-white blocks 7a #000 or #fff as a colour outside the token block tint toward the anchor hue: chroma 0.005 or more
absolute-surface-token ask 7b a surface token that resolves to pure black or white tint the paper
zero-chroma-neutrals ask 22 zero-chroma oklch() neutrals give the neutrals chroma 0.005 or more
accent-footprint ask 23 accent colour past about 5% of a viewport retreat the accent to actions and one signal
glow-wash ask 29 glow and wash: blurred orbs, a radial halo or a zero-offset coloured glow a solid or grained surface; elevation by lightness, shadows with offset and blur
cream-palette ask 65 the cream paper, serif display and terracotta accent look move one axis: the accent first, the paper second, the display last
framework-palette ask 74 a framework default palette (Tailwind indigo-500, slate-900 and kin) as a token or utility a colour mixed for the page in OKLCH
overused-font ask 1 a training-data display face (Inter, Roboto, Fraunces, Playfair Display, Space Grotesk and the rest of gstack's catalog) or a system stack as the display a display face chosen for the page
banned-font ask catalog a face gstack bans in any role (Papyrus, Comic Sans, Impact, Lobster, Raleway, Courier New and the rest of lib/design-catalog.ts BANNED_FONTS) a face chosen for the page; the body face for body and labels
line-length ask 25 running prose outside 45 to 75 characters a line max-width: 65ch
font-family-count ask 37 more than three font families display, body and one outlier
italic-word-in-heading blocks 38i one italic word inside an otherwise roman heading a line break, or the whole head set one way
italic-serif-display ask 38ii an italic display system roman display, or declare it on --font-display with font-synthesis: none
accented-word ask 38iii one word in a heading set apart by colour, weight, a highlight or an underline the whole head one way; a line break; a second line at a second size
caps-leading blocks 55a an uppercase display head that can wrap with line-height under 1.0 line-height 1.0 at least (1.02 to 1.08 recommended)
caps-lockup-leading ask 55b a single-line caps lockup under 1.02 line-height 1.02, or make sure it cannot wrap at 320px
monospace-costume ask 64 monospace on labels, captions or small type when the content is not code labels in the body face, a step smaller, normal case
tiny-text ask 75 running text under 12px, functional text under 11px, or prose leaded under 1.3 (render) body 16px at 1.5 to 1.65; labels one step smaller
wide-tracking blocks 67 the tracked-caps label: small text set uppercase or letter-spaced past 0.03em the body face, a step smaller, sentence case, untracked
template-cards ask 3 three equal icon-above-heading tiles, an icon in a rounded tile, or a thick coloured side stripe vary widths and heights, inline icons, hairline or no border
nested-cards blocks 4 a card inside a card one containment layer
vh-height blocks 6a a hero sized in vh dvh or svh
raw-spacing blocks 24a a raw padding, margin or gap length where a --space-* token exists the token
off-scale-spacing ask 24b spacing off the page's scale the scale; three optical nudges at most
overflow-x-unclipped ask 34 overflow-x missing on html and body, or a 100vw width overflow-x: clip on both; width: 100%
horizontal-scroll blocks 34 horizontal scroll at a width between 320 and 1920 (render) find the element wider than its viewport and cap it
hero-balance ask 44a/44c the hero's content sits off centre for its screen, or its visual stands apart from the text (render) centre the content in its screen; put the visual beside the text
hero-fold blocks 44b the headline's bottom or the hero's first action below the 800px fold at 1280 wide; the lede and the visual are not measured (render) pull the display clamp down, tighten leading, a two-line lede, trim padding
wide-container blocks 44d at 1920x1080 the container has no max-width, gutters under 120px, or a grid gains columns cap 1360 to 1520px and centre; hold column counts; cap vw visuals
bare-fr-track blocks 50 an image-bearing grid track on bare 1fr minmax(0, 1fr)
display-head-unwrapped blocks 50 a display head without overflow-wrap: break-word and min-width: 0 overflow-wrap: break-word; min-width: 0 (never anywhere)
radio-tab-jump blocks 53 CSS-only radio tabs at position: absolute; top: 0 with no JS guard radios in flow at zero size, or a handler with preventScroll
double-sticky blocks 56 two position: sticky; top: 0 elements top: var(--banner-height) on the second
screen-word-budget ask 58 more than about 80 words in one 900px window, or a hero with more than two text blocks before its actions headline, one sentence, actions; a section is a head block, then its composition
section-gap ask 59 a gap under 120px between sections at 1440, every gap the same, or more than nine sections (render) 120px at least, varied; six to nine sections a page
heading-rhythm ask 71 a heading nearer what precedes it than what it heads (render) at least twice the space above a head as below it
cramped-padding ask 72 text closer than 8px (or half an em) to the side of its filled box (render) pad the box; a 16px gutter
thin-section ask 77 a section below the hero with no mass: a narrow body on a wide canvas (render) widen the body, add a visual, set a statement, or merge the section
small-section-heads ask 78 the median h2 below the hero renders under 40px at 1440 (render) h2 at the section size, item titles at the title size
dead-band ask 79 more than 160px of nothing between the hero's lowest content and the fold (render) centre the content and grow the visual or the head; never fill with copy
hand-drawn-svg ask 80 an inline SVG of 120px or more with three curves or fills and no text: a drawn illustration (render) a photograph, the product built, or type at scale
empty-picture ask 81 a slot or gradient block of 300x200px or more showing nothing but its wash (render) the user's image, a credited photograph, or the product at full fidelity
counting-marks ask 82 24 or more identical marks standing for a quantity (render) set the number in type
photo-wall ask 83 photographs cover more than a third of the page below the hero (render) keep the strongest pictures, set big; carry the rest in type, figures, the product, a table
email-band-close ask 85 the page ends on an email sign-up form (one or two fields, one of them the email; never a form with a password or a sign-in button) on a page whose job is not subscribe, join, attend, listen or visit end on what the visitor came to do, or on the last strong section
aphoristic-cadence blocks 19 the rebuttal cadence three or more times ("Not a feature. A platform.") say what the product does, in its own nouns
generic-hero-copy blocks 19 a filler opener ("Built for the modern team", "In today's landscape"), "quietly trusted by", or the catalog's "Welcome to" and "all-in-one solution" say what the product does, in its own nouns
generic-cta-copy ask 19/42 a stock second action beside the primary (Learn more, Get started, Book a demo) one action, or a second that names where it goes
em-dash-overuse ask 19 em or en dashes carrying the prose a comma, a colon, a full stop or a line break
template-footer-line ask 19 a stock footer line (All rights reserved) the page's own line, or nothing
template-nav ask 42 the reflex nav: wordmark left, inline links, a button right, hairline under decide the nav and build another shape, or keep it on the record
template-footer ask 42 the reflex footer: four link columns, a social row, a copyright decide the footer and build another shape, or keep it on the record
invented-proof blocks 46a an invented metric, testimonial, logo wall or case-study count the user's number, a text slot, or nothing
drawn-browser-chrome blocks 47a a hand-drawn browser bar (traffic lights, URL pill) or OS window title bar a real screenshot in a
, or no chrome
device-frame ask 47b a phone, laptop, terminal or editor frame one frame with real content inside, or none
imitation-material ask 47c styled divs standing in for a product, a photo edge or a physical object the product built inside a slot, a real asset, or nothing
kicker-above-heading ask 54 the eyebrow: short inert type before a heading that only announces it open the section another way; where the heading is figurative, the label becomes the heading
template-punctuation blocks 63 template punctuation: middle-dot meta strings, an arrow glued to link text, decorative status dots line breaks, columns, hairlines; a verb as the label
wireframe-slots ask 68 more than five empty slots on one page, or four or more in one row set the set in type; a slot that stays holds a real photo or the product
repeated-fact ask 69 one fact (a price, a time, a count) printed in four or more sections say each fact once, where it does work
fake-send blocks 70 a form that prints success with no request behind it validate, post nowhere and say so plainly until the endpoint exists
layout-transition blocks 10a transition: all, or animating width, height, top or left list the properties; transform and opacity
spacing-transition ask 10b transitioning margin or padding transform
hover-tells ask 11 the same lift on every card, two effects on one element, an image scaling on hover, or a hover on something inert one signal per element, on the container, not the image
bounce-easing ask 12 bounce or overshoot easing on UI state an exponential ease-out
focus-ring-fade blocks 15 a focus ring that fades in instant: exclude outline and box-shadow from the transition
tooltip-focus-delay blocks 17 a tooltip whose focus delay equals its hover delay hover 800 to 1000ms, focus 0
autorotate-no-pause blocks 18 auto-rotating content with no pause on hover and focus pause on hover and focus; a visible pause control past 5 seconds
missing-focus-visible blocks 26a an interactive element with no :focus-visible ring, or an outline removed with nothing in its place a ring at 3:1 or better, instant
missing-states ask 26b no :active or :disabled state all the states a control can be in
missing-reduced-motion blocks 27 a keyframe or transform transition without a prefers-reduced-motion fallback add @media (prefers-reduced-motion: reduce)
autoplay-unmuted blocks 28a a video autoplaying without muted autoplay muted loop playsinline
lazy-lcp blocks 28a loading="lazy" on the first image or the hero video fetchpriority="high" on the LCP image
video-without-poster ask 28b a video without poster or a priority hint add them
input-state-shift blocks 39a input states that break: a border that changes width, a focus ring built from border, disabled by opacity alone constant border, outline, shared height, three disabled channels
control-height ask 39b control height outside 44 to 52px on a marketing page the number
content-hidden-at-rest blocks 62 a reveal class hiding content until JavaScript runs visible by default; hide only under a .js class, or animate from visible with @starting-style
emoji-decoration blocks 30a an emoji standing in for an icon one icon library, custom SVG, or typography
two-icon-libraries ask 30b two icon libraries on one page one library
unlabeled-decoration blocks 33 a decorative , or CSS-art element with neither aria-hidden="true" nor a label hide it or name it
broken-image blocks 76 an with no source or one that does not load the real file
stock-photo-hotlink blocks 76 a stock-site photo hotlink the user's image, or a library photograph credited on the page
text-occlusion blocks 73 text painted under another element (render) give the text its own space, or its own surface above the cover
low-contrast blocks 40 text, an icon, a focus ring or a field edge under its contrast threshold fix the pair; body 4.5:1, large text and edges 3:1
gray-on-color blocks 41 button text within a step of its fill, or grey text on a coloured surface the paired ink token
literal-outside-tokens blocks 48 a colour or font-family literal outside the token block lift it into the tokens
button-label-wraps blocks 49a a button label or primary nav link that wraps shorten it; white-space: nowrap
link-wraps ask 49b a footer link, tab or breadcrumb that wraps shorten it
straight-quotes polish F1 straight quotes in prose curly quotes
three-dot-ellipsis polish F2 three dots for an ellipsis the ellipsis character
balanced-display-head polish F3 a display head without text-wrap: balance text-wrap: balance
two-primary-buttons polish F4 two filled primary buttons on one page, or side by side one filled primary; the second as a text or outline action
icon-coin polish F5 an icon in a coloured coin the icon inline, bare
display-figures-tabular polish F6 display-size figures without tabular numerals font-variant-numeric: tabular-nums
stamped-recipe polish F7 one container recipe stamped on six or more selectors, or the pill radius on five or more non-controls vary the recipe; pills on controls, tags and badges only
scroll-choreography-mix polish F8 more than one scroll-choreography pattern one pattern a page
fake-avatar blocks F9 a fake-human avatar hotlink the user's people, initials, or no portrait
stock-person-photo polish F9 a person-keyword stock photo URL the user's photograph
page-invariants polish F15 page invariants broken: primary button style, container width, column gap or radius drifting one recipe for each
unthemed-browser-surfaces polish F16 no ::selection, caret-color or scrollbar-color on a page with a committed palette theme the browser surfaces
text-sliver polish F17 a text block squeezed into a sliver at 1440 (render) give the block room or merge the columns
tiny-slot polish F18 a slot rendering under 40px in either dimension (render) size the slot for what it will hold
font-weight-count polish F19 more than three font weights in running use (render) three weights
lonely-grid-item polish F20 a grid or wrapping row leaving one item alone on its last row (render) change the count or the columns
skipped-heading polish F21 a heading level skipped step the levels one at a time
justified-text polish F22 justified text without hyphens: auto hyphens: auto, or left-aligned
missing-lang polish F23 a document without lang lang on <html>
hairline-under-shadow polish F24 a hairline border under a wide soft shadow on one surface the shadow or the border, not both
placeholder-name blocks craft a placeholder name (John Doe) use a supplied name, or one that fits the product in sample data
placeholder-brand blocks craft a placeholder brand (Acme, Nexus) use a supplied customer, or a fitting name in sample data
lorem blocks craft lorem ipsum placeholder text write real copy
marketing-buzzword ask craft a marketing buzzword say the specific thing
round-number blocks craft a round invented number use the supplied figure, or drop the claim
needs-proof blocks craft a [Needs proof] marker left in the page supply the proof or remove the claim before publishing
oops ask craft "oops" as an error message say what happened and what to do
scroll-listener ask craft a scroll event listener use IntersectionObserver; read scroll inside requestAnimationFrame for a scroll-linked effect
dead-link ask craft a dead link (href="#") link it, or render the control disabled
alert ask craft alert() for a message show the message inline
img-no-alt blocks craft an without alt add alt text (alt="" when decorative)
missing-title blocks craft a document without a