Skip to content

feat(studio): fail the suite on a class that resolves to nothing - #3623

Draft
miguel-heygen wants to merge 2 commits into
feat/studio-u1-theme-tokensfrom
feat/studio-u2-token-gate-ratchet
Draft

feat(studio): fail the suite on a class that resolves to nothing#3623
miguel-heygen wants to merge 2 commits into
feat/studio-u1-theme-tokensfrom
feat/studio-u2-token-gate-ratchet

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Lands unit U2 (token gate and hex ratchet) of the Studio design-system foundation. Stacked on the theme PR (#3621). Ratchet baseline: 475 color literals across 104 files, committed; this PR adds none.

What

Two tests in packages/studio/src/styles that fail the Studio suite when a design value stops resolving.

  • Token gate (tokenGate.test.ts). Compiles Studio's real entry stylesheet through Tailwind v4's compile() with every class the source claims as the candidate list, then reports file: class for any candidate that produces no selector.
  • Hex ratchet (hexRatchet.test.ts). Counts colour literals (#hex, rgb(), rgba(), hsl(), hsla()) per file against a committed baseline. A rise fails; a fall passes and prints the command that banks the lower number.
  • classCandidates.ts, the pure extractor both the gate and its own tests use, plus styleSources.ts, the small shared reader the three style tests now share.

Stacked on the Tailwind v4 and theme-token branches.

Why

Tailwind has no strict mode for the classes it finds in markup. A class it cannot compile is silently dropped, so a button asking for rounded-button renders with no radius, review reads it as real, and nothing anywhere goes red. Colour literals have the same shape of problem in reverse: they always work, so they accumulate.

Both checks are vitest tests inside packages/studio so they run in the required Test job. A lint-only rule would not block.

How

Tailwind is the judge, so there is no allowlist. Static utilities, arbitrary values, theme tokens and Studio's own hand-written CSS rules all appear in the emitted sheet; a name nothing defines does not. Two structural exceptions, both namespaces rather than lists: Tailwind's group and peer variant markers, which by design emit no rule, and the hf- prefix Studio reserves for its own semantic hooks, which are not utilities and carry no design value.

The extractor is deliberately narrow. It reads className / class attributes, arguments to class-building calls, *class/*className properties, and bindings named *Styles / *Classes / *ClassName(s) (the lookup-table shape the primitives use, which never reaches a className literal). Anchors are matched against a masked copy of the file with comment and string bodies blanked, so prose in a doc comment and generated markup inside a template literal are both invisible to it. A template chunk that touches an interpolation is dropped at that edge.

The ratchet's baseline is per file, not per repository. Two sweep PRs touching the same file conflict on that file's line, which is exactly when a recount is wanted. A file absent from the baseline has a baseline of zero, so a rename cannot smuggle colours past it. The baseline is only written under HEX_BASELINE_WRITE=1, never as a side effect of a normal run, and a missing baseline fails with that flag named.

First-run false-positive list. The gate was run over the tree before it was allowed to fail the suite. Every entry is accounted for; none is allowlisted.

Finding Count Resolution
Prose from doc comments, words from generated markup, comparison operands (activeTool === "razor"), lookup keys, inline CSS text ~1350 occurrences Extractor bugs, all fixed: literal masking, a comparison check, a CSS-text check, and a call-context rule so a string inside a non-class-building call is not read
hf-fx-*, hf-automation-*, hf-volume-row 81 names Real: referenced in markup, styled nowhere in the repo. Studio's reserved hook prefix, outside Tailwind's namespace, so the gate does not judge them. Flagged below as follow-up work
rounded-button, shadow-btn-primary, bg-surface-hover, bg-accent-red, ease-standard, text-2xs, bg-panel-bg-soft, bg-panel-bg-2 8 names, 15 occurrences Real: no config has ever defined them, in v3 or v4. Tokens added to theme.css
is-micro on the timeline clip 1 Real: every sibling state class has a rule in studio.css, this one has none anywhere. Dead class removed

The new tokens: --radius-button and --ease-standard are named by role and alias --radius-md and --ease-out-quint; --shadow-btn-primary is a new value, a contact shadow plus an ambient one plus an inner top highlight for the one light surface in a dark UI; --color-surface-hover, --color-accent-red, --text-2xs, --color-panel-bg-soft and --color-panel-bg-2 go in the deprecated block as aliases for the sweep to remove, so there is still one source per decision.

Adding them changes what renders: those classes previously compiled to nothing, so the affected surfaces had no background, no radius and no shadow at all. The alias targets are a judgement call and worth a look in review.

Baseline total: 475 colour literals across 104 files. Higher than earlier hand counts because this regex also counts rgb(), rgba(), hsl() and hsla() alongside hex, as the plan requires. Whatever this rule sees is the number.

Test plan

  • bunx vitest run --poolOptions.forks.maxForks=4 in packages/studio: 431 files, 4778 tests, all passing.
  • The token gate over the whole tree runs in 0.3 s, well inside the 10 s budget.
  • Both gates proven non-vacuous by breaking the source on purpose: adding rounded-nonesuch and a hex to ui/Button.tsx fails both, each naming the file and the offending string.
  • Extractor unit tests cover class maps, cn() arguments, variant stripping, arbitrary-value counting, interpolation edges, unterminated regions and non-class strings.
  • Ratchet unit tests cover a rise, a fall, an unknown file, the write-flag round trip and the missing baseline.
  • bun run typecheck, bun run build, bunx oxlint, bunx oxfmt --check all clean.
  • bunx fallow audit --base origin/main --fail-on-issues passes, at the same counts as the branch point.

Not covered

  • The hf- hook classes are exempt, not fixed. 81 names are referenced in markup and styled nowhere in the repo. They are either intentional semantic hooks or leftovers from removed CSS; either way, deciding which needs a look at each panel and belongs with the sweep, not here.
  • No sweep. Nothing is migrated onto the new tokens, and the deprecated aliases stay until the sweep removes them.
  • Variants are stripped before judging, per the plan, so hover: resolves as bg-x. A misspelled variant is not caught.
  • The extractor is a scanner, not a parser. A regular-expression literal containing a quote is read as a string; the cost is a spurious candidate, which the gate reports out loud rather than swallowing. Dynamic class construction outside the named contexts is not seen.
  • The ratchet counts text, not semantics. An identifier that is exactly 3, 4, 6 or 8 hex characters reads as a colour, and a colour inside a comment counts. The count only has to be stable and monotone.
  • The hf-color-grading-* and timeline-clip rules in studio.css are untouched.

Follow-up commit

fix(studio): gate a class by where it is used, not what it is called. The first version decided candidate-ness by the name of the binding: only Styles / Classes / ClassName(s) counted. A primitive that keeps its size classes in a record named buttonSizes was therefore invisible to the gate, so rounded-hologram inside that record stayed green while the identical string in a className attribute went red. A name is not a contract.

Candidate-ness now follows one file's data flow. Every identifier used inside a className / class attribute or a class-building call marks whatever that identifier is bound to elsewhere in the file as a class list, under any name. The four masking rules that kept the first run's false positives out (comments, generated code inside template strings, comparison operands, non-class call arguments) are unchanged, and one more joins them: a subscript is excluded, so the key of variantStyles[variant] is not read as a class and the parameter default variant = "ghost" is not read as a class list. The name-based anchor is kept as a second source rather than the only one, because it is the only thing that can see a class map whose only consumer lives in another module, which single-file data flow cannot follow.

Running the widened gate over the whole tree produced two new hits, one bug in the extractor and one real:

  • Three names from ui/Button.tsx (md, ghost, secondary) were lookup-key defaults, not classes. That is the subscript rule above, and a unit test pins it.
  • bg-panel-bg-3 in the automation selection menu is a real one: no config has ever defined that token, so the menu row's hover background compiled to nothing. It now uses the hover token the rest of that row already uses.

Both the extractor test and the gate test are proven non-vacuous: with the old extractor restored, the buttonSizes fixture and the subscript fixture fail and the gate reports nothing.

Test plan for this commit: bunx vitest run src/styles --poolOptions.forks.maxForks=4 (38 passing, 4 files), the full Studio suite once (431 files, 4781 tests, 1 skipped, all passing), bun run typecheck, bunx oxlint and bunx oxfmt --check on the changed files, and bunx fallow audit --base origin/main --fail-on-issues clean. classCandidates.ts is 337 lines.

Still not covered: the data flow is one file deep and one hop long. A class list assembled through two intermediate variables, or imported from another module under a name that does not end in Styles, is still only reached by the name-based anchor.

Tailwind silently drops a class it cannot compile, so `rounded-button`
renders as no radius at all and nothing goes red. Two tests close that:

- the token gate compiles Studio's entry stylesheet with every class the
  source claims and reports `file: class` for anything that produces no
  selector. Tailwind is the judge, so there is no allowlist.
- the hex ratchet counts colour literals per file against a committed
  baseline. It fails on a rise, and on a fall it prints the command that
  banks the lower number. The baseline is only written under a named
  flag, never as a side effect of a run.

The gate's first run found seven names the markup has always asked for
and no config has ever defined: rounded-button, shadow-btn-primary,
bg-surface-hover, bg-accent-red, ease-standard, text-2xs and two panel
backgrounds. Each is added to theme.css, the semantic ones by role and
the rest as deprecated aliases for the sweep to remove. It also found a
dead state class on the timeline clip, styled nowhere, which is deleted.
The extractor treated a string as a class candidate only when its binding
matched Styles/Classes/ClassName(s), so the same twelve classes were gated
in `sizeStyles` and invisible in `buttonSizes`. A name is not a contract.

Candidate-ness now follows one file's data flow: an identifier used inside
a className attribute or a class-building call marks whatever is bound to
it as classes, under any name. A subscript is excluded, so the key of
`variantStyles[variant]` is not mistaken for a class list. The name-based
anchor stays for the map whose only consumer is another module.

The sweep this opened found one class no config has ever defined: the
automation menu's hover row asked for a background that compiled to
nothing, and now uses the token the rest of that row already uses.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant