You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Verified at HEAD 79fc28fc. Every line anchor below was re-checked against that commit and corrected where the original body was stale.
Problem
Two @webjsdev/ui registry modules run work at MODULE SCOPE. The elision analyser correctly reads that as a client side effect, so any page or layout with a component-free path to them ships whole instead of being elided (#605). Because cn is reached by essentially every kit helper, and every scaffolded app runs webjsui init, this silently costs page elision in every app using the kit.
Reproduced end to end at HEAD, with the CLI
A synthetic four-file app (a page importing buttonClass / cardClass, a second page importing inputClass / nativeSelectClass, the registry lib/utils.ts copied to lib/utils/cn.ts, the registry components copied to components/ui/) reports, from webjs elision run in the app directory:
BEFORE
3 route module(s): 1 inert, 0 import-only, 2 ship whole.
inert app/layout.ts
shipped app/page.ts blocked by lib/utils/cn.ts, which references a browser global at
module scope, runs code at module scope, or has a bare side-effect import
shipped app/settings/page.ts blocked by components/ui/native-select.ts, ... (same reason)
AFTER (memoise GROUPS, delete the native-select injection)
3 route module(s): 3 inert, 0 import-only, 0 ship whole.
The same defect is live in this repo. Run in examples/blog:
cd examples/blog && node ../../packages/cli/bin/webjs.js elision
and app/ui-demo/page.ts reports shipped, blocked by lib/utils/cn.ts. That page imports #components/ui/dialog.ts (a Tier-2 element that ships), so after the fix its verdict becomes import-only emitting components/ui/dialog.ts, not inert.
The command an implementer runs to see it, in any app that uses the kit:
npx webjs elision # human verdict, per route module, naming the blocker
npx webjs elision --json # machine-readable, byte-identical to the MCP list_elision tool
1. lib/utils.ts builds its GROUPS table with a module-scope call
packages/ui/packages/registry/lib/utils.ts L53 opens the table and L145 closes it. L103 is the offending line:
constGROUPS: Array<[RegExp,string]>=[// L53
...
...borderGroups(),// L103: a real call, at module load
...
];// L145
hasModuleScopeSideEffect() (packages/server/src/component-elision.jsL321, not L312) flags borderGroups( as a top-level call, which is correct: it genuinely runs on import. The human sentence in the report comes from clientEffectReason() at L1170 (there is no elisionReason() in the file; that name in the old body is wrong).
Measured by running the framework's own predicate against the unmodified file, both raw and comment-masked (the form analyzeElision actually feeds it):
quote-desync bail : NO
final brace depth : 0 (balanced, no conservative bail)
flagged token : borderGroups( <- a real call, L103
2. native-select.ts injects a stylesheet at module scope
Predicate output for that file: no desync, depth 0, flagged token installNativeSelectStyles(. It is both a module-scope call AND a document reference, so the module is doubly client-effecting, and it pins every page that renders a <select>.
It is also a progressive-enhancement regression on its own terms. The rule exists to stop <option> text going invisible in dark mode, and injecting it from JavaScript means the bug is still visible with JavaScript disabled, and for one frame before hydration with it enabled. The CSS is four lines of static, selector-only rules with no dynamic input, so it belongs in the stylesheet webjsui init already manages.
Correcting #1300, which descoped this as an analyser false positive
#1300 has a section titled "Deliberately OUT of scope: the ELISION_CARRIERS warnings" that examines this exact symptom on examples/blog/lib/utils/cn.ts and concludes:
They are flagged by hasModuleScopeSideEffect ... regex bodies are not tracked, so a stray quote inside a regex literal shifts quote pairing, and at that point "the lexical state is unreliable below here, so ship conservatively" (it returns true). cn.ts is precisely that shape ... So these are analyser false positives, not app debt.
That diagnosis does not hold at HEAD, and the conclusion inverts the truth. The correction REPRODUCES: the blog copy scans identically to the registry copy (no desync, depth 0, flagged token borderGroups(). There is no quote desync and no unbalanced-brace bail. The flag comes from the last branch of the function, the ordinary top-level call check, and it is a true positive.
The check behind #1300's conclusion was for browser GLOBALS, which is only one of four OR'd conditions in isClientEffecting (component-elision.js L1161 to L1165). cn.ts is indeed free of browser globals, and is still not pure.
What IS an analyser false positive here, and stays out of scope
Running the predicate over every registry module found six more flagged files. Four of them are a genuine analyser precision gap of a DIFFERENT shape, and this issue does not touch them:
File
Flagged token
Verdict
components/pagination.ts L48, L51
cn(
False positive. export const paginationPreviousClass = (): string => cn(...) is an arrow with an EXPRESSION body, so its call sits at brace depth 0 and reads as a top-level statement.
components/progress.ts L53
join(
False positive, same shape (=> [ ... ].join(' ')).
components/sonner.ts L128
makeToast(
False positive, same shape.
components/tabs.ts L71
replace(
False positive, same shape.
components/checkbox.ts L129
installCheckboxStyles(
TRUE positive, the identical style-injection defect. Deliberately not moved, see Out of scope.
components/radio-group.ts L112
installRadioStyles(
TRUE positive, same. Deliberately not moved.
Stale claims in the previous body, corrected
The old anchors are all wrong after PR fix: split the coarse bg, shadow and text-shadow cn() groups by property #1332 edited this file. Corrected: GROUPS opens L53 (was "L51"), closes L145 (was "L96"), spreads borderGroups() at L103 (was "L74"); borderGroups() is declared at L169 (was "L120"); the single read site is L286 (was "around L231").
native-select.ts: STYLES runs L93 to L99 (was "ends around L95"); let installed is L101 and installNativeSelectStyles() is L102 to L113 (was "L102-L114"); the module-scope call is L115 (correct).
hasModuleScopeSideEffect() is at L321, not L312. There is no elisionReason() at L1145; the reporting helper is clientEffectReason() at L1170.
"the popover and hover-card docs examples both do this" is now stale. Neither packages/ui/packages/registry/components/popover.ts nor components/hover-card.ts contains the string select at HEAD; those examples moved to the marketing site in seo: serve the UI gallery at webjs.dev/ui with the marketing site chrome #1099 and were rewritten. The commit that broadened the selector is dd6dd723, and its message is the surviving record of why. The RULE still stands (any consumer can legitimately write a bare <select class=${nativeSelectClass()}>), only the cited witnesses are gone.
"Say so in the changelog" is not actionable as written. WebJs changelogs are auto-generated by scripts/backfill-changelog.js from conventional-commit subjects on a version bump, and framework-dev.md forbids hand-writing one. The copy-on-add caveat therefore goes in the commit subject/body and in the doc surfaces, not in a hand-edited changelog file.
installNativeSelectStyles has zero callers anywhere in the repo other than its own L115 invocation (verified by a repo-wide grep excluding node_modules and the gitignored website mirror).
Anchors verified unchanged
packages/ui/src/utils/theme.jsensureTheme(cwd, baseColor, cssPath, registryUrl) at L38. Correct.
Called from packages/ui/src/commands/init.js L153 and packages/ui/src/commands/add.js L47. Correct, so an app that only ever runs add does reach it.
GROUPS is module-private: never exported, and no other module in the repo imports it. Exactly ONE read site, for (const [re, g] of GROUPS) at L286 in dedupeUtilities (L283 in the blog copy).
Design / approach
Two independent fixes, one shared rule, plus one mirrored copy. Every open call from the previous body is settled below.
A. lib/utils.ts: memoise the table behind a function
Decision 3, the memoisation shape, is settled as exactly that. Verified, not assumed:
??=survives type stripping. Fed through the framework's own stripTypeScript() (packages/server/src/ts-strip.js, built-in on Node, amaro on Bun), the output still contains ??= verbatim and the module imports and runs. Logical assignment is ES2021, so it is well under the Node 24+ / Bun floor, and it is not TypeScript syntax at all, so erasable-syntax invariant 10 is untouched.
The memoised shape reports CLEAN under the framework's own predicate. Built as a prototype and scanned: hasModuleScopeSideEffect returns false, no quote desync, final brace depth 0, no flagged token. Specifically, the function DECLARATION does not trip another branch: NOT_A_CALL contains function, and the predicate additionally skips a function-declaration parameter list explicitly, so function GROUPS( is not read as a call, and the array literal inside the now-braced body sits at depth 1 and never reaches the depth-0 frame.
Output is byte-identical for every input. The prototype and the current file were both loaded and compared over the 110-token battery from test/ui/cn-copies-in-sync.test.mjs: 12,100 ordered pairs and 84,700 ordered triples, zero mismatches. The order-sensitive directional cases were checked by hand and all match: cn('px-4','py-2','p-0') is p-0, cn('p-2','px-4') is p-2 px-4, cn('border-2','border-primary') keeps both, cn('flex','flex-1') keeps both, cn('w-8','size-4') is size-4, cn('text-sm','text-primary') keeps both, cn('bg-clip-text','bg-primary') keeps both, cn('border-border','border-accent') is border-accent.
The NAME stays GROUPS on purpose. The file's comments refer to "the GROUPS table above" in three places (L196, L226, and inside hintedGroup's body comment), and the read site is the only caller, so keeping the name means one changed character at the call site and no comment churn. HINTED_GROUPS and CONFLICTS are plain object literals with no calls, the predicate does not flag them, and changing them would be pure churn.
Alternatives rejected. Building the table lazily inside dedupeUtilities with a module-level if (!_groups) guard: same effect, more lines, and the guard is a top-level-looking statement inside a function that a future reader may hoist. Inlining borderGroups()'s 18 generated entries as literals: it deletes the documented classification comment at L147 to L168 and hard-codes a side list that hintedGroup L241 is required to keep in sync with, which is exactly the drift the helper exists to prevent. Rewriting cn to delegate to clsx + tailwind-merge the way shadcn does: forbidden by packages/ui/AGENTS.md invariant 2 (no third-party runtime deps).
Prior art. shadcn keeps its copy-on-add registry modules declaration-only. ~/Documents/Projects/frameworks/shadcn/apps/v4/registry/new-york-v4/lib/utils.ts is six lines with no module-scope work at all, and across the whole of apps/v4/registry/new-york-v4/ui/*.tsx the single document. reference is inside an event handler (sidebar.tsx:86). There is no style-injecting component anywhere in that registry.
B. native-select.ts: move the CSS into the theme block, delete the injector
Decision 1, removal versus a deprecated no-op export, is settled as REMOVE. Three facts decide it, and they all point the same way:
installNativeSelectStyles has zero callers in the repo outside its own L115 invocation. There is nothing to deprecate for.
The kit is copy-on-add. An existing app holds its OWN copy of native-select.ts, which this change does not touch, so removing the registry export breaks exactly nobody's running app. A shim would only ever be read by someone who re-adds the component, and a re-adder is by definition taking the new file whole.
The project stance in the root AGENTS.md and in the recorded history is that WebJs has no users yet and prefers a clean break to an additive shim. A no-op export in a file users are told to own and edit is dead code they inherit and then have to reason about.
npx @webjsdev/ui diff shows an existing copy drifting either way, which is the intended discovery channel.
Alternative rejected: keeping the injection but making it lazy from nativeSelectClass(). That does not work, because nativeSelectClass() is called during SSR where document is undefined, so the styles would never install.
Decision 2, exactly where the <option> CSS lands, is settled as follows.
The theme block ensureTheme() writes is not composed in theme.js at all. theme.js L49 fetches the registry item theme-<baseColor> and appends item.files[0].content verbatim (L59 to L63). For neutral that content IS packages/ui/packages/registry/themes/index.css; the other six base colours are synthesised by mergeThemeCss in packages/ui/packages/registry/themes/base-colors.js L171, which only rewrites variable VALUES inside the :root and .dark blocks (replaceBlockVars, L178). So a rule added anywhere outside those two blocks flows through to all seven themes unchanged, with no per-colour edit.
Insertion point: inside the existing @layer base { ... } block at the tail of themes/index.css (opens L137, closes L152), appended after the :focus-visible rule that ends at L151.
Why @layer base rather than an unlayered rule: it is where this file already puts element-level global defaults, it is where shadcn puts the equivalent (~/Documents/Projects/frameworks/shadcn/apps/v4/app/globals.css L186 to L212, which includes [data-slot="layout"] rules in exactly this position), and it makes the rule strictly MORE overridable than today. The selector and its specificity are preserved exactly (select option, select optgroup, 0,0,2, two element selectors), so the class-based override the L82 to L85 comment promises still works. The layer additionally means a Tailwind utility on the <option> now wins, which today it does not, because today's injected <style> is unlayered and beats every layered rule regardless of specificity. That is a strict improvement in the direction the comment already wanted.
Idempotency:ensureTheme keys the whole block on THEME_MARKER (/* @webjsdev/ui theme */, theme.js L21), checked at L44. If the marker is present the function returns 'present' and writes nothing. So the rule cannot be duplicated by a re-run, by init, or by any number of add calls. No separate idempotency mechanism is needed and none should be added.
An app that already ran init does NOT get the rules automatically, and there is no command that gives them to it.add calls ensureTheme (add.js L47), but ensureTheme short-circuits on the marker, so a later add does not rewrite the block. init --overwrite does not help either: opts.overwrite reaches only writeLibUtils (init.js L148), and the ensureTheme call at L153 is passed no overwrite flag. The only routes are hand-editing the app stylesheet, or deleting the marker line and re-running init. This is a real, stated consequence of the change and it belongs in the docs and in the acceptance criteria, not papered over.
A freshly scaffolded app DOES get it.packages/cli/lib/create.js L199 to L208 copies themes/index.css verbatim to styles/globals.css, so webjs create carries the rule with no extra wiring.
What an existing app loses in the gap: the <option> colours revert to the browser default, which is the pre-fix behaviour the rule was compensating for. A degraded read, not a broken control. That asymmetry is exactly why checkbox and radio-group are NOT moved in this PR (see Out of scope).
Exact CSS to write:
/* Native <select> options paint transparent over the browser popup when no rule applies, so in dark mode Chrome's dark popup plus the inherited text colour makes every unselected option disappear. Canvas / CanvasText are the system colours the browser would have painted anyway, so this stops relying on inheritance to pull them through. Two ELEMENT selectors (specificity 0,0,2) and no wrapper requirement on purpose: a bare <select class=${nativeSelectClass()}> with no wrapper is legitimate, and any single class overrides this. */selectoption,selectoptgroup {
background-color: Canvas;
color: CanvasText;
}
nativeSelectOptionClass() and nativeSelectOptGroupClass() stay exported. They are intentionally redundant with the global rule (both emit bg-[Canvas] text-[CanvasText]), and the L87 to L92 comment says so.
The in-repo marketing site needs the same rule added by hand, because it never ran webjsui init and has no theme block: it writes its own website/public/input.css, whose @layer base at L182 already reproduces one kit rule "scoped to the preview, in the SAME layer" for exactly this reason. Its /ui/native-select preview (website/modules/ui/utils/examples.ts L337) renders real <option> elements and relies on the injection today.
C. Mirror the memoisation into the blog copy
Decision 4 is settled as YES, mirror it.test/ui/cn-copies-in-sync.test.mjs enforces behavioural identity, not textual: it strips both files, imports them, and compares cn(a, b) across a 110-token battery (L85 to L101). Its own header says a byte comparison "would be noise", and the two files already differ today (different comment wording at L245 to L251, and the blog copy lacks the whole domId / ensureId block). So the sync test does not FORCE the blog edit.
It is still required, for a reason the sync test cannot see: examples/blog/app/ui-demo/page.ts reports shipped, blocked by lib/utils/cn.ts today. The repo's own dogfood app carries the identical defect, and packages/ui/AGENTS.md names these two files as "the whole inventory of hand-synced sources", so leaving one fixed and one not is precisely the drift that inventory exists to prevent. The edit is the same three-hunk change, at L53 / L145 / L283 in the blog copy.
D. The shared rule, written down
A registry module must do NO work at module scope. Not a call, not a new, not a document reference. The reason is mechanical and worth stating in the docs: the analyser reads any of those as client work, so the module pins every page that reaches it on a component-free path, and for a Tier-1 helper (which registers no element) there is no path-aware carve-out to save it.
Decision 5, the durable regression test, is settled in the Tests section below, including how it reaches hasModuleScopeSideEffect.
Implementation plan
Cut a worktree first (git worktree add -b fix/ui-registry-module-scope-work ../webjs-ui-module-scope origin/main, then npm run worktree:link inside it). All line anchors are HEAD 79fc28fc.
Step 1. Memoise GROUPS in packages/ui/packages/registry/lib/utils.ts
After (the entries themselves are untouched, only re-indented by two spaces so the array body sits inside the function):
// Built on FIRST USE, not at module load. A module-scope `...borderGroups()`// spread is a real top-level call, so the elision analyser reads this module// as client-effecting and every page that reaches `cn` on a component-free// path ships whole instead of being elided (#1320).let_groups: Array<[RegExp,string]>|undefined;functionGROUPS(): Array<[RegExp,string]>{return(_groups??=[[/^p-/,'p'],[/^px-/,'px'], ...
1b. L145, the table closer. Today:
[/^grid-flow-/,'grid-flow'],];
After:
[/^grid-flow-/,'grid-flow'],]);}
1c. L286, the single read site inside dedupeUtilities. Today:
for(const[re,g]ofGROUPS){
After:
for(const[re,g]ofGROUPS()){
Do NOT touch anything else in this file. hintedGroup() (L229, its regex L230) and variantPrefix() (L304) belong to #1338, which is being planned in parallel against the same file. See the collision note at the end of this plan.
Step 2. Mirror steps 1a to 1c into examples/blog/lib/utils/cn.ts
Same three hunks. The blog copy's anchors are L53 (opener), L145 (closer), L283 (read site, three lines earlier than the registry copy because its L245 comment block is shorter). Everything else in that file stays as it is; the two copies are hand-synced behaviourally, not textually.
Step 3. Add the <option> rule to packages/ui/packages/registry/themes/index.css
Append inside the existing @layer base block, after the :focus-visible rule that closes at L151 and before the block's } at L152. Exact text is in the Design section above. Nothing outside :root and .dark is rewritten by mergeThemeCss, so all seven base colours inherit it with no edit to themes/base-colors.js.
Step 4. Strip the injection from packages/ui/packages/registry/components/native-select.ts
Delete L93 through L115 inclusive, that is the whole const STYLES = ... template (L93 to L99), the let installed = false; (L101), the installNativeSelectStyles() declaration (L102 to L113), and the module-scope call (L115). Today:
After: nothing. The file goes straight from the L61 to L92 comment block (rewritten, see step 5) to export const nativeSelectWrapperClass at L117. import { cn } from '../lib/utils.ts'; at L57 stays, nativeSelectClass() at L120 stays, and nativeSelectOptionClass() / nativeSelectOptGroupClass() at L132 to L133 stay.
Verify with the probe in step 10: the file must report hasModuleScopeSideEffect: false and contain no document token at all.
Step 5. Rewrite the two comment blocks in native-select.ts that describe the injection
5a. L16 to L19, in the module JSDoc. Today:
* Importing this module installs a stylesheet that forces Canvas /
* CanvasText on every <option> inside the wrapper so the dropdown reads
* in both light and dark themes regardless of OS preference; advanced
* overrides use `nativeSelectOptionClass()` / `nativeSelectOptGroupClass()`.
After (note the module no longer installs anything, and the semicolon pause is an invariant-11 violation being carried forward):
* The theme stylesheet forces Canvas / CanvasText on every <option> so the
* dropdown reads in both light and dark themes regardless of OS preference.
* It arrives with the design tokens (`npx @webjsdev/ui init`, or `add`, which
* self-heals a missing block), so it is in the first paint and works with
* JavaScript off. Advanced overrides use `nativeSelectOptionClass()` and
* `nativeSelectOptGroupClass()`.
5b. L61 to L92, the long history comment above the deleted STYLES. Its content moves with the rule: keep the two paragraphs that explain WHY the colours are needed and why the selector is unscoped (they are now the CSS comment written in step 3), and leave behind a short pointer in this file:
// The <option> / <optgroup> colour rule is NOT injected from here. It lives in// the theme stylesheet the kit installs, so it is in the first paint, works// with JavaScript off, and does not make this module client-effecting (a// module-scope call pins every page that imports it, #1320). See the// `select option, select optgroup` rule in the theme block.//// `nativeSelectOptionClass()` and `nativeSelectOptGroupClass()` stay exported// for users who want to opt into the same colours via a class helper. They// emit the same `bg-[Canvas] text-[CanvasText]` utilities: redundant when the// theme block is present, harmless, and they match the broader shadcn// convention that every part has a class helper.
Step 6. Add the same rule to website/public/input.css
The marketing site never ran webjsui init and has no theme block, and its /ui/native-select preview renders real <option> elements. Append the select option, select optgroup rule to the existing @layer base block that opens at L182, immediately after the .ui-preview rule. That block already exists to reproduce a kit rule the site does not receive, and its comment says so.
examples/blog/public/input.css needs nothing: the blog renders no <select> anywhere in app/ or components/.
Step 7. Export the predicate so a test can call it
That is the whole change to packages/server. It is a one-word addition needed because the predicate is currently module-private and the regression test in step 8 must call the real thing rather than re-implement its depth-0 scan (a re-implementation would drift the moment the analyser changes). No behaviour changes.
Note the two commit hooks this touches. require-docs-with-src.sh fires on any packages/*/src change and is satisfied by the doc surfaces in step 9 (packages/ui/AGENTS.md and .agents/skills/webjs/references/ui-kit.md both match its allowlist). require-bun-parity-with-runtime-src.sh does NOT fire: its filename filter is serialize|/json\.js|file-storage|listener|ts-strip|action|render-server|/ssr\.js|conditional-get|websocket|node-version|csrf|/auth\.js|/session\.js|/cors\.js|crypto|compression|body-limit|/dev\.js|stream, and component-elision.js matches none of them. Nothing in this change touches the serializer, the listener, the request path, or the stripper, so there is no Bun-parity obligation and no test/bun/** file is needed.
Step 8 to 10 are the Tests section below.
Tests
Step 8. The durable module-scope-purity guard (decision 5)
File: packages/ui/test/utils-purity.test.js, extended. That file already exists and already guards exactly this property class for lib/utils.ts (the #819 client-globals scan), so the new assertion belongs beside its siblings rather than in a new file. Cross-package imports from packages/ui/test/ are established: packages/ui/test/cn-helper.test.js L23 imports ../../server/src/ts-strip.js.
Scope: every .ts under packages/ui/packages/registry/lib/ and packages/ui/packages/registry/components/. Not all of packages/registry/**, because themes/*.css is not JavaScript and registry.json is data.
The assertion is a pinned-set EQUALITY, not a subset. That is what makes it durable in both directions: a new module doing module-scope work fails immediately, and removing an entry without editing the list also fails, so the list can only ever shrink deliberately.
import{hasModuleScopeSideEffect}from'../../server/src/component-elision.js';// A registry module must do NO work at module scope: not a call, not a `new`,// not a `document` reference. The elision analyser reads any of those as// client work, so the module pins every page reaching it on a component-free// path, and a Tier-1 helper registers no element, so the path-aware carve-out// (#963) cannot save it. Measured: a synthetic app went from 2 of 3 route// modules shipping whole to 0 when `lib/utils.ts` and `native-select.ts` were// cleaned (#1320).//// This list may only SHRINK. Every entry names why it is still on it.constKNOWN_FLAGGED=newSet([// ANALYSER PRECISION GAP, not module-scope work. An arrow function with an// EXPRESSION body puts its call at brace depth 0, so `export const f = ():// string => cn(...)` reads to the predicate as a top-level call. Fixing that// means either teaching the analyser about arrow bodies or wrapping pure// helpers in braces to dodge a scanner limitation. Neither is #1320's job.'components/pagination.ts',// L48, L51: `=> cn(buttonClass(...))`'components/progress.ts',// L53: `=> [ ... ].join(' ')`'components/sonner.ts',// L128: `=> makeToast(...)`'components/tabs.ts',// L71: `=> s.replace(...)`// REAL module-scope style injection, the same defect #1320 fixed in// native-select. NOT moved with it, because their injected CSS is the ONLY// source of the checkmark and the radio dot, `ensureTheme` never rewrites an// existing theme block, and an already-initialised app would therefore lose// the indicator entirely (a WCAG 1.4.1 failure, not a cosmetic one). Needs a// theme-block upgrade path first.'components/checkbox.ts',// L129: `if (typeof document !== 'undefined') installCheckboxStyles();`'components/radio-group.ts',// L112: `if (typeof document !== 'undefined') installRadioStyles();`]);test('registry modules do no module-scope work (#1320)',()=>{constflagged=[];for(constsubof['lib','components']){for(constnameofreaddirSync(join(REG,sub)).filter((n)=>n.endsWith('.ts'))){constrel=`${sub}/${name}`;if(hasModuleScopeSideEffect(readFileSync(join(REG,sub,name),'utf8')))flagged.push(rel);}}assert.deepEqual(flagged.sort(),[...KNOWN_FLAGGED].sort());});
Add two named assertions beside it so the intent survives a careless edit of the pinned set:
test('lib/utils.ts and native-select.ts are clean (#1320)',()=>{for(constrelof['lib/utils.ts','components/native-select.ts']){assert.equal(hasModuleScopeSideEffect(readFileSync(join(REG,rel),'utf8')),false,rel);}});test('native-select injects no stylesheet (#1320)',()=>{constsrc=readFileSync(join(REG,'components','native-select.ts'),'utf8');constcode=stripComments(src);// the helper already in this file, L16assert.ok(!/\bdocument\b/.test(code),'native-select.ts must not reference document');assert.ok(!/installNativeSelectStyles/.test(code),'the injector is gone');});
Counterfactual. Revert step 1 or step 4 and flagged gains lib/utils.ts or components/native-select.ts, so deepEqual fails naming the exact file. Revert step 7 and the import fails at link time. Run both reverts before opening the PR and record the failure output.
Step 9. cn output invariance, table-driven
File: packages/ui/test/cn-helper.test.js, extended. It already loads the registry utils.ts through stripTypeScript and is where every conflict-group case lives.
Add one table-driven test that pins the ORDER-dependent behaviour the memoisation could plausibly break, since the table is now built on first call rather than at load:
test('cn: directional conflicts survive the memoised table (#1320)',()=>{for(const[args,expected]of[[['px-4','py-2','p-0'],'p-0'],// shorthand subsumes both axes[['p-2','px-4'],'p-2 px-4'],// axis only refines: both survive[['p-2','px-4','p-0'],'p-0'],[['w-8','h-9','size-4'],'size-4'],// size-vs-width/height[['border-2','border-primary'],'border-2 border-primary'],// width vs colour[['border-border','border-accent'],'border-accent'],// later colour wins[['flex','flex-1'],'flex flex-1'],// display vs grow[['text-sm','text-primary'],'text-sm text-primary'],// size vs colour[['bg-clip-text','bg-primary'],'bg-clip-text bg-primary'],]){assert.equal(cn(...args),expected,JSON.stringify(args));}});test('cn: the memoised table is built once (#1320)',()=>{// Second and third calls must not rebuild: assert identity of the array the// memo returns by calling `cn` repeatedly and comparing outputs, and assert// the source shape (`??=`) that guarantees it.constsrc=readFileSync(UTILS_SRC,'utf8');assert.match(src,/_groups\?\?=\[/);assert.equal(cn('p-2','px-4'),cn('p-2','px-4'));});
The full 110-token cross-product is already covered by test/ui/cn-copies-in-sync.test.mjs, which now also proves the registry and blog copies stayed in step after step 2. Do not duplicate that battery here.
Step 10. Theme-CSS assertions (node)
File: packages/ui/test/base-colors.test.js, extended. It already reads themes/index.css (L18) and already asserts @layer base survives the merge (L88). Add:
test('the <option> colour rule ships in every base colour (#1320)',()=>{for(constnameof['neutral','stone','zinc','mauve','olive','mist','taupe']){constcss=name==='neutral' ? neutralCss : mergeThemeCss(neutralCss,OVERRIDES[name]);assert.match(css,/selectoption,\s*\n\s*selectoptgroup\{/);assert.match(css,/background-color:Canvas;/);assert.match(css,/color:CanvasText;/);}});
Plus one assertion that the rule sits INSIDE @layer base (slice the file from the @layer base { index to its closing brace and match within that slice), because placement is the half of decision 2 that a plain substring match cannot see.
Step 11. Browser layer
File: packages/ui/test/components/browser/ui-native-select.test.js, new. Sibling naming follows ui-overlay.test.js / ui-stateful.test.js / ui-a11y.test.js in that directory; the runner picks up packages/ui/test/<feature>/browser/*.test.js per web-test-runner.config.js. Run with npm run test:browser.
Two assertions:
No injection.await import('.../components/native-select.ts'), then assert(!document.getElementById('ui-native-select-styles')) and assert.equal(document.head.querySelectorAll('style').length, before). This is the regression that matters, and it fails against the pre-fix module.
The rule works, including on a bare <select> with no wrapper. Inject the theme rule text into the document (a <style> the test builds, whose text the node test in step 10 pins against themes/index.css), mount both <div class=${nativeSelectWrapperClass()}><select class=${nativeSelectClass()}><option> and a BARE <select class=${nativeSelectClass()}><option>, and assert getComputedStyle(option).backgroundColor is not rgba(0, 0, 0, 0) for either. Transparent is precisely the bug the rule exists to prevent, and the bare case is the one the old wrapper-scoped selector missed.
Do the same under an explicit dark theme (document.documentElement.setAttribute('data-theme', 'dark')) and assert the computed color and backgroundColor differ from each other, which is the legibility property.
Step 12. The elision assertion, on the REPORT
File: test/scaffolds/scaffold-ui-integration.test.js, extended. That file is already the generate-then-inspect home for the kit specifically (it calls scaffoldApp directly, and its L132 test already asserts the #819 purity property on the scaffolded lib/utils/cn.ts). Scaffold an app, run add for button card input native-select against the local registry (resolution is LOCAL-FIRST, so no network), write one page importing all four helpers, run analyzeElision from packages/server/src/component-elision.js, and assert on the report, not on byte size:
shippedRouteModules is empty, or contains no entry whose blocker ends in lib/utils/cn.ts or components/ui/native-select.ts.
The page's verdict is inert (it imports only Tier-1 helpers, which register no elements).
Byte size is deliberately not asserted: it moves with unrelated kit changes and would make this test a maintenance tax without adding signal.
Counterfactual for this layer: revert step 1 and the page's verdict becomes shipped with blockerlib/utils/cn.ts; revert step 4 and it becomes shipped with blockercomponents/ui/native-select.ts. Both were observed on a real synthetic app during planning, with the exact strings quoted in the Problem section.
Step 13. Cross-copy drift
test/ui/cn-copies-in-sync.test.mjs needs no edit. It compares behaviour, and step 2 keeps behaviour identical, so it passes unchanged and serves as the guard that step 2 was done correctly. Run it explicitly and say so in the PR.
Layers that do NOT apply, and why
Bun parity (test/bun/**). Nothing here touches a runtime-divergent surface. The only packages/*/src edit is adding the export keyword to a static source-scanning predicate (step 7), which matches none of the filename patterns require-bun-parity-with-runtime-src.sh gates on, and the registry .ts files are copied text, not framework runtime. No test/bun/** file is added and the hook will not fire. Do NOT set WEBJS_BUN_VERIFIED=1; it is unnecessary.
e2e (test/e2e/*.test.mjs). There is no JavaScript-disabled harness in the repo (no javaScriptEnabled anywhere under test/), and building one for this is scope creep. The progressive-enhancement property is discharged structurally instead: after step 4 the module contains no document token at all (asserted in step 8), so the rule CANNOT be JavaScript-dependent, and it ships in a stylesheet the app links. That is a stronger guarantee than a single JS-off page load.
Smoke (test/examples/*/smoke/*).examples/blog renders no <select>, so there is no blog-visible behaviour change beyond the elision verdict, which step 12 covers at the right layer.
Docs
Four surfaces, all required. The doc gate (.claude/hooks/require-docs-with-src.sh) fires because of step 7 and is satisfied by the first two.
packages/ui/AGENTS.md. Add ONE bullet to the "Class-helper conventions (Tier 1)" list stating the rule: a registry module must do no work at module scope (no call, no new, no document reference), because the elision analyser reads any of those as client work and the module then pins every page reaching it on a component-free path, with no path-aware carve-out for a Tier-1 helper that registers no element. Name the two shapes that caused it (a ...helper() spread inside a module-scope table, and a if (typeof document !== 'undefined') install...() stylesheet injection) and the fix for each (memoise behind a function; put the CSS in the theme block). Keep this to its own bullet, because Teach cn() the Tailwind v4 parenthesis hint spelling #1338 rewrites the existing paren-hint bullet in the same list.
Also update the native-select row of the v1 inventory table, which currently says nothing about the injected stylesheet, and the Accessibility section's form-controls bullet, which mentions the checkbox / radio data-slot stylesheets and should now say the <option> colours come from the theme block rather than from importing the module.
.agents/skills/webjs/references/ui-kit.md. Add a bullet under ## Idioms (the section starts at L67). Two facts an agent needs: a registry module does no module-scope work, so importing cn or a Tier-1 helper never pins a page; and the <option> colour rule arrives with the design tokens, so an app whose tokens are missing gets unstyled <option> colours along with everything else, fixed by re-running init or letting add self-heal. There is no scaffold copy of this reference to sync: packages/cli/templates/ contains no references/ directory and no ui-kit.md, verified at HEAD.
The copy-on-add caveat. The kit is source-copied, so fixing the registry does not fix an existing app until a re-add or a manual edit, and the theme block is written once and never rewritten, so an app that already ran init does not receive the <option> rule at all. State both in packages/ui/AGENTS.md beside the new rule, and put the second in the commit body. Do not hand-write a changelog file: framework-dev.md L176 to L185 says changelog/<pkg>/<version>.md is auto-generated by scripts/backfill-changelog.js on a version bump, and hand-editing one is forbidden. The PR title must carry a conventional prefix (fix(ui): ...) so the squash subject feeds that generator.
website/public/input.css carries a comment beside the rule added in step 6 saying it reproduces a kit rule the site does not receive, matching the wording pattern of the .ui-preview comment already at L183 to L189.
Not applicable, and why: the docs site under website/app/docs/** documents framework surfaces, not kit internals, and none of its pages mention installNativeSelectStyles or the <option> rule. README.md is unaffected (no headline capability changes). packages/cli/templates/ is unaffected beyond what themes/index.css already carries verbatim through create.js L199 to L208.
Acceptance criteria
hasModuleScopeSideEffect() returns false for packages/ui/packages/registry/lib/utils.ts
hasModuleScopeSideEffect() returns false for packages/ui/packages/registry/components/native-select.ts, and that file contains no document token
examples/blog/lib/utils/cn.ts carries the same memoisation, and test/ui/cn-copies-in-sync.test.mjs passes unchanged
cd examples/blog && node ../../packages/cli/bin/webjs.js elision no longer reports app/ui-demo/page.ts as shipped, blocked by lib/utils/cn.ts (expected new verdict: import-only, emitting components/ui/dialog.ts)
A scaffolded app that adds button card input native-select has its pages elide, asserted on the elision REPORT (no route module whose blocker is lib/utils/cn.ts or components/ui/native-select.ts), not on byte size
cn output is identical for every input: the 110-token battery in test/ui/cn-copies-in-sync.test.mjs and the directional cases (p-0 after px-4 py-2 drops both, px-4 after p-2 keeps both, width vs colour, display vs grow, shorthand vs axis, size vs colour, later-wins) all unchanged
The select option, select optgroup rule appears in all seven synthesised base-colour themes, inside @layer base, at specificity 0,0,2, with the selector NOT narrowed to a wrapper
<option> colours are correct in dark mode in a real browser, for a wrapped <select> AND for a bare <select class=${nativeSelectClass()}> with no wrapper
<option> colours are legible with JavaScript disabled, guaranteed structurally: the rule ships in the linked stylesheet and the component module references no browser API at all
Importing native-select.ts in a browser adds no <style> element and no #ui-native-select-styles node
installNativeSelectStyles is gone from the registry, and nativeSelectOptionClass() / nativeSelectOptGroupClass() are still exported
The pinned-set purity test fails, naming the reverted file, when step 1 or step 4 is reverted (counterfactual run and its output recorded on the PR)
webjs check is clean, and webjs doctor is clean for examples/blog and website (the required conventions CI job runs it over both)
packages/ui/AGENTS.md and .agents/skills/webjs/references/ui-kit.md state the no-module-scope-work rule and the copy-on-add caveat
website/public/input.css carries the <option> rule, and /ui/native-select still renders legible options in dark mode
Out of scope
Do not widen into any of these. Each was measured during planning and is recorded here so the next reader does not re-derive it. Do not file follow-up issues for them; raise them in conversation if they matter.
Do NOT "fix" hasModuleScopeSideEffect(). It is correct on both counts here. The module-scope work in lib/utils.ts and native-select.ts is real; the bug is in the kit. The analyser-precision work chore(doctor): parity-test the base-path port, fix the versions false positive, validate config at boot #1300 suggested (tracking regex literals in the redaction pass) is explicitly out of scope, and the measurement in the Problem section shows it would not have helped anyway: neither file trips the quote-desync or unbalanced-brace bail.
The arrow-expression-body false positives in components/pagination.ts, components/progress.ts, components/sonner.ts and components/tabs.ts. They ARE costing real elision, and either fix (teach the analyser about arrow bodies, or wrap four pure helpers in braces to dodge a scanner limitation) is a separate decision. They are pinned in the purity test's allowlist with their cause written down.
components/checkbox.ts and components/radio-group.ts. Same true-positive style-injection defect, same shape of fix, deliberately excluded. Their injected CSS is the ONLY source of the checkmark and the radio dot (packages/ui/AGENTS.md states neither class carries a fallback fill, so without it the checked state reads as colour alone, a WCAG 1.4.1 failure), and ensureTheme never rewrites an existing theme block, so moving that CSS would silently break the checked state in every already-initialised app. Native-select's equivalent gap only degrades <option> legibility back to the browser default. Moving these two needs a theme-block upgrade path in ensureTheme first.
Changing ensureTheme to version or upgrade an existing theme block. Real and needed for the point above, but it changes the contract of a command users run against their own edited stylesheet, and it is not required for this fix.
HINTED_GROUPS and CONFLICTS. Plain object literals with no calls. The predicate does not flag them; changing them is churn.
variantPrefix() (L304) and hintedGroup() (L229, its regex L230) in lib/utils.ts. See the collision note.
Byte-size assertions on any elision test. Assert the report.
Replacing the hand-rolled cn with clsx + tailwind-merge. Forbidden by packages/ui/AGENTS.md invariant 2.
Cross-PR collision, read before you edit
Issue #1338 is being planned in parallel and edits the SAME two files.
L53 (table opener), L145 (closer), L286 (the read site in dedupeUtilities)
variantPrefix() at L304, hintedGroup() at L229 and its regex at L230
examples/blog/lib/utils/cn.ts
L53, L145, L283
the same two functions in that copy
packages/ui/AGENTS.md
ADDS a new bullet for the no-module-scope-work rule
REWRITES the existing paren-hint gap bullet
Keep this diff strictly inside the three hunks per cn.ts copy. Do not touch variantPrefix or hintedGroup for any reason, including a drive-by comment fix. The two regions do not overlap, so the PRs merge cleanly if both stay in their lane.
Two collision points, both stated explicitly:
packages/ui/packages/registry/lib/utils.ts, different regions, no textual overlap.
examples/blog/lib/utils/cn.ts. This issue DOES edit the blog copy (decision 4, justified above by app/ui-demo/page.ts shipping today), and Teach cn() the Tailwind v4 parenthesis hint spelling #1338 definitely edits it too. Same non-overlap, same rule.
packages/ui/AGENTS.md will likely produce a trivial adjacent-bullet conflict in the "Class-helper conventions (Tier 1)" list. Keep each change to its own bullet so the resolution is mechanical.
Verified at HEAD
79fc28fc. Every line anchor below was re-checked against that commit and corrected where the original body was stale.Problem
Two
@webjsdev/uiregistry modules run work at MODULE SCOPE. The elision analyser correctly reads that as a client side effect, so any page or layout with a component-free path to them ships whole instead of being elided (#605). Becausecnis reached by essentially every kit helper, and every scaffolded app runswebjsui init, this silently costs page elision in every app using the kit.Reproduced end to end at HEAD, with the CLI
A synthetic four-file app (a page importing
buttonClass/cardClass, a second page importinginputClass/nativeSelectClass, the registrylib/utils.tscopied tolib/utils/cn.ts, the registry components copied tocomponents/ui/) reports, fromwebjs elisionrun in the app directory:The same defect is live in this repo. Run in
examples/blog:and
app/ui-demo/page.tsreportsshipped, blocked by lib/utils/cn.ts. That page imports#components/ui/dialog.ts(a Tier-2 element that ships), so after the fix its verdict becomesimport-onlyemittingcomponents/ui/dialog.ts, notinert.The command an implementer runs to see it, in any app that uses the kit:
1.
lib/utils.tsbuilds its GROUPS table with a module-scope callpackages/ui/packages/registry/lib/utils.tsL53 opens the table and L145 closes it. L103 is the offending line:hasModuleScopeSideEffect()(packages/server/src/component-elision.jsL321, not L312) flagsborderGroups(as a top-level call, which is correct: it genuinely runs on import. The human sentence in the report comes fromclientEffectReason()at L1170 (there is noelisionReason()in the file; that name in the old body is wrong).Measured by running the framework's own predicate against the unmodified file, both raw and comment-masked (the form
analyzeElisionactually feeds it):2.
native-select.tsinjects a stylesheet at module scopepackages/ui/packages/registry/components/native-select.tsL115:Predicate output for that file: no desync, depth 0, flagged token
installNativeSelectStyles(. It is both a module-scope call AND adocumentreference, so the module is doubly client-effecting, and it pins every page that renders a<select>.It is also a progressive-enhancement regression on its own terms. The rule exists to stop
<option>text going invisible in dark mode, and injecting it from JavaScript means the bug is still visible with JavaScript disabled, and for one frame before hydration with it enabled. The CSS is four lines of static, selector-only rules with no dynamic input, so it belongs in the stylesheetwebjsui initalready manages.Correcting #1300, which descoped this as an analyser false positive
#1300 has a section titled "Deliberately OUT of scope: the
ELISION_CARRIERSwarnings" that examines this exact symptom onexamples/blog/lib/utils/cn.tsand concludes:That diagnosis does not hold at HEAD, and the conclusion inverts the truth. The correction REPRODUCES: the blog copy scans identically to the registry copy (no desync, depth 0, flagged token
borderGroups(). There is no quote desync and no unbalanced-brace bail. The flag comes from the last branch of the function, the ordinary top-level call check, and it is a true positive.The check behind #1300's conclusion was for browser GLOBALS, which is only one of four OR'd conditions in
isClientEffecting(component-elision.jsL1161 to L1165).cn.tsis indeed free of browser globals, and is still not pure.What IS an analyser false positive here, and stays out of scope
Running the predicate over every registry module found six more flagged files. Four of them are a genuine analyser precision gap of a DIFFERENT shape, and this issue does not touch them:
components/pagination.tsL48, L51cn(export const paginationPreviousClass = (): string => cn(...)is an arrow with an EXPRESSION body, so its call sits at brace depth 0 and reads as a top-level statement.components/progress.tsL53join(=> [ ... ].join(' ')).components/sonner.tsL128makeToast(components/tabs.tsL71replace(components/checkbox.tsL129installCheckboxStyles(components/radio-group.tsL112installRadioStyles(Stale claims in the previous body, corrected
GROUPSopens L53 (was "L51"), closes L145 (was "L96"), spreadsborderGroups()at L103 (was "L74");borderGroups()is declared at L169 (was "L120"); the single read site is L286 (was "around L231").native-select.ts:STYLESruns L93 to L99 (was "ends around L95");let installedis L101 andinstallNativeSelectStyles()is L102 to L113 (was "L102-L114"); the module-scope call is L115 (correct).hasModuleScopeSideEffect()is at L321, not L312. There is noelisionReason()at L1145; the reporting helper isclientEffectReason()at L1170.packages/ui/packages/registry/components/popover.tsnorcomponents/hover-card.tscontains the stringselectat HEAD; those examples moved to the marketing site in seo: serve the UI gallery at webjs.dev/ui with the marketing site chrome #1099 and were rewritten. The commit that broadened the selector isdd6dd723, and its message is the surviving record of why. The RULE still stands (any consumer can legitimately write a bare<select class=${nativeSelectClass()}>), only the cited witnesses are gone.scripts/backfill-changelog.jsfrom conventional-commit subjects on a version bump, andframework-dev.mdforbids hand-writing one. The copy-on-add caveat therefore goes in the commit subject/body and in the doc surfaces, not in a hand-edited changelog file.installNativeSelectStyleshas zero callers anywhere in the repo other than its own L115 invocation (verified by a repo-wide grep excludingnode_modulesand the gitignored website mirror).Anchors verified unchanged
packages/ui/src/utils/theme.jsensureTheme(cwd, baseColor, cssPath, registryUrl)at L38. Correct.packages/ui/src/commands/init.jsL153 andpackages/ui/src/commands/add.jsL47. Correct, so an app that only ever runsadddoes reach it.GROUPSis module-private: never exported, and no other module in the repo imports it. Exactly ONE read site,for (const [re, g] of GROUPS)at L286 indedupeUtilities(L283 in the blog copy).Design / approach
Two independent fixes, one shared rule, plus one mirrored copy. Every open call from the previous body is settled below.
A.
lib/utils.ts: memoise the table behind a functionDecision 3, the memoisation shape, is settled as exactly that. Verified, not assumed:
??=survives type stripping. Fed through the framework's ownstripTypeScript()(packages/server/src/ts-strip.js, built-in on Node,amaroon Bun), the output still contains??=verbatim and the module imports and runs. Logical assignment is ES2021, so it is well under the Node 24+ / Bun floor, and it is not TypeScript syntax at all, so erasable-syntax invariant 10 is untouched.hasModuleScopeSideEffectreturnsfalse, no quote desync, final brace depth 0, no flagged token. Specifically, the function DECLARATION does not trip another branch:NOT_A_CALLcontainsfunction, and the predicate additionally skips afunction-declaration parameter list explicitly, sofunction GROUPS(is not read as a call, and the array literal inside the now-braced body sits at depth 1 and never reaches the depth-0 frame.test/ui/cn-copies-in-sync.test.mjs: 12,100 ordered pairs and 84,700 ordered triples, zero mismatches. The order-sensitive directional cases were checked by hand and all match:cn('px-4','py-2','p-0')isp-0,cn('p-2','px-4')isp-2 px-4,cn('border-2','border-primary')keeps both,cn('flex','flex-1')keeps both,cn('w-8','size-4')issize-4,cn('text-sm','text-primary')keeps both,cn('bg-clip-text','bg-primary')keeps both,cn('border-border','border-accent')isborder-accent.The NAME stays
GROUPSon purpose. The file's comments refer to "the GROUPS table above" in three places (L196, L226, and insidehintedGroup's body comment), and the read site is the only caller, so keeping the name means one changed character at the call site and no comment churn.HINTED_GROUPSandCONFLICTSare plain object literals with no calls, the predicate does not flag them, and changing them would be pure churn.Alternatives rejected. Building the table lazily inside
dedupeUtilitieswith a module-levelif (!_groups)guard: same effect, more lines, and the guard is a top-level-looking statement inside a function that a future reader may hoist. InliningborderGroups()'s 18 generated entries as literals: it deletes the documented classification comment at L147 to L168 and hard-codes a side list thathintedGroupL241 is required to keep in sync with, which is exactly the drift the helper exists to prevent. Rewritingcnto delegate toclsx+tailwind-mergethe way shadcn does: forbidden bypackages/ui/AGENTS.mdinvariant 2 (no third-party runtime deps).Prior art. shadcn keeps its copy-on-add registry modules declaration-only.
~/Documents/Projects/frameworks/shadcn/apps/v4/registry/new-york-v4/lib/utils.tsis six lines with no module-scope work at all, and across the whole ofapps/v4/registry/new-york-v4/ui/*.tsxthe singledocument.reference is inside an event handler (sidebar.tsx:86). There is no style-injecting component anywhere in that registry.B.
native-select.ts: move the CSS into the theme block, delete the injectorDecision 1, removal versus a deprecated no-op export, is settled as REMOVE. Three facts decide it, and they all point the same way:
installNativeSelectStyleshas zero callers in the repo outside its own L115 invocation. There is nothing to deprecate for.native-select.ts, which this change does not touch, so removing the registry export breaks exactly nobody's running app. A shim would only ever be read by someone who re-adds the component, and a re-adder is by definition taking the new file whole.AGENTS.mdand in the recorded history is that WebJs has no users yet and prefers a clean break to an additive shim. A no-op export in a file users are told to own and edit is dead code they inherit and then have to reason about.npx @webjsdev/ui diffshows an existing copy drifting either way, which is the intended discovery channel.Alternative rejected: keeping the injection but making it lazy from
nativeSelectClass(). That does not work, becausenativeSelectClass()is called during SSR wheredocumentis undefined, so the styles would never install.Decision 2, exactly where the
<option>CSS lands, is settled as follows.The theme block
ensureTheme()writes is not composed intheme.jsat all.theme.jsL49 fetches the registry itemtheme-<baseColor>and appendsitem.files[0].contentverbatim (L59 to L63). For neutral that content ISpackages/ui/packages/registry/themes/index.css; the other six base colours are synthesised bymergeThemeCssinpackages/ui/packages/registry/themes/base-colors.jsL171, which only rewrites variable VALUES inside the:rootand.darkblocks (replaceBlockVars, L178). So a rule added anywhere outside those two blocks flows through to all seven themes unchanged, with no per-colour edit.@layer base { ... }block at the tail ofthemes/index.css(opens L137, closes L152), appended after the:focus-visiblerule that ends at L151.@layer baserather than an unlayered rule: it is where this file already puts element-level global defaults, it is where shadcn puts the equivalent (~/Documents/Projects/frameworks/shadcn/apps/v4/app/globals.cssL186 to L212, which includes[data-slot="layout"]rules in exactly this position), and it makes the rule strictly MORE overridable than today. The selector and its specificity are preserved exactly (select option, select optgroup, 0,0,2, two element selectors), so the class-based override the L82 to L85 comment promises still works. The layer additionally means a Tailwind utility on the<option>now wins, which today it does not, because today's injected<style>is unlayered and beats every layered rule regardless of specificity. That is a strict improvement in the direction the comment already wanted.ensureThemekeys the whole block onTHEME_MARKER(/* @webjsdev/ui theme */,theme.jsL21), checked at L44. If the marker is present the function returns'present'and writes nothing. So the rule cannot be duplicated by a re-run, byinit, or by any number ofaddcalls. No separate idempotency mechanism is needed and none should be added.initdoes NOT get the rules automatically, and there is no command that gives them to it.addcallsensureTheme(add.jsL47), butensureThemeshort-circuits on the marker, so a lateradddoes not rewrite the block.init --overwritedoes not help either:opts.overwritereaches onlywriteLibUtils(init.jsL148), and theensureThemecall at L153 is passed no overwrite flag. The only routes are hand-editing the app stylesheet, or deleting the marker line and re-runninginit. This is a real, stated consequence of the change and it belongs in the docs and in the acceptance criteria, not papered over.packages/cli/lib/create.jsL199 to L208 copiesthemes/index.cssverbatim tostyles/globals.css, sowebjs createcarries the rule with no extra wiring.<option>colours revert to the browser default, which is the pre-fix behaviour the rule was compensating for. A degraded read, not a broken control. That asymmetry is exactly why checkbox and radio-group are NOT moved in this PR (see Out of scope).Exact CSS to write:
nativeSelectOptionClass()andnativeSelectOptGroupClass()stay exported. They are intentionally redundant with the global rule (both emitbg-[Canvas] text-[CanvasText]), and the L87 to L92 comment says so.The in-repo marketing site needs the same rule added by hand, because it never ran
webjsui initand has no theme block: it writes its ownwebsite/public/input.css, whose@layer baseat L182 already reproduces one kit rule "scoped to the preview, in the SAME layer" for exactly this reason. Its/ui/native-selectpreview (website/modules/ui/utils/examples.tsL337) renders real<option>elements and relies on the injection today.C. Mirror the memoisation into the blog copy
Decision 4 is settled as YES, mirror it.
test/ui/cn-copies-in-sync.test.mjsenforces behavioural identity, not textual: it strips both files, imports them, and comparescn(a, b)across a 110-token battery (L85 to L101). Its own header says a byte comparison "would be noise", and the two files already differ today (different comment wording at L245 to L251, and the blog copy lacks the wholedomId/ensureIdblock). So the sync test does not FORCE the blog edit.It is still required, for a reason the sync test cannot see:
examples/blog/app/ui-demo/page.tsreportsshipped, blocked by lib/utils/cn.tstoday. The repo's own dogfood app carries the identical defect, andpackages/ui/AGENTS.mdnames these two files as "the whole inventory of hand-synced sources", so leaving one fixed and one not is precisely the drift that inventory exists to prevent. The edit is the same three-hunk change, at L53 / L145 / L283 in the blog copy.D. The shared rule, written down
A registry module must do NO work at module scope. Not a call, not a
new, not adocumentreference. The reason is mechanical and worth stating in the docs: the analyser reads any of those as client work, so the module pins every page that reaches it on a component-free path, and for a Tier-1 helper (which registers no element) there is no path-aware carve-out to save it.Decision 5, the durable regression test, is settled in the Tests section below, including how it reaches
hasModuleScopeSideEffect.Implementation plan
Cut a worktree first (
git worktree add -b fix/ui-registry-module-scope-work ../webjs-ui-module-scope origin/main, thennpm run worktree:linkinside it). All line anchors are HEAD79fc28fc.Step 1. Memoise
GROUPSinpackages/ui/packages/registry/lib/utils.tsThree hunks, nothing else in this file.
1a. L53, the table opener. Today:
After (the entries themselves are untouched, only re-indented by two spaces so the array body sits inside the function):
1b. L145, the table closer. Today:
After:
1c. L286, the single read site inside
dedupeUtilities. Today:After:
Do NOT touch anything else in this file.
hintedGroup()(L229, its regex L230) andvariantPrefix()(L304) belong to #1338, which is being planned in parallel against the same file. See the collision note at the end of this plan.Step 2. Mirror steps 1a to 1c into
examples/blog/lib/utils/cn.tsSame three hunks. The blog copy's anchors are L53 (opener), L145 (closer), L283 (read site, three lines earlier than the registry copy because its L245 comment block is shorter). Everything else in that file stays as it is; the two copies are hand-synced behaviourally, not textually.
Step 3. Add the
<option>rule topackages/ui/packages/registry/themes/index.cssAppend inside the existing
@layer baseblock, after the:focus-visiblerule that closes at L151 and before the block's}at L152. Exact text is in the Design section above. Nothing outside:rootand.darkis rewritten bymergeThemeCss, so all seven base colours inherit it with no edit tothemes/base-colors.js.Step 4. Strip the injection from
packages/ui/packages/registry/components/native-select.tsDelete L93 through L115 inclusive, that is the whole
const STYLES = ...template (L93 to L99), thelet installed = false;(L101), theinstallNativeSelectStyles()declaration (L102 to L113), and the module-scope call (L115). Today:After: nothing. The file goes straight from the L61 to L92 comment block (rewritten, see step 5) to
export const nativeSelectWrapperClassat L117.import { cn } from '../lib/utils.ts';at L57 stays,nativeSelectClass()at L120 stays, andnativeSelectOptionClass()/nativeSelectOptGroupClass()at L132 to L133 stay.Verify with the probe in step 10: the file must report
hasModuleScopeSideEffect: falseand contain nodocumenttoken at all.Step 5. Rewrite the two comment blocks in
native-select.tsthat describe the injection5a. L16 to L19, in the module JSDoc. Today:
After (note the module no longer installs anything, and the semicolon pause is an invariant-11 violation being carried forward):
5b. L61 to L92, the long history comment above the deleted
STYLES. Its content moves with the rule: keep the two paragraphs that explain WHY the colours are needed and why the selector is unscoped (they are now the CSS comment written in step 3), and leave behind a short pointer in this file:Step 6. Add the same rule to
website/public/input.cssThe marketing site never ran
webjsui initand has no theme block, and its/ui/native-selectpreview renders real<option>elements. Append theselect option, select optgrouprule to the existing@layer baseblock that opens at L182, immediately after the.ui-previewrule. That block already exists to reproduce a kit rule the site does not receive, and its comment says so.examples/blog/public/input.cssneeds nothing: the blog renders no<select>anywhere inapp/orcomponents/.Step 7. Export the predicate so a test can call it
packages/server/src/component-elision.jsL321. Today:After:
That is the whole change to
packages/server. It is a one-word addition needed because the predicate is currently module-private and the regression test in step 8 must call the real thing rather than re-implement its depth-0 scan (a re-implementation would drift the moment the analyser changes). No behaviour changes.Note the two commit hooks this touches.
require-docs-with-src.shfires on anypackages/*/srcchange and is satisfied by the doc surfaces in step 9 (packages/ui/AGENTS.mdand.agents/skills/webjs/references/ui-kit.mdboth match its allowlist).require-bun-parity-with-runtime-src.shdoes NOT fire: its filename filter isserialize|/json\.js|file-storage|listener|ts-strip|action|render-server|/ssr\.js|conditional-get|websocket|node-version|csrf|/auth\.js|/session\.js|/cors\.js|crypto|compression|body-limit|/dev\.js|stream, andcomponent-elision.jsmatches none of them. Nothing in this change touches the serializer, the listener, the request path, or the stripper, so there is no Bun-parity obligation and notest/bun/**file is needed.Step 8 to 10 are the Tests section below.
Tests
Step 8. The durable module-scope-purity guard (decision 5)
File:
packages/ui/test/utils-purity.test.js, extended. That file already exists and already guards exactly this property class forlib/utils.ts(the #819 client-globals scan), so the new assertion belongs beside its siblings rather than in a new file. Cross-package imports frompackages/ui/test/are established:packages/ui/test/cn-helper.test.jsL23 imports../../server/src/ts-strip.js.Scope: every
.tsunderpackages/ui/packages/registry/lib/andpackages/ui/packages/registry/components/. Not all ofpackages/registry/**, becausethemes/*.cssis not JavaScript andregistry.jsonis data.The assertion is a pinned-set EQUALITY, not a subset. That is what makes it durable in both directions: a new module doing module-scope work fails immediately, and removing an entry without editing the list also fails, so the list can only ever shrink deliberately.
Add two named assertions beside it so the intent survives a careless edit of the pinned set:
Counterfactual. Revert step 1 or step 4 and
flaggedgainslib/utils.tsorcomponents/native-select.ts, sodeepEqualfails naming the exact file. Revert step 7 and the import fails at link time. Run both reverts before opening the PR and record the failure output.Step 9.
cnoutput invariance, table-drivenFile:
packages/ui/test/cn-helper.test.js, extended. It already loads the registryutils.tsthroughstripTypeScriptand is where every conflict-group case lives.Add one table-driven test that pins the ORDER-dependent behaviour the memoisation could plausibly break, since the table is now built on first call rather than at load:
The full 110-token cross-product is already covered by
test/ui/cn-copies-in-sync.test.mjs, which now also proves the registry and blog copies stayed in step after step 2. Do not duplicate that battery here.Step 10. Theme-CSS assertions (node)
File:
packages/ui/test/base-colors.test.js, extended. It already readsthemes/index.css(L18) and already asserts@layer basesurvives the merge (L88). Add:Plus one assertion that the rule sits INSIDE
@layer base(slice the file from the@layer base {index to its closing brace and match within that slice), because placement is the half of decision 2 that a plain substring match cannot see.Step 11. Browser layer
File:
packages/ui/test/components/browser/ui-native-select.test.js, new. Sibling naming followsui-overlay.test.js/ui-stateful.test.js/ui-a11y.test.jsin that directory; the runner picks uppackages/ui/test/<feature>/browser/*.test.jsperweb-test-runner.config.js. Run withnpm run test:browser.Two assertions:
await import('.../components/native-select.ts'), thenassert(!document.getElementById('ui-native-select-styles'))andassert.equal(document.head.querySelectorAll('style').length, before). This is the regression that matters, and it fails against the pre-fix module.<select>with no wrapper. Inject the theme rule text into the document (a<style>the test builds, whose text the node test in step 10 pins againstthemes/index.css), mount both<div class=${nativeSelectWrapperClass()}><select class=${nativeSelectClass()}><option>and a BARE<select class=${nativeSelectClass()}><option>, and assertgetComputedStyle(option).backgroundColoris notrgba(0, 0, 0, 0)for either. Transparent is precisely the bug the rule exists to prevent, and the bare case is the one the old wrapper-scoped selector missed.Do the same under an explicit dark theme (
document.documentElement.setAttribute('data-theme', 'dark')) and assert the computedcolorandbackgroundColordiffer from each other, which is the legibility property.Step 12. The elision assertion, on the REPORT
File:
test/scaffolds/scaffold-ui-integration.test.js, extended. That file is already the generate-then-inspect home for the kit specifically (it callsscaffoldAppdirectly, and its L132 test already asserts the#819purity property on the scaffoldedlib/utils/cn.ts). Scaffold an app, runaddforbutton card input native-selectagainst the local registry (resolution is LOCAL-FIRST, so no network), write one page importing all four helpers, runanalyzeElisionfrompackages/server/src/component-elision.js, and assert on the report, not on byte size:shippedRouteModulesis empty, or contains no entry whoseblockerends inlib/utils/cn.tsorcomponents/ui/native-select.ts.inert(it imports only Tier-1 helpers, which register no elements).Byte size is deliberately not asserted: it moves with unrelated kit changes and would make this test a maintenance tax without adding signal.
Counterfactual for this layer: revert step 1 and the page's verdict becomes
shippedwithblockerlib/utils/cn.ts; revert step 4 and it becomesshippedwithblockercomponents/ui/native-select.ts. Both were observed on a real synthetic app during planning, with the exact strings quoted in the Problem section.Step 13. Cross-copy drift
test/ui/cn-copies-in-sync.test.mjsneeds no edit. It compares behaviour, and step 2 keeps behaviour identical, so it passes unchanged and serves as the guard that step 2 was done correctly. Run it explicitly and say so in the PR.Layers that do NOT apply, and why
test/bun/**). Nothing here touches a runtime-divergent surface. The onlypackages/*/srcedit is adding theexportkeyword to a static source-scanning predicate (step 7), which matches none of the filename patternsrequire-bun-parity-with-runtime-src.shgates on, and the registry.tsfiles are copied text, not framework runtime. Notest/bun/**file is added and the hook will not fire. Do NOT setWEBJS_BUN_VERIFIED=1; it is unnecessary.test/e2e/*.test.mjs). There is no JavaScript-disabled harness in the repo (nojavaScriptEnabledanywhere undertest/), and building one for this is scope creep. The progressive-enhancement property is discharged structurally instead: after step 4 the module contains nodocumenttoken at all (asserted in step 8), so the rule CANNOT be JavaScript-dependent, and it ships in a stylesheet the app links. That is a stronger guarantee than a single JS-off page load.test/examples/*/smoke/*).examples/blogrenders no<select>, so there is no blog-visible behaviour change beyond the elision verdict, which step 12 covers at the right layer.Docs
Four surfaces, all required. The doc gate (
.claude/hooks/require-docs-with-src.sh) fires because of step 7 and is satisfied by the first two.packages/ui/AGENTS.md. Add ONE bullet to the "Class-helper conventions (Tier 1)" list stating the rule: a registry module must do no work at module scope (no call, nonew, nodocumentreference), because the elision analyser reads any of those as client work and the module then pins every page reaching it on a component-free path, with no path-aware carve-out for a Tier-1 helper that registers no element. Name the two shapes that caused it (a...helper()spread inside a module-scope table, and aif (typeof document !== 'undefined') install...()stylesheet injection) and the fix for each (memoise behind a function; put the CSS in the theme block). Keep this to its own bullet, because Teach cn() the Tailwind v4 parenthesis hint spelling #1338 rewrites the existing paren-hint bullet in the same list.Also update the
native-selectrow of the v1 inventory table, which currently says nothing about the injected stylesheet, and the Accessibility section's form-controls bullet, which mentions the checkbox / radiodata-slotstylesheets and should now say the<option>colours come from the theme block rather than from importing the module..agents/skills/webjs/references/ui-kit.md. Add a bullet under## Idioms(the section starts at L67). Two facts an agent needs: a registry module does no module-scope work, so importingcnor a Tier-1 helper never pins a page; and the<option>colour rule arrives with the design tokens, so an app whose tokens are missing gets unstyled<option>colours along with everything else, fixed by re-runninginitor lettingaddself-heal. There is no scaffold copy of this reference to sync:packages/cli/templates/contains noreferences/directory and noui-kit.md, verified at HEAD.The copy-on-add caveat. The kit is source-copied, so fixing the registry does not fix an existing app until a re-add or a manual edit, and the theme block is written once and never rewritten, so an app that already ran
initdoes not receive the<option>rule at all. State both inpackages/ui/AGENTS.mdbeside the new rule, and put the second in the commit body. Do not hand-write a changelog file:framework-dev.mdL176 to L185 sayschangelog/<pkg>/<version>.mdis auto-generated byscripts/backfill-changelog.json a version bump, and hand-editing one is forbidden. The PR title must carry a conventional prefix (fix(ui): ...) so the squash subject feeds that generator.website/public/input.csscarries a comment beside the rule added in step 6 saying it reproduces a kit rule the site does not receive, matching the wording pattern of the.ui-previewcomment already at L183 to L189.Not applicable, and why: the docs site under
website/app/docs/**documents framework surfaces, not kit internals, and none of its pages mentioninstallNativeSelectStylesor the<option>rule.README.mdis unaffected (no headline capability changes).packages/cli/templates/is unaffected beyond whatthemes/index.cssalready carries verbatim throughcreate.jsL199 to L208.Acceptance criteria
hasModuleScopeSideEffect()returnsfalseforpackages/ui/packages/registry/lib/utils.tshasModuleScopeSideEffect()returnsfalseforpackages/ui/packages/registry/components/native-select.ts, and that file contains nodocumenttokenexamples/blog/lib/utils/cn.tscarries the same memoisation, andtest/ui/cn-copies-in-sync.test.mjspasses unchangedcd examples/blog && node ../../packages/cli/bin/webjs.js elisionno longer reportsapp/ui-demo/page.tsasshipped, blocked by lib/utils/cn.ts(expected new verdict:import-only, emittingcomponents/ui/dialog.ts)button card input native-selecthas its pages elide, asserted on the elision REPORT (no route module whose blocker islib/utils/cn.tsorcomponents/ui/native-select.ts), not on byte sizecnoutput is identical for every input: the 110-token battery intest/ui/cn-copies-in-sync.test.mjsand the directional cases (p-0afterpx-4 py-2drops both,px-4afterp-2keeps both, width vs colour, display vs grow, shorthand vs axis, size vs colour, later-wins) all unchangedselect option, select optgrouprule appears in all seven synthesised base-colour themes, inside@layer base, at specificity 0,0,2, with the selector NOT narrowed to a wrapper<option>colours are correct in dark mode in a real browser, for a wrapped<select>AND for a bare<select class=${nativeSelectClass()}>with no wrapper<option>colours are legible with JavaScript disabled, guaranteed structurally: the rule ships in the linked stylesheet and the component module references no browser API at allnative-select.tsin a browser adds no<style>element and no#ui-native-select-stylesnodeinstallNativeSelectStylesis gone from the registry, andnativeSelectOptionClass()/nativeSelectOptGroupClass()are still exportedwebjs checkis clean, andwebjs doctoris clean forexamples/blogandwebsite(the requiredconventionsCI job runs it over both)packages/ui/AGENTS.mdand.agents/skills/webjs/references/ui-kit.mdstate the no-module-scope-work rule and the copy-on-add caveatwebsite/public/input.csscarries the<option>rule, and/ui/native-selectstill renders legible options in dark modeOut of scope
Do not widen into any of these. Each was measured during planning and is recorded here so the next reader does not re-derive it. Do not file follow-up issues for them; raise them in conversation if they matter.
hasModuleScopeSideEffect(). It is correct on both counts here. The module-scope work inlib/utils.tsandnative-select.tsis real; the bug is in the kit. The analyser-precision work chore(doctor): parity-test the base-path port, fix the versions false positive, validate config at boot #1300 suggested (tracking regex literals in the redaction pass) is explicitly out of scope, and the measurement in the Problem section shows it would not have helped anyway: neither file trips the quote-desync or unbalanced-brace bail.components/pagination.ts,components/progress.ts,components/sonner.tsandcomponents/tabs.ts. They ARE costing real elision, and either fix (teach the analyser about arrow bodies, or wrap four pure helpers in braces to dodge a scanner limitation) is a separate decision. They are pinned in the purity test's allowlist with their cause written down.components/checkbox.tsandcomponents/radio-group.ts. Same true-positive style-injection defect, same shape of fix, deliberately excluded. Their injected CSS is the ONLY source of the checkmark and the radio dot (packages/ui/AGENTS.mdstates neither class carries a fallback fill, so without it the checked state reads as colour alone, a WCAG 1.4.1 failure), andensureThemenever rewrites an existing theme block, so moving that CSS would silently break the checked state in every already-initialised app. Native-select's equivalent gap only degrades<option>legibility back to the browser default. Moving these two needs a theme-block upgrade path inensureThemefirst.ensureThemeto version or upgrade an existing theme block. Real and needed for the point above, but it changes the contract of a command users run against their own edited stylesheet, and it is not required for this fix.HINTED_GROUPSandCONFLICTS. Plain object literals with no calls. The predicate does not flag them; changing them is churn.variantPrefix()(L304) andhintedGroup()(L229, its regex L230) inlib/utils.ts. See the collision note.cnwithclsx+tailwind-merge. Forbidden bypackages/ui/AGENTS.mdinvariant 2.Cross-PR collision, read before you edit
Issue #1338 is being planned in parallel and edits the SAME two files.
packages/ui/packages/registry/lib/utils.tsdedupeUtilities)variantPrefix()at L304,hintedGroup()at L229 and its regex at L230examples/blog/lib/utils/cn.tspackages/ui/AGENTS.mdKeep this diff strictly inside the three hunks per
cn.tscopy. Do not touchvariantPrefixorhintedGroupfor any reason, including a drive-by comment fix. The two regions do not overlap, so the PRs merge cleanly if both stay in their lane.Two collision points, both stated explicitly:
packages/ui/packages/registry/lib/utils.ts, different regions, no textual overlap.examples/blog/lib/utils/cn.ts. This issue DOES edit the blog copy (decision 4, justified above byapp/ui-demo/page.tsshipping today), and Teach cn() the Tailwind v4 parenthesis hint spelling #1338 definitely edits it too. Same non-overlap, same rule.packages/ui/AGENTS.mdwill likely produce a trivial adjacent-bullet conflict in the "Class-helper conventions (Tier 1)" list. Keep each change to its own bullet so the resolution is mechanical.