Skip to content
9 changes: 8 additions & 1 deletion .agents/skills/webjs/references/styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,13 @@ The default stack is a static compiled Tailwind stylesheet (`css:build` compiles
--border: light-dark(#e2e5e9, #3d434b);
--ring: light-dark(#9aa1a9, #6c737b);
--destructive: light-dark(#b3261e, #f2b8b5);
/* every FILL token needs its foreground pair, and the pair flips per
theme: a dark fill takes light text, a light one takes dark text */
--primary-foreground: light-dark(#ffffff, #191c20);
--destructive-foreground: light-dark(#ffffff, #ffffff);
/* a red used BOTH as a fill and as error text needs two values: text on
a dark card must be light, a fill under white text must be dark */
--destructive-fill: light-dark(#b3261e, #c0202a);
/* a derived token tracks BOTH themes for free via var(--primary) */
--primary-tint: color-mix(in srgb, var(--primary) 22%, transparent);
}
Expand All @@ -141,7 +148,7 @@ The default stack is a static compiled Tailwind stylesheet (`css:build` compiles

**Edge cases.** `light-dark()` is COLOUR-only. A colour needed in just one theme sets the unused side to a no-op (`light-dark(#fff, transparent)`). A derived token that references a `light-dark()` one (like `--primary-tint` above) tracks both themes automatically. A NON-colour token that must differ per theme (a shadow's geometry, a gradient, a size, an image) cannot use `light-dark()`; give it a `:root[data-theme='dark']` override plus an `@media (prefers-color-scheme: dark) { :root:not([data-theme]) { ... } }` rule for the OS default.

**The ui class helpers build on these tokens.** `buttonClass()` / `cardClass()` / `inputClass()` / `badgeClass()` emit Tailwind utilities that reference the same tokens (`bg-primary`, `border-border`), so theming the tokens re-skins every helper at once.
**The ui class helpers build on these tokens.** `buttonClass()` / `cardClass()` / `inputClass()` / `badgeClass()` emit Tailwind utilities that reference the same tokens (`bg-primary`, `border-border`), so theming the tokens re-skins every helper at once. A filled variant pairs `bg-<token>` with `text-<token>-foreground`, so **define the `-foreground` half of every fill token you override**, or that variant's text falls back to whatever it inherits. Check the pair in BOTH themes: the palette above uses a deep red in light and a light red in dark, so a single near-white foreground would read at 6.5:1 in light and 1.7:1 in dark. That is why the two halves flip together rather than the fill carrying a fixed white.

**Focus rings.** The design system applies ONE themed, keyboard-only focus ring globally in the theme CSS: `@layer base { * { @apply border-border outline-ring/50 } }` themes the outline COLOUR to `--ring/50`, and a `:focus-visible { outline: 2px solid color-mix(in oklab, var(--color-ring) 50%, transparent); outline-offset: 2px }` forces a SOLID outline. That second rule matters: `outline-ring/50` alone leaves `outline-style: auto`, so the browser draws its OWN focus ring (which can look thick and white and ignore the colour). So every focusable element (button, link, input) shares one `--ring`-coloured ring with no per-element styling (a native `<button>` renders it a touch wider than a link, a Chromium form-control quirk, but the colour is the same). Do NOT re-add a focus style on a light-DOM element (`buttonClass` deliberately carries none), and NEVER remove it (`outline: none` with no replacement fails WCAG 2.4.7). `:focus-visible` already limits the ring to keyboard / programmatic focus, not a mouse click. A SHADOW-DOM component is the ONE exception: a document rule cannot cross the shadow boundary, so it styles its own focus in `static styles`, matching the global ring EXACTLY (`--ring` at 50%, the same as `outline-ring/50`): `button:focus-visible { outline: 2px solid color-mix(in oklab, var(--color-ring) 50%, transparent); outline-offset: 2px }`. Without it, its controls fall back to the raw browser outline (thick, light on a dark theme, and shown on window-refocus).

Expand Down
12 changes: 9 additions & 3 deletions packages/cli/templates/partials/agents-playbook-fullstack.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,15 @@ Full reference: `.agents/skills/webjs/references/styling.md`.
written ONCE with the native CSS `light-dark(LIGHT, DARK)` function, so light
and dark modes come from one declaration.
- Define at least: `--background`, `--foreground`, `--card`, `--primary`,
`--secondary`, `--muted`, `--muted-foreground`, `--accent`, `--border`,
`--ring`, `--destructive`. Add the matching `*-foreground` pair for each
surface token you use, following the styling guide's reference palette.
`--primary-foreground`, `--secondary`, `--muted`, `--muted-foreground`,
`--accent`, `--border`, `--ring`, `--destructive`, `--destructive-fill`,
`--destructive-foreground`. A filled ui variant pairs `bg-<token>` with
`text-<token>-foreground`, so every fill token needs its `*-foreground`
half or that variant's text has no colour to resolve to. Check the pair in
both themes. A colour used BOTH as a fill and as text on a surface needs
two values rather than one, which is why the destructive red has a
separate `--destructive-fill`: error text on a dark card wants a light
red, and a fill under white text wants a dark one.
- Consume colors ONLY as token utilities: `bg-background`, `text-foreground`,
`bg-card`, `border-border`, `text-primary`, `text-muted-foreground`,
`bg-destructive`.
Expand Down
16 changes: 16 additions & 0 deletions packages/ui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,22 @@ when the caller passes an explicit custom `--registry <url>`.
- `onValueChange={fn}` → `addEventListener('ui-value-change', fn)`
- `asChild` → drop the wrapper, apply the class helper directly

**Parity covers the API, not the pixels.** A shadcn class string is
reference, not specification, so a variant here may render differently
where this kit has a reason. Treating the class strings as authoritative
is the mistake this note exists to prevent. The deliberate divergences so
far:

- The accordion trigger tints on hover instead of underlining (#1137).
- The destructive fill paints a `--destructive-fill` token at full
opacity instead of `text-white` plus `dark:bg-destructive/60` (#1138).
shadcn's fade exists because one token cannot be both a fill under
white text and legible red text on a dark card. A second token answers
that more directly than compositing does, and it keeps the dark fill
saturated rather than dusty.

Record any new one here, and keep the reason at the call site.

6. **Native form controls participate in `<form>` submission natively.**
`<input type="checkbox" class=${checkboxClass()}>` is a real input ,
no `ElementInternals`, no `setFormValue` proxying. Submission,
Expand Down
10 changes: 7 additions & 3 deletions packages/ui/packages/registry/components/badge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@
* <span> (not focusable, no tabindex). Only an interactive badge (an <a>
* or <button>) is focusable, and an icon-only one needs an aria-label.
*
* Design tokens used: --primary, --secondary, --destructive, --foreground,
* --accent, --border, --ring.
* Design tokens used: --primary, --secondary, --destructive,
* --destructive-fill, --destructive-foreground, --foreground, --accent,
* --border, --ring.
*
* @example
* ```html
Expand All @@ -31,8 +32,11 @@ const BASE =
const VARIANTS = {
default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary: 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
// bg-destructive-fill, for the reason documented on the button's destructive
// variant. Kept in step deliberately: a destructive Badge sitting next to a
// destructive Button in one view has to be the same red.
destructive:
'bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90',
'bg-destructive-fill text-destructive-foreground focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive-fill/90',
outline:
'border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
ghost: '[a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
Expand Down
28 changes: 25 additions & 3 deletions packages/ui/packages/registry/components/button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@
* Native <button> focus and keyboard activation are already correct.
*
* Design tokens used: --primary, --primary-foreground, --destructive,
* --secondary, --secondary-foreground, --accent, --accent-foreground,
* --background, --input, --ring.
* --destructive-fill, --destructive-foreground, --secondary,
* --secondary-foreground, --accent, --accent-foreground, --background,
* --input, --ring.
*
* @example
* ```html
Expand All @@ -44,8 +45,29 @@ const BASE =

const VARIANTS = {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
// bg-destructive-FILL, not bg-destructive, and that is the whole point.
//
// --destructive has to be a LIGHT red in dark mode, because the same token
// paints text-destructive on a near-black card (the alert variant,
// errorClass, a destructive menu item) and has to be legible there. But a
// fill under white text needs to be DARK. Those pull opposite ways and the
// window between them is empty, so one token cannot do both jobs.
//
// shadcn resolves it with dark:bg-destructive/60, fading the light red over
// near-black until white text clears AA. That works, but compositing toward
// a neutral background desaturates, which is why the dark destructive button
// reads dusty. Attenuation is also this kit's DISABLED vocabulary
// (disabled:opacity-50), so the one control that should look most certain
// ends up looking least.
//
// Giving the fill its own token lets dark run a genuinely saturated red
// under white text, and leaves --destructive free to stay light for its text
// role. In light mode the two are the same value; only dark needs the split.
//
// packages/ui/test/destructive-contrast.test.js measures both roles and
// fails if a token edit breaks either.
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40',
'bg-destructive-fill text-destructive-foreground hover:bg-destructive-fill/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
Expand Down
11 changes: 7 additions & 4 deletions packages/ui/packages/registry/themes/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-fill: var(--destructive-fill);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
Expand Down Expand Up @@ -79,8 +80,9 @@
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.97 0.01 17);
--destructive: oklch(0.52 0.20 25);
--destructive-fill: oklch(0.52 0.20 25);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
Expand Down Expand Up @@ -114,8 +116,9 @@
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.371 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.58 0.22 27);
--destructive: oklch(0.70 0.17 25);
--destructive-fill: oklch(0.58 0.20 25);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
Expand Down
Loading
Loading