Skip to content

dogfood: ui kit cn.ts and native-select.ts do module-scope work, pinning every page #1320

Description

@vivek7405

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:

const GROUPS: Array<[RegExp, string]> = [   // L53
  ...
  ...borderGroups(),                        // L103: a real call, at module load
  ...
];                                          // L145

hasModuleScopeSideEffect() (packages/server/src/component-elision.js L321, 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

packages/ui/packages/registry/components/native-select.ts L115:

if (typeof document !== 'undefined') installNativeSelectStyles();

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.js ensureTheme(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

let _groups: Array<[RegExp, string]> | undefined;
function GROUPS(): Array<[RegExp, string]> {
  return (_groups ??= [ ...the same literal, unchanged... ]);
}

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:

  1. installNativeSelectStyles has zero callers in the repo outside its own L115 invocation. There is nothing to deprecate for.
  2. 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.
  3. 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. */
  select option,
  select optgroup {
    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

Three hunks, nothing else in this file.

1a. L53, the table opener. Today:

const GROUPS: Array<[RegExp, string]> = [
  [/^p-/, 'p'], [/^px-/, 'px'], ...

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;
function GROUPS(): 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] of GROUPS) {

After:

      for (const [re, g] of GROUPS()) {

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:

const STYLES = `
select option,
select optgroup {
  background-color: Canvas;
  color: CanvasText;
}
`;

let installed = false;
export function installNativeSelectStyles(): void {
  if (installed || typeof document === 'undefined') return;
  if (document.getElementById('ui-native-select-styles')) {
    installed = true;
    return;
  }
  const style = document.createElement('style');
  style.id = 'ui-native-select-styles';
  style.textContent = STYLES;
  document.head.appendChild(style);
  installed = true;
}

if (typeof document !== 'undefined') installNativeSelectStyles();

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

packages/server/src/component-elision.js L321. Today:

function hasModuleScopeSideEffect(src, literals) {

After:

export function hasModuleScopeSideEffect(src, literals) {

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.
const KNOWN_FLAGGED = new Set([
  // 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)', () => {
  const flagged = [];
  for (const sub of ['lib', 'components']) {
    for (const name of readdirSync(join(REG, sub)).filter((n) => n.endsWith('.ts'))) {
      const rel = `${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 (const rel of ['lib/utils.ts', 'components/native-select.ts']) {
    assert.equal(hasModuleScopeSideEffect(readFileSync(join(REG, rel), 'utf8')), false, rel);
  }
});

test('native-select injects no stylesheet (#1320)', () => {
  const src = readFileSync(join(REG, 'components', 'native-select.ts'), 'utf8');
  const code = stripComments(src);   // the helper already in this file, L16
  assert.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.
  const src = 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 (const name of ['neutral', 'stone', 'zinc', 'mauve', 'olive', 'mist', 'taupe']) {
    const css = name === 'neutral' ? neutralCss : mergeThemeCss(neutralCss, OVERRIDES[name]);
    assert.match(css, /select option,\s*\n\s*select optgroup \{/);
    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:

  1. 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.
  2. 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 blocker lib/utils/cn.ts; revert step 4 and it becomes shipped with blocker components/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.

  1. 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.

  2. .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.

  3. 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.

  4. 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.

File This issue touches #1338 touches
packages/ui/packages/registry/lib/utils.ts 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:

  1. packages/ui/packages/registry/lib/utils.ts, different regions, no textual overlap.
  2. 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.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions