` with no role and no name. Supply `role="dialog"` + `aria-labelledby` to the `popoverTitleClass()` heading, `aria-haspopup="dialog"` + a `toggle`-event-synced `aria-expanded` on the trigger, and prefer `popover` (auto) over `popover="manual"` so the platform still gives you light-dismiss, Escape, and focus restoration.
- `card`: use a REAL heading for `cardTitleClass()`, at the level the surrounding document wants, and never wrap a whole card in one `
`.
- `kbd`: a symbol-only key (`⌘`) needs a spoken name, but NOT via `aria-label` on the ``: that maps to `role=generic`, where a name is prohibited and ignored. Hide the glyph (`aria-hidden`) and put the spoken form in an `sr-only` sibling. For a chord, wrap the group in `role="img"` + `aria-label`, which supports a name AND makes children presentational, so it is announced once.
@@ -451,6 +452,40 @@ names mechanically:
- All `.ts` files in `components/` export named functions. No default exports.
- Use `cn()` from `'../lib/utils.ts'` to merge a helper's output with
user-supplied classes when needed: ``.
+- **A registry module does 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 that reaches it on a component-free path,
+ and a Tier-1 helper registers no element, so the path-aware carve-out (#963)
+ cannot save it. Because `cn` sits under essentially every helper, one such
+ line costs page elision in every app that runs `webjsui init`. Two shapes
+ caused it (#1320). A `...borderGroups()` spread inside a module-scope table is
+ a real top-level call, fixed by memoising the table behind a function
+ (`let _groups; function GROUPS() { return (_groups ??= [...]) }`). An
+ `if (typeof document !== 'undefined') installFooStyles()` stylesheet
+ injection is both a call and a browser-global reference, fixed by putting the
+ CSS in the theme block instead, which also puts it in the first paint and
+ makes it work with JavaScript off. `packages/ui/test/utils-purity.test.js`
+ pins the flagged set as an EQUALITY, so a new offender fails immediately and
+ the remaining entries can only be removed deliberately. Six are still on that
+ list and DO pin an importing page today: `checkbox` and `radio-group` inject a
+ stylesheet for real, and `pagination`, `progress`, `sonner` and `tabs` are an
+ analyser precision gap, since an arrow with an expression body puts its call
+ at brace depth 0 and reads as a top-level statement. The page ships either
+ way, so do not treat the second group as harmless.
+- **The kit is copy-on-add, so fixing the registry does not fix an app.** An
+ existing app holds its own copy of every component and of `lib/utils/cn.ts`,
+ and `npx @webjsdev/ui diff` is the discovery channel for the drift. The theme
+ block is worse: `ensureTheme` keys the whole block on its `@webjsdev/ui theme`
+ marker and returns early when it is present, so a later `add`, and even
+ `init --overwrite` (whose overwrite flag reaches only `writeLibUtils`), leaves
+ an existing block exactly as it was. An app that already ran `init` therefore
+ does NOT receive a rule added to the theme, and the only routes are editing
+ its stylesheet by hand or deleting the marker line and re-running `init`. A
+ freshly scaffolded app is fine, since `webjs create` copies `themes/index.css`
+ verbatim. Weigh that gap before moving CSS into the theme block: for the
+ native `` colours the app degrades to the browser default, which is a
+ worse read rather than a broken control, and that asymmetry is why the
+ checkbox and radio-group injections were left where they are.
- **A `cn()` conflict group is one CSS PROPERTY, never one class prefix.**
Utilities that merely share a prefix must land in different groups, or the
merger silently drops one of them. Two defects came from getting this wrong:
diff --git a/packages/ui/packages/registry/components/native-select.ts b/packages/ui/packages/registry/components/native-select.ts
index d909421a1..311d44d88 100644
--- a/packages/ui/packages/registry/components/native-select.ts
+++ b/packages/ui/packages/registry/components/native-select.ts
@@ -9,14 +9,16 @@
* NativeSelect wrapper → nativeSelectWrapperClass()
* NativeSelect chevron → nativeSelectIconClass()
* NativeSelect option → bare (or nativeSelectOptionClass()
- * for explicit overrides; the installed
+ * for explicit overrides, since the theme
* stylesheet sets Canvas / CanvasText on
* every automatically)
*
- * Importing this module installs a stylesheet that forces Canvas /
- * CanvasText on every inside the wrapper so the dropdown reads
- * in both light and dark themes regardless of OS preference; advanced
- * overrides use `nativeSelectOptionClass()` / `nativeSelectOptGroupClass()`.
+ * The theme stylesheet forces Canvas / CanvasText on every 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
+ * writes the block when it is missing), so it is in the first paint and works
+ * with JavaScript off. Advanced overrides use `nativeSelectOptionClass()` and
+ * `nativeSelectOptGroupClass()`.
*
* Design tokens used: --input, --background, --primary, --primary-foreground,
* --muted-foreground, --ring, --destructive.
@@ -58,61 +60,17 @@ import { cn } from '../lib/utils.ts';
export type NativeSelectSize = 'default' | 'sm';
-// Auto-apply Canvas/CanvasText to every on the page. Without
-// this, an with no explicit bg paints transparent on top of
-// the browser-popup background; in dark mode (when color-scheme: dark
-// is set on ) Chrome's popup is dark, the option's transparent
-// bg lets the popup colour through, and the inherited text colour
-// from the matches that dark popup: the option disappears,
-// only the focused/selected one stays visible because the browser
-// overlays its own highlight on it.
+// The / 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.
//
-// Original selector required the option to be inside a
-// `.group/native-select` wrapper, on the assumption every user would
-// follow the documented Usage block above. But it's easy to write a
-// bare without the wrapper
-// (legitimate when you don't need the chevron icon: the popover and
-// hover-card docs examples both do this), in which case the rule
-// never matched and the dropdown reverted to invisible-options.
-// Broadening to `select option, select optgroup` makes the fix work
-// everywhere the user has imported native-select, with no required
-// wrapper. The Canvas/CanvasText pair is a safe default: they ARE
-// the system colours the browser would have painted anyway when no
-// rule applied; we just stop relying on inheritance to pull through.
-// Selector specificity is 0,0,2 (two elements), so any user who
-// genuinely needs custom colours can override with a single
-// class anywhere in their cascade (e.g. `.my-select option { ... }`
-// at 0,1,2 wins).
-//
-// `nativeSelectOptionClass()` and `nativeSelectOptGroupClass()` stay
-// exported for users who want to opt into the same colours via the
-// class helper instead of the global rule. They emit the same
-// `bg-[Canvas] text-[CanvasText]` Tailwind utilities: redundant if
-// this stylesheet is installed, but harmless and matches the broader
-// shadcn convention of "every part has a class helper".
-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();
+// `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.
export const nativeSelectWrapperClass = (): string =>
'group/native-select relative w-fit has-[select:disabled]:opacity-50';
diff --git a/packages/ui/packages/registry/lib/utils.ts b/packages/ui/packages/registry/lib/utils.ts
index f4fa05477..2e210634e 100644
--- a/packages/ui/packages/registry/lib/utils.ts
+++ b/packages/ui/packages/registry/lib/utils.ts
@@ -50,99 +50,106 @@ function walk(value: ClassValue, out: string[]): void {
// IMPORTANT: text-size (text-sm, text-xs, text-base, text-lg, …) and
// text-color (text-primary, text-foreground, …) are DIFFERENT properties
// and must be in different groups. Same for bg-size vs bg-color etc.
-const GROUPS: Array<[RegExp, string]> = [
- [/^p-/, 'p'], [/^px-/, 'px'], [/^py-/, 'py'], [/^pt-/, 'pt'], [/^pr-/, 'pr'], [/^pb-/, 'pb'], [/^pl-/, 'pl'],
- [/^m-/, 'm'], [/^mx-/, 'mx'], [/^my-/, 'my'], [/^mt-/, 'mt'], [/^mr-/, 'mr'], [/^mb-/, 'mb'], [/^ml-/, 'ml'],
- [/^w-/, 'w'], [/^h-/, 'h'], [/^size-/, 'size'],
- // A `bg-[url(…)]` / `bg-[linear-gradient(…)]` background image is classified
- // by its FUNCTION, since it carries no type hint to classify it by. The
- // hinted forms are handled centrally, in `hintedGroup`.
- [/^bg-\[(url\(|linear-gradient|radial-gradient|conic-gradient)/, 'bg-image'],
- [/^bg-(linear|gradient|conic|radial|none)/, 'bg-image'],
- [/^bg-(no-repeat|repeat|repeat-x|repeat-y|repeat-round|repeat-space)$/, 'bg-repeat'],
- [/^bg-(fixed|local|scroll)$/, 'bg-attach'],
- [/^bg-(auto|cover|contain)$/, 'bg-size'],
- [/^bg-size-/, 'bg-size'],
- // Two entries rather than one alternation: the first covers the live v4
- // compounds (`bg-top-left`), the second the bare keywords plus the v4.1
- // deprecated reversed compounds (`bg-left-top`), which tailwind-merge still
- // carries. An unmatched `bg-*` token falls into the colour catch-all below and
- // evicts a real colour, so admitting a dead spelling is the safe direction.
- [/^bg-(top|bottom)(-(left|right))?$/, 'bg-position'],
- [/^bg-(left|right|center)(-(top|bottom))?$/, 'bg-position'],
- [/^bg-position-/, 'bg-position'],
- // Clip, origin, and blend mode are three more properties under the same
- // prefix. Each sat in `bg-color` before, so `bg-clip-text` evicted a real
- // background colour (the gradient-text idiom lost its clip silently).
- [/^bg-clip-(border|padding|content|text)$/, 'bg-clip'],
- [/^bg-origin-(border|padding|content)$/, 'bg-origin'],
- [/^bg-blend-(normal|multiply|screen|overlay|darken|lighten|color-dodge|color-burn|hard-light|soft-light|difference|exclusion|hue|saturation|color|luminosity)$/, 'bg-blend'],
- [/^bg-/, 'bg-color'],
- // text-shadow is its own property, and its size scale and its colour are two
- // properties again. All three entries precede the text- patterns below, which
- // is what keeps a `text-shadow-*` token out of `text-size` and `text-color`.
- [/^text-shadow(-(2xs|xs|sm|md|lg|none))?(\/([\d.]+|\[[^\]]*\]))?$/, 'text-shadow'],
- [/^text-shadow-(\[(inset|-|\.|\d|var\()|\(--)/, 'text-shadow'],
- [/^text-shadow-/, 'text-shadow-color'],
- // Font size: explicit list of Tailwind size scale.
- [/^text-(xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl)$/, 'text-size'],
- // Alignment, wrapping, and overflow are three more properties under the same
- // prefix. Each was previously excluded from text-color by a lookahead and then
- // matched nothing at all, so two alignments never collapsed.
- [/^text-(left|center|right|justify|start|end)$/, 'text-align'],
- [/^text-(wrap|nowrap|balance|pretty)$/, 'text-wrap'],
- [/^text-(ellipsis|clip)$/, 'text-overflow'],
- // Text color: anything else under the prefix. The specific groups above are
- // the whole carve-out, so no negative lookahead is needed here as well.
- [/^text-/, 'text-color'],
- // Border sub-properties that are neither a width nor a colour. These come
- // FIRST so the width / colour classifier below never sees them.
- [/^border-(collapse|separate)$/, 'border-collapse'],
- [/^border-spacing(-[xy])?-/, 'border-spacing'],
- [/^border-(solid|dashed|dotted|double|hidden|none)$/, 'border-style'],
- ...borderGroups(),
- [/^rounded(-[a-z]+)?$/, 'rounded'],
- [/^rounded-/, 'rounded'],
- [/^opacity-/, 'opacity'],
- [/^font-(thin|light|normal|medium|semibold|bold|black|extralight|extrabold)$/, 'font-weight'],
- // Box-shadow SIZE and box-shadow COLOUR are two properties (`box-shadow` and
- // `--tw-shadow-color`), so they need two groups. `shadow-none` is a size,
- // `shadow-inherit` / `shadow-initial` are colours, and Tailwind accepts an
- // alpha modifier on a size as well as on a colour, so `shadow-lg/25` has to
- // stay on the size side. An unhinted arbitrary value is a SIZE when it opens
- // with `inset`, a sign, a dot, or a digit (a shadow offset list) and also
- // when it is a bare `var()` or the `(--x)` variable shorthand: Tailwind
- // itself resolves an ambiguous arbitrary shadow to `box-shadow` unless the
- // value is provably a colour, and `shadow-[var(--shadow-glow)]` is the normal
- // way to write a design-token shadow. This is the one place the
- // `borderGroups()` convention inverts, because a bare `border-[var(--x)]` is
- // far more often a colour while a bare `shadow-[var(--x)]` is far more often
- // a shadow. The size entries must precede the colour catch-all, or every
- // size lands in the colour group and the bug inverts rather than being fixed.
- [/^shadow(-(2xs|xs|sm|md|lg|xl|2xl|inner|none))?(\/([\d.]+|\[[^\]]*\]))?$/, 'shadow'],
- [/^shadow-(\[(inset|-|\.|\d|var\()|\(--)/, 'shadow'],
- // A bare name the size scale does not list reads as a colour, because
- // `shadow-primary` is overwhelmingly more common than a `@theme`-extended
- // `--shadow-card`. A project that adds a custom shadow NAME is the residual
- // gap, and the docs say so rather than claiming the split is total.
- [/^shadow-/, 'shadow-color'],
- [/^z-/, 'z'],
- // A bare `flex` / `grid` is a DISPLAY value, not a member of the flex / grid
- // sub-property groups below, so it must never dedupe against them: an element
- // can be both a flex container and a flex child (`class="flex flex-1"`), and
- // collapsing the two silently drops `display:flex`. It still belongs to a
- // group of its own, alongside every other display keyword, so a repeated one
- // collapses and `cn('hidden', open && 'flex')` resolves to one display.
- [/^(inline-block|inline-flex|inline-grid|inline-table|inline|block|flex|grid|flow-root|contents|hidden|list-item|table-caption|table-cell|table-column-group|table-column|table-footer-group|table-header-group|table-row-group|table-row|table)$/, 'display'],
- // Each sub-utility below gets the group of the real CSS property it sets, so
- // none of them collapses against the display value or against each other.
- [/^flex-(row|row-reverse|col|col-reverse)$/, 'flex-direction'],
- [/^flex-(wrap|wrap-reverse|nowrap)$/, 'flex-wrap'],
- [/^flex-(\d+|auto|initial|none|\[[^\]]*\])$/, 'flex'],
- [/^grid-cols-/, 'grid-cols'],
- [/^grid-rows-/, 'grid-rows'],
- [/^grid-flow-/, 'grid-flow'],
-];
+// 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'], [/^py-/, 'py'], [/^pt-/, 'pt'], [/^pr-/, 'pr'], [/^pb-/, 'pb'], [/^pl-/, 'pl'],
+ [/^m-/, 'm'], [/^mx-/, 'mx'], [/^my-/, 'my'], [/^mt-/, 'mt'], [/^mr-/, 'mr'], [/^mb-/, 'mb'], [/^ml-/, 'ml'],
+ [/^w-/, 'w'], [/^h-/, 'h'], [/^size-/, 'size'],
+ // A `bg-[url(…)]` / `bg-[linear-gradient(…)]` background image is classified
+ // by its FUNCTION, since it carries no type hint to classify it by. The
+ // hinted forms are handled centrally, in `hintedGroup`.
+ [/^bg-\[(url\(|linear-gradient|radial-gradient|conic-gradient)/, 'bg-image'],
+ [/^bg-(linear|gradient|conic|radial|none)/, 'bg-image'],
+ [/^bg-(no-repeat|repeat|repeat-x|repeat-y|repeat-round|repeat-space)$/, 'bg-repeat'],
+ [/^bg-(fixed|local|scroll)$/, 'bg-attach'],
+ [/^bg-(auto|cover|contain)$/, 'bg-size'],
+ [/^bg-size-/, 'bg-size'],
+ // Two entries rather than one alternation: the first covers the live v4
+ // compounds (`bg-top-left`), the second the bare keywords plus the v4.1
+ // deprecated reversed compounds (`bg-left-top`), which tailwind-merge still
+ // carries. An unmatched `bg-*` token falls into the colour catch-all below and
+ // evicts a real colour, so admitting a dead spelling is the safe direction.
+ [/^bg-(top|bottom)(-(left|right))?$/, 'bg-position'],
+ [/^bg-(left|right|center)(-(top|bottom))?$/, 'bg-position'],
+ [/^bg-position-/, 'bg-position'],
+ // Clip, origin, and blend mode are three more properties under the same
+ // prefix. Each sat in `bg-color` before, so `bg-clip-text` evicted a real
+ // background colour (the gradient-text idiom lost its clip silently).
+ [/^bg-clip-(border|padding|content|text)$/, 'bg-clip'],
+ [/^bg-origin-(border|padding|content)$/, 'bg-origin'],
+ [/^bg-blend-(normal|multiply|screen|overlay|darken|lighten|color-dodge|color-burn|hard-light|soft-light|difference|exclusion|hue|saturation|color|luminosity)$/, 'bg-blend'],
+ [/^bg-/, 'bg-color'],
+ // text-shadow is its own property, and its size scale and its colour are two
+ // properties again. All three entries precede the text- patterns below, which
+ // is what keeps a `text-shadow-*` token out of `text-size` and `text-color`.
+ [/^text-shadow(-(2xs|xs|sm|md|lg|none))?(\/([\d.]+|\[[^\]]*\]))?$/, 'text-shadow'],
+ [/^text-shadow-(\[(inset|-|\.|\d|var\()|\(--)/, 'text-shadow'],
+ [/^text-shadow-/, 'text-shadow-color'],
+ // Font size: explicit list of Tailwind size scale.
+ [/^text-(xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl)$/, 'text-size'],
+ // Alignment, wrapping, and overflow are three more properties under the same
+ // prefix. Each was previously excluded from text-color by a lookahead and then
+ // matched nothing at all, so two alignments never collapsed.
+ [/^text-(left|center|right|justify|start|end)$/, 'text-align'],
+ [/^text-(wrap|nowrap|balance|pretty)$/, 'text-wrap'],
+ [/^text-(ellipsis|clip)$/, 'text-overflow'],
+ // Text color: anything else under the prefix. The specific groups above are
+ // the whole carve-out, so no negative lookahead is needed here as well.
+ [/^text-/, 'text-color'],
+ // Border sub-properties that are neither a width nor a colour. These come
+ // FIRST so the width / colour classifier below never sees them.
+ [/^border-(collapse|separate)$/, 'border-collapse'],
+ [/^border-spacing(-[xy])?-/, 'border-spacing'],
+ [/^border-(solid|dashed|dotted|double|hidden|none)$/, 'border-style'],
+ ...borderGroups(),
+ [/^rounded(-[a-z]+)?$/, 'rounded'],
+ [/^rounded-/, 'rounded'],
+ [/^opacity-/, 'opacity'],
+ [/^font-(thin|light|normal|medium|semibold|bold|black|extralight|extrabold)$/, 'font-weight'],
+ // Box-shadow SIZE and box-shadow COLOUR are two properties (`box-shadow` and
+ // `--tw-shadow-color`), so they need two groups. `shadow-none` is a size,
+ // `shadow-inherit` / `shadow-initial` are colours, and Tailwind accepts an
+ // alpha modifier on a size as well as on a colour, so `shadow-lg/25` has to
+ // stay on the size side. An unhinted arbitrary value is a SIZE when it opens
+ // with `inset`, a sign, a dot, or a digit (a shadow offset list) and also
+ // when it is a bare `var()` or the `(--x)` variable shorthand: Tailwind
+ // itself resolves an ambiguous arbitrary shadow to `box-shadow` unless the
+ // value is provably a colour, and `shadow-[var(--shadow-glow)]` is the normal
+ // way to write a design-token shadow. This is the one place the
+ // `borderGroups()` convention inverts, because a bare `border-[var(--x)]` is
+ // far more often a colour while a bare `shadow-[var(--x)]` is far more often
+ // a shadow. The size entries must precede the colour catch-all, or every
+ // size lands in the colour group and the bug inverts rather than being fixed.
+ [/^shadow(-(2xs|xs|sm|md|lg|xl|2xl|inner|none))?(\/([\d.]+|\[[^\]]*\]))?$/, 'shadow'],
+ [/^shadow-(\[(inset|-|\.|\d|var\()|\(--)/, 'shadow'],
+ // A bare name the size scale does not list reads as a colour, because
+ // `shadow-primary` is overwhelmingly more common than a `@theme`-extended
+ // `--shadow-card`. A project that adds a custom shadow NAME is the residual
+ // gap, and the docs say so rather than claiming the split is total.
+ [/^shadow-/, 'shadow-color'],
+ [/^z-/, 'z'],
+ // A bare `flex` / `grid` is a DISPLAY value, not a member of the flex / grid
+ // sub-property groups below, so it must never dedupe against them: an element
+ // can be both a flex container and a flex child (`class="flex flex-1"`), and
+ // collapsing the two silently drops `display:flex`. It still belongs to a
+ // group of its own, alongside every other display keyword, so a repeated one
+ // collapses and `cn('hidden', open && 'flex')` resolves to one display.
+ [/^(inline-block|inline-flex|inline-grid|inline-table|inline|block|flex|grid|flow-root|contents|hidden|list-item|table-caption|table-cell|table-column-group|table-column|table-footer-group|table-header-group|table-row-group|table-row|table)$/, 'display'],
+ // Each sub-utility below gets the group of the real CSS property it sets, so
+ // none of them collapses against the display value or against each other.
+ [/^flex-(row|row-reverse|col|col-reverse)$/, 'flex-direction'],
+ [/^flex-(wrap|wrap-reverse|nowrap)$/, 'flex-wrap'],
+ [/^flex-(\d+|auto|initial|none|\[[^\]]*\])$/, 'flex'],
+ [/^grid-cols-/, 'grid-cols'],
+ [/^grid-rows-/, 'grid-rows'],
+ [/^grid-flow-/, 'grid-flow'],
+ ]);
+}
/**
* Border WIDTH and border COLOUR share the `border-` prefix but are different
@@ -283,7 +290,7 @@ function dedupeUtilities(input: string): string {
const hinted = hintedGroup(bare);
let gk: string | null = hinted ?? null;
if (hinted === undefined || hinted === null) {
- for (const [re, g] of GROUPS) {
+ for (const [re, g] of GROUPS()) {
if (re.test(bare)) { gk = g; break; }
}
}
diff --git a/packages/ui/packages/registry/themes/index.css b/packages/ui/packages/registry/themes/index.css
index e368f6f48..36e852519 100644
--- a/packages/ui/packages/registry/themes/index.css
+++ b/packages/ui/packages/registry/themes/index.css
@@ -149,4 +149,17 @@
outline: 2px solid color-mix(in oklab, var(--color-ring) 50%, transparent);
outline-offset: 2px;
}
+ /* Native 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
+ with no wrapper is legitimate, and
+ any single class overrides this. */
+ select option,
+ select optgroup {
+ background-color: Canvas;
+ color: CanvasText;
+ }
}
diff --git a/packages/ui/test/base-colors.test.js b/packages/ui/test/base-colors.test.js
index 4270e714b..348076572 100644
--- a/packages/ui/test/base-colors.test.js
+++ b/packages/ui/test/base-colors.test.js
@@ -87,3 +87,32 @@ test('mergeThemeCss preserves @theme block, custom variants, keyframes, @layer',
assert.match(merged, /@keyframes accordion-down/);
assert.match(merged, /@layer base/);
});
+
+// The colour rule moved out of `native-select.ts`'s module-scope style
+// injection and into the theme block (#1320), so it must survive the per-colour
+// synthesis. `mergeThemeCss` only rewrites variable VALUES inside `:root` and
+// `.dark`, so a rule in `@layer base` should flow through untouched, and that
+// is exactly the property worth pinning.
+test('the colour rule ships in every base colour, inside @layer base (#1320)', { skip }, async () => {
+ const { mergeThemeCss, BASE_COLORS, BASE_OVERRIDES } = await import(BASE_COLORS_PATH);
+ const neutral = readFileSync(NEUTRAL_CSS_PATH, 'utf8');
+ for (const name of BASE_COLORS) {
+ const css = name === 'neutral' ? neutral : mergeThemeCss(neutral, BASE_OVERRIDES[name]);
+ const open = css.indexOf('@layer base {');
+ assert.ok(open !== -1, `${name}: no @layer base block`);
+ // Slice to the block's closing brace so placement is asserted, not just
+ // presence: an unlayered copy of the rule would beat every layered one and
+ // silently take the override path away from a Tailwind utility.
+ let depth = 0;
+ let end = -1;
+ for (let i = css.indexOf('{', open); i < css.length; i++) {
+ if (css[i] === '{') depth++;
+ else if (css[i] === '}' && --depth === 0) { end = i; break; }
+ }
+ assert.ok(end !== -1, `${name}: unbalanced @layer base block`);
+ const layer = css.slice(open, end);
+ assert.match(layer, /select option,\s*\n\s*select optgroup \{/, name);
+ assert.match(layer, /background-color: Canvas;/, name);
+ assert.match(layer, /color: CanvasText;/, name);
+ }
+});
diff --git a/packages/ui/test/cn-helper.test.js b/packages/ui/test/cn-helper.test.js
index 2f81b7aab..9c506c735 100644
--- a/packages/ui/test/cn-helper.test.js
+++ b/packages/ui/test/cn-helper.test.js
@@ -423,3 +423,31 @@ test('Base and defineElement are no longer exported (removed in #819)', async ()
assert.equal(utils.Base, undefined, 'Base was removed');
assert.equal(utils.defineElement, undefined, 'defineElement was removed');
});
+
+// The conflict table is built on FIRST CALL rather than at module load (#1320),
+// so the order-dependent behaviour is worth pinning explicitly: a memo that
+// rebuilt per call, or one that captured a half-built table, would show up here
+// as a directional case flipping.
+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'], // an 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)', () => {
+ // `??=` is what makes the table build once, and it is also what keeps the
+ // module free of module-scope work, so pin the shape as well as the result.
+ assert.match(readFileSync(UTILS_SRC, 'utf8'), /_groups \?\?= \[/);
+ assert.equal(cn('p-2', 'px-4'), cn('p-2', 'px-4'));
+ assert.equal(cn('border-border', 'border-accent'), cn('border-border', 'border-accent'));
+});
diff --git a/packages/ui/test/components/browser/ui-native-select.test.js b/packages/ui/test/components/browser/ui-native-select.test.js
new file mode 100644
index 000000000..ef4d049dc
--- /dev/null
+++ b/packages/ui/test/components/browser/ui-native-select.test.js
@@ -0,0 +1,99 @@
+/**
+ * Browser tests for the native-select colour rule (#1320).
+ *
+ * The rule used to be injected from `native-select.ts` at module scope, which
+ * made the module client-effecting and pinned every page that imported it. It
+ * now lives in the theme stylesheet the kit installs, so two things need
+ * proving in a real browser: importing the module injects nothing, and the CSS
+ * that replaced the injection actually paints the options.
+ *
+ * The rule text here is pinned against `themes/index.css` by the node test in
+ * `packages/ui/test/base-colors.test.js`, so a drift between the two shows up
+ * there rather than silently making this test assert an obsolete rule.
+ */
+import { html } from '../../../../core/src/html.js';
+import { render } from '../../../../core/src/render-client.js';
+
+import { assert } from '../../../../../test/browser-assert.js';
+
+const COMPONENTS_DIR = '/packages/ui/packages/registry/components';
+
+const tick = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
+
+async function mount(tpl) {
+ const root = document.createElement('div');
+ document.body.appendChild(root);
+ render(tpl, root);
+ await tick();
+ return root;
+}
+
+suite('ui native-select', () => {
+ let mod;
+ let styleCountBeforeImport;
+ let themeStyle;
+
+ suiteSetup(async () => {
+ styleCountBeforeImport = document.head.querySelectorAll('style').length;
+ mod = await import(`${COMPONENTS_DIR}/native-select.ts`);
+ await tick();
+ });
+
+ suiteTeardown(() => {
+ themeStyle?.remove();
+ document.documentElement.removeAttribute('data-theme');
+ document.documentElement.style.colorScheme = '';
+ });
+
+ test('importing the module injects no stylesheet', () => {
+ // The regression that matters: this fails against the pre-#1320 module,
+ // which appended a