Skip to content

feat(ui): add an opt-in, agent-first design-system linter - #1479

Merged
vivek7405 merged 9 commits into
mainfrom
feat/ui-design-system-linter
Sep 18, 2026
Merged

vivek7405 merged 9 commits into
mainfrom
feat/ui-design-system-linter

Conversation

@vivek7405

@vivek7405 vivek7405 commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

Closes #1478

Adds webjsui lint, an opt-in design-system linter in @webjsdev/ui. It reads the Tailwind classes an app writes inside html templates, cn() calls and class=${...} holes, and reports at the exact line where the app drifts off its own theme, with a message built from the app's real tokens and helper variants. Three rules ship (no-raw-colors, no-arbitrary-values, no-restyle), each off unless a lint block in components.json turns it on, so no existing install changes behaviour.

What changed

  • rawConfigSchema accepts an optional, strict lint block (it is .strict(), so a lint key threw before).
  • extractHelperAxes in the shared projector reads a helper's variant and size values from source, so a no-restyle message names what the app's copy actually declares. Drift-guarded against the kit button.
  • A hand-rolled class-site scanner over html tagged templates, cn() arguments and class=${...} holes. A class= is a site only inside an open tag, which is what keeps the entity-escaped code sample in the file-storage docs page from being reported.
  • The token grammar (arbitrary VALUE only when the utility segment carries a bracket) and shadcn's category taxonomy, transcribed verbatim from shadcn-ui/lint so allow: ["layout"] means the same thing in both tools.
  • A theme-token reader over the configured Tailwind CSS file, accepting both @theme and @theme inline. No tokens turns no-raw-colors off for the run with one warning naming the path.
  • The webjsui lint command: a webjs check-shaped text report with a severity marker, --json, --max-warnings, and 0/1 exit codes. components/ui/** is skipped by default; a negated ignore entry ("!components/ui/**") widens the scope.
  • The three-arm eval harness under packages/ui/test/evals/ (before, after with diagnostics, rules-only control), run on demand through the claude CLI. It is the gate before any surface tells an agent to run the linter.

Against the blog with a lint block, all four text-red-600 feedback lines are reported and the escaped docs sample is not.

Deliberately excluded

  • Nothing lands in webjs check, which stays correctness-only.
  • No token is added to the kit theme and the sonner palette colors are left alone.
  • No scaffold or CI wiring, and no skill pointer telling an agent to run it. That is phase 3 of the issue and gated on the eval result.
  • No new dependency. The package still depends on commander, kleur, prompts and zod only, and does not import @webjsdev/server or @webjsdev/mcp.
  • No autofix.

Test plan

  • Unit: npm test --workspace=@webjsdev/ui (275 pass after the review fixes) and root npm test (4688 of 4694; the 5 failures are the known linked-worktree set, listener, listener-overhead and three elision assertions, which pass in the primary and in CI)
  • Counterfactual: the blog feedback line reports no-raw-colors at line 29 and clears with text-destructive (lint-command.test.js, proven at cdea21cc, re-proven at 5194c88c with the shape pinned inline)
  • webjs check passes in gallery, examples/blog and website; webjs doctor in website has 0 failures
  • Dogfood: website boots in prod mode with /ui and /docs/styling at 200, 12 preloads each, none broken, both carrying the new content
  • Browser / e2e / smoke / Bun: N/A, a Node CLI over source files touching no runtime-sensitive surface (only node:fs and node:path, already used throughout packages/ui/src)

Note for a linked worktree: webjs ui lint through the CLI wrapper resolves @webjsdev/ui into the primary checkout, so it reports unknown command 'lint' there. The wrapper spawns the ui bin with the arguments verbatim, and node packages/ui/bin/webjsui.js lint from the branch works.

Docs

  • packages/ui/README.md (command row), packages/ui/AGENTS.md (lede, module map, command row, a section on the config, rules, taxonomy, scope and phases)
  • .agents/skills/webjs/references/ui-kit.md (the command and its block), references/styling.md (the allow: ["layout", "rounded"] sentence at the sanctioned one-off)
  • website/app/ui/page.ts (command row), website/app/docs/styling/page.ts (a subsection at #lint)
  • Root AGENTS.md CLI reference, packages/cli/AGENTS.md and the webjs ui usage string list lint
  • Scaffold templates, create.js, MCP, editor plugins: N/A in phase 1 by design (no scaffold wiring until the gate), and the MCP ui tool projects the kit, not the linter

@vivek7405 vivek7405 self-assigned this Sep 16, 2026
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Design notes: the class-to-group resolver, opacity stripping, and two scanner calls the issue left open

The issue settles the taxonomy (shadcn's GROUP_CATEGORY, verbatim) but says nothing about how a utility gets to a group id in the first place. Upstream does that through tailwind-merge's config, which is exactly the dependency the issue rejects. So grammar.js carries its own resolver: a prefix table for the groups that take a plain value, plus explicit colour-versus-size splits for the prefixes Tailwind overloads (text-, bg-, border-, ring-, shadow-, outline-, divide-, stroke-, decoration-, the gradient stops). An arbitrary value on one of those is read as a colour only when its inner text looks like one (a hash, a colour function, a color: hint, or a --color-* variable), so text-[13px] is a font size and text-[#333] is a colour, which is the same call tailwind-merge makes. Anything the table cannot place resolves to null, which is layout, the permissive direction: an unknown class can be allowed by mistake but never reported by mistake.

The /opacity strip is scoped to colour groups. A naive top-level split would turn w-1/2 into w-1 at opacity 2 and text-sm/6 into a size with an opacity, so the parser resolves the part before the slash first and strips the suffix only when that resolves to a colour group.

Two scanner calls the issue's D2 did not pin down, both decided toward fewer false positives. A recognised *Class() helper's own arguments are never read as classes, because buttonClass({ variant: 'secondary' }) carries option values, and the issue's own expected output for that line lists three classes, not four. And a cn() call inside a class=${...} hole feeds the hole's site rather than opening a second one, which is what makes the "never reported twice" guarantee structural instead of a dedupe pass.

The ignore semantics changed shape slightly from the issue text. Zod defaults ignore to [], so "clearing the skip through ignore" cannot be detected as an absent key. The default components/ui/** entry is therefore always present and a negated entry ("!components/ui/**") removes it, the way every glob-based ignore list works.

@vivek7405
vivek7405 marked this pull request as ready for review September 16, 2026 15:11

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read the whole diff plus the scanner, grammar, rules, orchestrator, command, extractor and the eval harness. The scanner is careful (the tag-region rule, the hole-fragment rule, the mute for helper args, position tracking through escapes) and the tests cover the shapes the description claims. Nothing here blocks the opt-in guarantee.

What I would fix before merging:

  1. Two prefix-ordering bugs in groupOf: text-shadow-* and bg-blend-* both resolve to a colour group (the dedicated branches are dead), so an allow: ["effects"] never admits them and the messages call them colours. Suggestions inline.
  2. --json is not JSON on the two early exits (missing / invalid components.json), and run.mjs turns any non-JSON lint output into count: 0, so a broken linter reads as a clean eval. Since the harness is the phase-3 gate, that one matters more than its size.
  3. The no-raw-colors message says "error text" for a border- / ring- / fill- offender.

Smaller: non-class string literals in a class hole are collected as classes (only layout saves them), commented-out markup inside an html template is still scanned, no-restyle names only the first helper of a site, a redundant ternary on cnNames, and in no-raw-colors ordered / list are rebuilt per token and could sit above the loop.

I did not apply any of these (the review-only rule); happy to fold them in on the branch if you want that as a separate ask.

Comment thread packages/ui/src/lint/grammar.js Outdated
Comment thread packages/ui/src/lint/grammar.js
Comment thread packages/ui/src/commands/lint.js Outdated
Comment thread packages/ui/test/evals/run.mjs Outdated
Comment thread packages/ui/test/evals/run.mjs Outdated
Comment thread packages/ui/src/lint/rules/no-raw-colors.js Outdated
Comment thread packages/ui/src/lint/scan.js Outdated
Comment thread packages/ui/src/lint/scan.js
Comment thread packages/ui/src/lint/index.js Outdated
Comment thread packages/ui/src/lint/rules/no-restyle.js Outdated

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of the linter at f7ddfe88, run inline (no reviewer subagents) at high effort for recall. Ten findings, most severe first, each anchored where it fails; the extractor, scanner, grammar and import-list ones were reproduced by running the code against the kit button.ts and small templates.

Not fixed on the branch: the webjs-ui-lint worktree had uncommitted edits landing from another session while this review ran (several of them address the same lines: the text-shadow order, the HTML-comment region, the message wording, the eval double lint, and a --json error document), so applying fixes there would have collided. The findings stand against the pushed head; whichever session owns the worktree can fold them in.

Comment thread packages/ui/src/registry/extract.js
Comment thread packages/ui/src/lint/scan.js
Comment thread packages/ui/src/lint/grammar.js
Comment thread packages/ui/src/lint/index.js Outdated
Comment thread packages/ui/src/commands/lint.js
Comment thread packages/ui/src/lint/scan.js Outdated
Comment thread packages/ui/test/lint-command.test.js
Comment thread packages/ui/src/lint/rules/no-raw-colors.js Outdated
Comment thread packages/ui/test/evals/run.mjs Outdated
Comment thread packages/ui/src/lint/index.js Outdated

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Third pass at 5194c88c, run inline at high effort for recall, after the two earlier rounds were fixed. Every finding below was reproduced against the branch (a probe script over parseToken / scanClassSites / the rules, plus a real webjsui lint run on a scratch copy of gallery with the harness's own LINT_BLOCK). Ten findings, most severe first. The scanner held up on all three in-repo apps (1481 sites, no desync, no odd tokens), so the scanner items here are coverage gaps rather than misreads.

Comment thread packages/ui/test/evals/run.mjs Outdated
const before = scratchCopy();
const b = agent(before, task.prompt);
const beforeLint = lint(before);
result.before = { findings: beforeLint.summary.count, cost: b.cost };

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The harness counts findings over the WHOLE scratch app, and gallery is not clean under this exact LINT_BLOCK: a plain webjsui lint on an untouched copy reports 90 warnings (p-6 beside cardClass, text-[13px], no-underline beside buttonClass, ...). So before.findings starts at 90 before the agent writes a byte, the after prompt says "the file you produced" and then lists 90 diagnostics in files the agent never touched, and after.findings === 0 is reachable only by rewriting the gallery. Both correction arms will run to the round cap on every task, so after.meanRounds < rulesOnly.meanRounds and zeroFindings === ok.length cannot come out true and the phase 3 gate measures nothing.

Lint the pristine copy once, keep that as a baseline multiset keyed on file|rule|class (line numbers shift when the agent edits app/page.ts), and have every arm count and print only the violations not covered by the baseline.

column: token.column,
class: token.name,
message,
...(roleDeclared ? { fix: `${prefix}-${role}` } : {}),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

class is the raw token and fix is built from the stripped base, so the pair is not a drop-in replacement. Measured: hover:text-red-600/50 reports class: "hover:text-red-600/50", fix: "text-destructive", and dark:md:bg-gray-100 reports fix: "bg-muted". An agent loop that applies fix over class (the point of a machine-readable field) silently drops the variants, the opacity modifier and a !, turning a hover colour into an always-on one. Rebuild fix from token.name with only the base swapped, so the variants, /50 and ! survive.

}
if (close === -1) break;
const body = css.slice(open + 1, close);
const declRe = /--color-([A-Za-z0-9_-]+)\s*:/g;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reader is not comment-aware, which breaks the rule's one promise ("never names a token the theme does not declare"). Measured on @theme inline { --color-a: red; /* } */ --color-b: blue; } preceded by a commented-out /* @theme { --color-fake: red; } */: the result is ['fake', 'a']. So a commented-out token (/* --color-old: ...; */, common while iterating on a theme) is offered as text-old, which Tailwind does not generate, and a } inside a comment closes the block early and loses every token after it. Strip /* ... */ from the CSS before matching.

if (site && mute === 0 && !isComparisonOperand(quoteAt, i)) pushTokens(site, body, start);
markValue();
};
const scanPlainTemplate = () => {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A plain template literal inside a cn() call or a class hole is lexed for its holes but its static text is never collected, so the backtick spelling evades all three rules. Measured: cn('a', tpl-[3px]) yields only a, and class=${cn(buttonClass(), `w-[9px] ${y}`)} yields no site at all. cn(buttonClass(), bg-pink-500) therefore passes no-restyle, and class=${`p-4 bg-red-500 ${extra}`} passes no-raw-colors, while the docs say every string literal in those two sites is read. When a collector is active and not muted, collect the static runs here with the same hole-fragment rule the attribute site uses.

Comment thread packages/ui/src/lint/grammar.js Outdated
if (v === '') return 'border-w';
if (BORDER_STYLES.has(v)) return 'border-style';
if (v === 'collapse' || v === 'separate') return 'border-collapse';
if (v.startsWith('spacing')) return v === 'spacing' ? 'border-spacing' : `border-spacing-${v.slice(8)}`;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

v.slice(8) is the VALUE, not the axis, so this returns a group id that does not exist: border-spacing-2 resolves to border-spacing-2, border-spacing-x-2 to border-spacing-x-2, border-spacing-[3px] to border-spacing-[3px]. GROUP_CATEGORY has none of them, so the whole family reads as layout instead of spacing, no-arbitrary-values with allow: ["layout"] admits border-spacing-[3px], and the border-spacing* entries in SIMPLE_GROUPS are dead because the border head gets there first.

Suggested change
if (v.startsWith('spacing')) return v === 'spacing' ? 'border-spacing' : `border-spacing-${v.slice(8)}`;
if (v.startsWith('spacing')) {
const axis = /^spacing-([xy])(?:-|$)/.exec(v);
return axis ? `border-spacing-${axis[1]}` : 'border-spacing';
}

Comment thread packages/ui/src/lint/grammar.js Outdated
if (/^(?:wrap|nowrap|balance|pretty)$/.test(v)) return 'text-wrap';
if (/^(?:ellipsis|clip)$/.test(v)) return 'text-overflow';
if (v === 'base' || T_SHIRT.test(v) || isArbitraryLength(v)) return 'font-size';
if (/^(?:base|xs|sm|lg|\dxl|xl)\/[\w.]+$/.test(v)) return 'font-size';

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The line-height modifier only matches [\w.]+, so the arbitrary and variable spellings fall through to text-color: text-sm/[17px] and text-lg/(--lh) both parse as category color. The report then says "(color, group text-color)" for a font size, allow: ["typography"] does not admit it, and allow: ["color"] wrongly does. The same fallthrough exists under bg- a few lines down: bg-[length:200px] (and the size: / position: / percentage: hints) resolves to bg-color instead of bg-size / bg-position. Accept [...] and (...) after the slash here, and route the typed bg-[...] hints before the bg-color default.

Comment thread packages/ui/src/lint/grammar.js Outdated
return 'flex';
}
if (utility.startsWith('mask-')) return 'mask-image';
if (utility.startsWith('scrollbar-')) return utility.includes('thumb') ? 'scrollbar-thumb-color' : 'scrollbar-track-color';

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This catch-all runs before SIMPLE_GROUPS, so its scrollbar-gutter and scrollbar-w entries are unreachable and scrollbar-gutter-stable resolves to scrollbar-track-color, category color (measured). Upstream places both groups in layout (null), so beside a helper the token is reported under allow: ["layout"] with a message telling the author to allow "color".

Suggested change
if (utility.startsWith('scrollbar-')) return utility.includes('thumb') ? 'scrollbar-thumb-color' : 'scrollbar-track-color';
if (utility.startsWith('scrollbar-') && !/^scrollbar-(?:gutter|w)(?:-|$)/.test(utility)) return utility.includes('thumb') ? 'scrollbar-thumb-color' : 'scrollbar-track-color';

Comment thread packages/ui/src/lint/scan.js Outdated
const piece = part.trim().replace(/^type\s+/, '');
if (!piece) continue;
const [imported, local = imported] = piece.split(/\s+as\s+/).map((s) => s.trim());
if (inUi && /Class$/.test(local)) { helpers.push(local); helperFiles[local] = target; }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recognition keys on the LOCAL name, and the axes lookup in lint/index.js (r.axes[helper]) keys on it too, while extractHelperAxes keys on the EXPORTED name. Measured: import { buttonClass as bc } from '#components/ui/button.ts' recognizes no helper, so everything composed beside bc() passes no-restyle, and import { buttonClass as primaryButtonClass } is recognized but gets the no-value-list fallback message because axes['primaryButtonClass'] is undefined. Test imported against /Class$/ and carry the exported name alongside the file. Related, same regex: import Def, { cn } from '#lib/utils/cn.ts' does not match at all, so that module's cn() calls are never opened.

/** A colour or typography override reads as a variant; a size-ish one as a size, when the helper has one. */
function pickAxis(parsed, axisNames) {
const cat = parsed.category;
const sizeLike = cat === null || cat === 'spacing' || cat === 'shape' || cat === 'typography';

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc comment one line up says a typography override reads as a VARIANT, and this line files all of typography under size. The real gallery run shows the effect: class="${buttonClass()} no-underline" reports "Use a buttonClass size: default, sm, xs, none", and no size touches the underline (the link variant does). Only the font-size and line-height groups are carried by a kit size (text-xs in the registry button's xs), so narrow it to those:

Suggested change
const sizeLike = cat === null || cat === 'spacing' || cat === 'shape' || cat === 'typography';
const sizeLike = cat === null || cat === 'spacing' || cat === 'shape' || parsed.group === 'font-size' || parsed.group === 'leading';

Comment thread packages/ui/src/lint/index.js Outdated
if (entry.startsWith('!')) unignore.push(entry.slice(1));
else ignore.push(entry);
}
const ignoreRes = ignore.map(globToRegExp);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An ignore entry is a full-path glob and nothing else, so the directory spellings people write from .gitignore habit match no file and are silent no-ops. Measured with globToRegExp: app/legacy, app/legacy/ and ./app/** all fail to match app/legacy/x.ts, and the negated !components/ui un-ignores nothing. The schema is strict precisely so a config mistake is not silent, and this one is. Normalize each entry (drop a leading ./ and a trailing /) and also match <entry>/**, so a bare directory covers its subtree.

rawConfigSchema is strict, so an app that adds a lint key today makes
getConfig throw. The new lintConfigSchema makes the block optional and
keeps it strict, so a typo in a rule name or severity is a config error
rather than a silent no-op. extractHelperAxes reads the variant and size
VALUES a helper exposes from its own source text, which is what a
no-restyle message has to name, and it lives beside
extractHelperSignatures so the two projections cannot drift.
The grammar parses one token into variants and a utility, decides
arbitrary VALUE against arbitrary VARIANT on whether the utility segment
carries a bracket, and resolves the utility to a class group and to the
category taxonomy transcribed verbatim from shadcn-ui/lint. The scanner
reads class sites from html tagged templates, cn() arguments and
class=${} holes with a hand-rolled lexer, and only inside an open tag, so
an entity-escaped code sample in a docs page is never a site. The theme
reader parses --color-* tokens from both @theme and @theme inline.
…ules

Each rule is a pure function over a class site so its tests are a
string in and an array out. no-raw-colors names only tokens the app
theme declares and adds a role suggestion only when unambiguous.
no-restyle names the helper variants and sizes the shared projector
reads from the app copy, guarded by a drift test against button.ts, and
the skill sanctioned one-off passes under allow layout plus rounded.
The orchestrator walks app, components, modules and lib, skips the
resolved ui directory by default (a copied primitive owns structural
values no variant expresses and is what other files are measured
against), reads the theme once and turns no-raw-colors off with a
warning when it yields no tokens. The command mirrors webjs check in
report shape and 0/1 exit posture, adds a severity marker, --json and
--max-warnings, and reports nothing with no lint block so no existing
install changes behaviour.
The skill, the /ui page and the styling docs describe the command and
the lint block; styling.md names the allow configuration under which
its sanctioned icon-button one-off passes, so the rule and the skill
agree. The three-arm eval under packages/ui/test/evals runs on demand
and is the gate before any surface tells an agent to run the linter.
The wrapper spawns the ui bin with the arguments verbatim, so lint
already dispatches; only the usage string and the two command tables
named the old six.
A longer head is matched before the prefix it starts with, so
text-shadow and bg-blend resolve to their own groups. The projector
blanks comments before matching braces, so a comment in an app copy of
a variant map cannot swallow the value list. A comparison operand or a
case label in a class hole is not a class, commented-out markup opens
no tag, and a comment inside an import list is not a binding. A negated
ignore entry un-ignores what it matches rather than one exact string,
--json answers the early exits with an error document, a non-integer
--max-warnings is refused, the role sentence names the surface by
prefix, and no-restyle names every composed helper. The eval harness
throws on a run that yields no document and lints once per round. The
command test pins the blog shape inline instead of live line numbers.
The scanner missed static text in plain template literals inside cn() and class holes, keyed helper recognition on the local import name so an aliased or default-plus-named import went unseen, and kept its html lexer state into a hole so a leading regex or template was misread. The grammar filed border-spacing, scrollbar-gutter, and slash or typed text and bg values under the wrong category, which made the allow list admit or reject the wrong classes. The reported fix dropped variants, opacity, and the important flag, the theme reader treated commented-out tokens as live, no-restyle named the size axis for any typography class, and directory-style ignore entries matched nothing without saying so.
The pristine gallery copy already reports about 90 warnings under the harness's own lint block, so a whole-app count started every arm far above zero and the gate could never pass. Each arm now subtracts a baseline taken from the pristine copy, keyed without positions because an edit shifts the lines below it.
@vivek7405
vivek7405 force-pushed the feat/ui-design-system-linter branch from ce47ea1 to 5e9ca9e Compare September 18, 2026 11:43
@vivek7405
vivek7405 merged commit cb07357 into main Sep 18, 2026
10 checks passed
@vivek7405
vivek7405 deleted the feat/ui-design-system-linter branch September 18, 2026 12:35
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.

feat(ui): add an opt-in, agent-first design-system linter

1 participant