Skip to content

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

Open
vivek7405 wants to merge 7 commits into
mainfrom
feat/ui-design-system-linter
Open

vivek7405 wants to merge 7 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

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.
@vivek7405 vivek7405 self-assigned this Sep 16, 2026
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.
@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
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.
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