ReviewOS

stacks/stx

56 of 102 components hard-code Tailwind palette colours and 59 use dark: variants, so the library cannot be adopted by a token-themed app

#1993
Closed glennmichael123 opened this 9 days ago ·
9 days ago

packages/components — measured against 0.2.321.

This is the single thing blocking us from adopting the library, and it is bigger than the two bugs I filed in #1973 and #1974. Opening it separately because it is a design decision rather than a defect, and the fix is a direction rather than a patch.

The numbers

components total                            102
hard-code a Tailwind palette colour          56
use dark: variants rather than token swaps   59

Button is the clearest case:

const variantClasses = {
  primary:   'bg-blue-500 hover:bg-blue-600 text-white ... dark:bg-blue-600 dark:hover:bg-blue-700',
  danger:    'bg-red-500 hover:bg-red-600 text-white ... dark:bg-red-600',
  secondary: 'bg-surface-sunken hover:bg-neutral-300 text-neutral-900 dark:text-white',
}

Why that is unusable for us

Our six apps theme through crosswind semantic tokens backed by CSS custom properties — --accent, --panel, --border, --text. Those generate real utilities (bg-accent, text-accent-ink, bg-panel, border-line, text-subtle), and dark mode works by swapping the custom properties under [data-theme] / prefers-color-scheme, so there is no dark: on anything. That was a deliberate migration away from a 13-line :root block copy-pasted into nine views.

Concretely, in bughq:

--accent: #e11d48   (rose)        <Button variant="primary"> ships bg-blue-500
our buttons:  rounded 999px       <Button> ships rounded-md
our buttons:  px-4 py-1.5         <Button size="md"> ships a fixed h-10
our dark mode: token swap         <Button> ships dark: variants

Adopting <Button> across our 294 call sites would turn a rose-accented product Tailwind blue, square off every pill, and reintroduce the dark: variants the design system exists to avoid.

className is not an escape hatch

buttonClasses appends className last in the template string:

`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${widthClasses} ${className}`

but class attribute order does not decide CSS. bg-blue-500 and bg-accent are both single-class selectors of equal specificity, so the winner is whichever comes later in the generated stylesheet — not something a call site can control. Even where it happens to win, the caller is passing a className whose entire job is to cancel the variant, at which point the component contributes nothing but a wrapper.

What would make this adoptable

Any of these works for us; the first is cleanest:

  1. Drive variants from CSS custom properties with sensible fallbacks, e.g. background: var(--stx-button-primary-bg, theme(colors.blue.500)). Apps that define nothing keep today's look exactly; apps with a token system point the variables at their own and everything follows, dark mode included, with no dark: needed.
  2. A theme/preset layer — one place to remap the palette per app, rather than per component per call site.
  3. Unstyled/headless variants of the common primitives, so the behaviour can be adopted without the paint.

The 46 that are already fine

Worth saying this is not a wholesale complaint — 46 components carry no hard-coded palette colour and we can adopt them today. Usefully, that includes the whole Table family, Popover, Dropdown, Portal, Teleport, Transition, VirtualList, Sidebar* and the Stepper/TabBar families.

So the split is almost exactly structural-vs-painted: the components that only provide behaviour are adoptable, and the ones that paint are not. That is also why this is worth fixing centrally rather than app-side — the painted half is where most of the 294 buttons, 54 selects and 11 dialogs would have gone.

Happy to prototype the custom-property approach on Button as a PR if that direction is agreeable.

9 days ago

Additional data point, and a correction to my own framing above.

I said 46 components carry no hard-coded palette colour and are therefore adoptable today, and named the Table family as an example. That was measured too narrowly — I only screened for Tailwind palette colours like blue-500. Trying to actually adopt the family showed the same wall, reached by a different route: imposed layout and structure, not just colour.

Table      min-w-full divide-y divide-line
TableBody  bg-white dark:bg-neutral-900
TableCell  px-6 py-4 whitespace-nowrap text-sm text-fg
TableHead  bg-surface

Against a real table in one of our apps:

<table class="w-full text-sm">
  <tr class="text-text-3 text-xs">
    <th class="pb-2 pl-1 font-medium text-left">Device</th>

Adopting the family would triple the cell padding, add row dividers the design does not have, and paint the body white on a dark-capable panel. Each of those then has to be cancelled through className, which runs into the specificity problem in the original post.

Two things worth separating out of this:

  1. TableBody's bg-white dark:bg-neutral-900 is the colour problem again, so it belongs to this issue directly.
  2. TableCell's px-6 py-4 and Table's divide-y are a different axis — a component can be colour-neutral and still un-adoptable because it decides spacing and borders. If the custom-property direction goes ahead, spacing is worth including (--stx-table-cell-padding), and divide-y is arguably better left off entirely so it is opt-in.

Also worth noting the token names themselves do not resolve outside the library: bg-surface, text-fg, text-fg-soft, divide-line, bg-surface-sunken are not defined in any of our six apps. So on the colour-neutral components the practical result today is partially unstyled markup — divide-y applies a border width while divide-line supplies no colour, for instance. Shipping the tokens' definitions with the package, or documenting the contract an app has to satisfy, would help independently of the theming direction.

Net effect for us: the structural-versus-painted split I described is real but the line sits further over than I said. Genuinely drop-in today are the behaviour-only components — Portal, Teleport, Transition, VirtualList, Popover/PopoverButton — and not much else.

9 days ago

Correcting myself on the central claim in the opening post. I asserted that className is not a usable escape hatch because class attribute order does not decide CSS. I have now actually tested it instead of reasoning about it, and for our setup that claim is wrong.

I built a probe page in one of our apps:

<Button variant="primary" className="bg-accent text-accent-ink rounded-full px-4 py-1.5">Save</Button>

The component resolves and renders correctly, and every override wins. Rule offsets in the emitted stylesheet:

.bg-blue-500      @7922        .bg-accent        @8969   -> bg-accent wins
.text-white       @8061        .text-accent-ink  @9017   -> text-accent-ink wins
.rounded-md       @6956        .rounded-full     @9064   -> rounded-full wins
.py-2             @9295        .py-1\.5          @9350   -> py-1.5 wins

And the dark: variants turn out to be inert for us rather than hostile. They compile to .dark .dark\:bg-blue-600, which is specificity (0,2,0) and would beat a single-class override — but our apps switch themes with [data-theme] on :root and never put class="dark" on anything, so that selector never matches. Meanwhile bg-accent resolves to var(--accent), which swaps with the theme on its own. So the token override works in both themes.

What this means for the issue. The blocker I described is real but smaller than I said. It is friction, not impossibility:

  • every call site has to carry a className that re-states the design, which is most of what the variant was for
  • size still imposes a fixed h-10 that has to be cancelled separately
  • it works because of the emission order of the generated utilities, which is not something the component contract promises — so it could change under us on a crosswind or build change, silently, across hundreds of call sites

So I would restate the ask: not "the library is unusable", but "variants should be overridable by contract rather than by luck of stylesheet order". The custom-property direction in the opening post still does that, and would remove the need for a compensating className at every call site.

Apologies for the overstatement — the measured position is that we can adopt Button today, and would still much rather adopt it against tokens.

9 days ago

Direction agreed: option 2, role tokens, extended to cover the painted half. Landed in 89b49cf, with the remainder still open.

What exists now

Role tokens already covered neutrals and status text. A painted component needs four more things per status, and all five statuses now have them:

TokenWhat it isLightDark
<status>-solida filled button500600
<status>-solid-hoverthat fill, hovered600700
<status>-inkthe text on a fillwhite¹white¹
<status>-softa tinted chip100900
<status>-soft-inkthe text on a tint800200

¹ except warning-ink, which is dark: white on yellow-500 is about 1.9:1.

So your Button example is now:

primary: 'bg-accent-solid hover:bg-accent-solid-hover text-accent-ink shadow-md hover:shadow-lg'
danger:  'bg-danger-solid hover:bg-danger-solid-hover text-danger-ink shadow-md hover:shadow-lg'

No dark: anywhere, and :root { --stx-accent-solid: #e11d48 } re-themes it.

-solid-hover is a role rather than a hover: on the base token because hover:bg-accent-solid would just repaint the same colour — an app that re-points a fill has to be able to re-point what it becomes on hover. -ink exists because every fill had text-white beside it, which is right for the stock hues and unreadable the moment you point a fill at something light.

Your accent is the one in charge now

accent was indigo and is now blue. The library had two competing accents — 73 blue uses against 34 indigo, both meaning "primary" — and a role vocabulary cannot have two. Blue won on count, so the painted majority kept its appearance and ~15 indigo focus/selected states moved.

accent and info therefore share a default. That is deliberate: they are different roles, so pointing --stx-accent at rose does not drag informational emphasis with it.

Measured rather than claimed

A tool resolves every class string to its effective light and dark colour, with dark: overriding plain, and diffs before against after. 47 effective colours changed across the 10 migrated files, every one a single shade step. The ones that matter to you:

  • Button primary and danger fills: unchanged. outline's border and text go 500 → 600 and its hover tint one step stronger; secondary's text neutral-900 → gray-900; the focus ring gains a dark value it never had.
  • Badge's soft fills: unchanged. Its four status dots go 500 → 600 in light.
  • Notification adopts Badge's tint convention — bg 50 → 100, text 900 → 800, dark text 100 → 200.
  • Progress/Spinner's warning fill flips direction, now consistent with every other solid fill.

79 palette-shade uses are gone, 413 → 334.

Still open, which is why this is Refs and not Closes

  • Badge's secondary (purple) and info (cyan), and the same two in Progress and Spinner. The vocabulary has five status hues and neither is one of them. Mapping info onto the info role would paint it blue, which is what primary already is — so a badge whose entire job is to distinguish would render two of its variants identically. Needs a decision: a sixth and seventh role, or accept the collapse.
  • The five inputs' error state. ring-red-300 dark:ring-red-600 text-red-900 placeholder-red-300 matches no role, and forcing it would turn a pale error ring into a strong one. Probably wants its own danger-ring / danger-placeholder pair.
  • Neutrals — ~55 components still name a gray. Less of an adoption blocker than a hue, but it is the bulk of the remaining 334.
  • Footer, the auth screens, the payment flow — page-level compositions rather than primitives, deliberately out of this pass.

On your other three points, which are not colour and so are not addressed here: the pill radius, px-4 py-1.5 against a fixed h-10, and rounded-md. Those are shape, and className has the same specificity problem for rounded-full as it did for bg-accent. If that is still blocking the 294 call sites, it is worth its own issue — the answer is probably a radius role token and dropping the fixed height, but it is a different decision from this one.

9 days ago

Neutrals done too, in e801e37.

The gap that mattered most

The status pass left 43 places painting bg-white dark:bg-neutral-800 — every card, menu, dialog and form control — with no role to move to, because surface is gray-50 and surface-raised is gray-100. Neither is white. So a themed app still saw stock colours on every panel after the hues were migrated.

Two new roles, because the library draws a real distinction:

TokenLightDarkFor
panelwhitegray-800cards, menus, dialogs (24 uses)
fieldwhitegray-700form controls (16 uses)

A control sits one step lighter than the panel it is in, or it disappears into it. panel is the name your own system already uses for this.

Plus the pairings that matched a role exactly, across 33 files — text-neutral-900 dark:text-white → text-fg, text-neutral-600 dark:text-neutral-400 → text-fg-muted, divide-neutral-100 dark:divide-neutral-700 → divide-line, and so on. Mapped by exact pairing rather than by renaming families, so a dark-only value or a different dark step never got swept up.

Measured

64 effective colours changed across 28 files:

  • 55 are neutral-N → gray-N at the same step. The roles are gray-backed, so this unifies an achromatic neutral onto a very slightly blue-tinted gray. That direction was chosen because the roles already had 287 uses against 146 remaining neutral shades — moving the roles instead would have shifted twice as much.
  • 7 are dark:text-white → gray-100, the same colour by another name.
  • 2 are a real step change, both in CommandPalette's divider adopting the shared line role.

No shade steps changed anywhere else.

Where the numbers stand

you measurednow
components hard-coding a shade5644
total palette-shade uses~890269
components using dark:5947

Still literal, deliberately

  • A value with no dark twin, and a dark-only value. Migrating either adds mode behaviour that was never there — 13 dark:bg-neutral-700 with no light partner, 12 text-white with no dark one.
  • Hovered neutrals. Seven of them, and they disagree (gray-50, neutral-300, neutral-700), so there is no dominant pairing to name yet.
  • text-white on a solid fill, which wants -ink but needs the fill identified per component rather than by pattern.
  • Badge's secondary/info and the inputs' error state, as in the previous comment.

Still Refs. What is left is the list above plus the shape questions (pill radius, the fixed h-10) — none of which is a palette problem, and the radius one may well still block your 294 buttons.

9 days ago

Shape done too, in 1284c0f. That closes the three non-colour blockers from the original report.

Radius

Three roles in theme.borderRadius, merged in as base entries exactly like the colour roles — so they resolve with no config, and rounded-md keeps compiling to a baked value (turning an existing utility into a variable would change the CSS of apps that never asked):

TokenUtilityDefaultFor
controlrounded-control0.375rembuttons, inputs, selects, textareas
panelrounded-panel0.5remcards, dialogs, menus, toasts
pillrounded-pill9999pxchips and badges
:root { --stx-radius-control: 9999px }   /* every control becomes a pill */

Directional variants work, so rounded-t-control is available.

78 radius utilities migrated across 38 files. The radius diff reports 5 effective changes, all deliberate: the dropdown, listbox and combobox menus plus Notification and CodeBlock used the control radius by value and were moved to panel, 0.375rem → 0.5rem. That distinction is what makes the roles safe to re-point — otherwise asking for pill-shaped buttons would give you pill-shaped dropdown menus.

Genuine circles keep rounded-full. An avatar, a spinner and a skeleton are circles, not theming decisions.

Height — and a bug in it

px-4 py-1.5 now works, because the fixed heights are gone from Button and the four inputs. Padding decides.

While doing it I measured the old ones against the theme's line-heights and three of five button sizes were wrong:

sizedeclaredcontent
xs1.75rem1.5rembox too tall
sm2rem2rem✓
md2.5rem2.5rem✓
lg3rem3.25remcontent overflowed
xl3.5rem3.75remcontent overflowed

So lg and xl had been quietly eating a quarter-rem of their own padding, and lg in the inputs the same. The new padding reproduces every previous height exactly — 1.75/2/2.5/3/3.5rem for Button, 2/2.5/3rem for the inputs — so nothing moved on screen; xs, lg and xl needed one padding step adjusted to land on the number they already claimed.

Where #1993 stands

Everything in the original report is now addressed:

  • ✅ bg-blue-500 → bg-accent-solid, re-themed by --stx-accent-solid
  • ✅ rounded-md → rounded-control, re-themed by --stx-radius-control
  • ✅ px-4 py-1.5 against a fixed h-10 — the height is gone
  • ✅ dark: variants → one class correct in both modes via the role's own dark value

44 components still name a palette shade, down from 56, and 269 shade uses down from ~890. What is left is the set I would not migrate without a decision from you:

  • Badge's secondary (purple) and info (cyan) — the vocabulary has five status hues and neither is one. Mapping info onto the info role paints it blue, which is what primary already is, so two variants of a badge would render identically. Wants a sixth and seventh role, or an accepted collapse.
  • The inputs' error state — ring-red-300 dark:ring-red-600 text-red-900 placeholder-red-300 matches no role, and forcing it would turn a pale error ring into a strong one. Probably a danger-ring / danger-placeholder pair.
  • Values with no dark twin (13 dark-only backgrounds, 12 text-white with no dark partner) — migrating either adds mode behaviour that was never there.
  • Seven hovered neutrals that disagree with each other (gray-50, neutral-300, neutral-700), so there is no dominant pairing to name.
  • text-white sitting on a solid fill, which wants -ink but needs the fill identified per component rather than by pattern.

Happy to take any of those on your call. If the 294 call sites can migrate now, that is the more useful signal.

9 days ago

The hovered neutrals — and four of them had no hover at all

1f03b15bd1 and 548db884e8.

I went in expecting an inconsistency and found a defect. Thirteen interactive surfaces each invented their own hover pair, and four invented one that cannot be seen:

bg-panel hover:bg-surface                   neutral-800 -> neutral-800
bg-surface-raised hover:bg-surface-sunken   neutral-700 -> neutral-700

surface shares a dark value with panel, and surface-raised with surface-sunken. So Pagination's buttons, both Accordion headers, every hoverable <Table> and <VirtualTable> row, and <FilterChip> highlighted in light mode and did nothing at all in dark. That shape survives review because the class is present and its name reads correctly — only resolving both sides shows it.

Four roles

RoleLightDarkHovers
surface-hoverneutral-100neutral-700a row, header, nav item — bg-panel or nothing
surface-raised-hoverneutral-200neutral-600bg-surface-raised
surface-sunken-hoverneutral-300neutral-600bg-surface-sunken
field-hoverneutral-50neutral-600bg-field

No panel-hover: neutral-100/neutral-700 reads against white, neutral-50, neutral-800 and neutral-900 alike, so a transparent row works wherever the host puts it.

The coloured families got -solid-hover when the fills were migrated, because a fill and its hover have to move together. The neutrals never did — this is that gap closed.

What changed on screen

Nine of the thirteen sites are byte-identical before and after: Login, Signup and PaymentMethods' provider buttons, Button's secondary and ghost variants, both Navigator items, Calendar's nav and day cells. Each one drops a dark: twin it had been spelling out.

The five that do change (measured per class string, dark: overriding plain):

LightDark
Pagination, Accordion ×2, TableRow, VirtualTableneutral-50 → neutral-100neutral-800 → neutral-700
FilterChipunchangedneutral-700 → neutral-600
Calendar's Today buttonblue-50 → neutral-100unchanged

Bold = a hover that did not previously exist. Calendar's Today button joins its two siblings in the same nav row, whose dark hover it already shared.

Two solid fills also hovered to a hue they no longer were. SubscriptionCheckout painted bg-accent-solid hover:bg-indigo-700, left behind when accent moved from indigo to blue; Calendar's selected day hovered dark:bg-blue-600 on a blue-600 fill — absent again. Both go through accent-solid-hover.

inverse, and gray is gone

Tooltip's bubble and the arrow that points out of it are the same colour by intent, spelled as two independent literals in two different families:

bubble   bg-neutral-900 dark:bg-neutral-700
arrow    border-t-gray-900 dark:border-t-gray-700

neutral is achromatic, gray is blue-tinted, so the triangle did not match the bubble it belonged to. Five classes that must agree had nothing holding them together — hence inverse / inverse-ink, a surface that is dark in both modes. The bubble is byte-identical; the arrow stops being tinted.

A focus ring's offset is the surface behind the control, so it takes a surface role too. Eight controls declared only dark:focus:ring-offset-gray-900 and left light mode to the engine's #fff default — which is exactly panel's light value, so focus:ring-offset-panel keeps light byte-identical and corrects dark from gray-900 to the neutral-800 the panel actually is.

That was the last gray in the library. The count is zero and a test keeps it there.

Button now carries no dark: at all

Which is the goal, not a regression. A dark:bg-neutral-600 is a second hard-coded shade an app cannot redirect, and it beats whatever bg-<role> resolves to — so a themed app got its colour in light mode and Tailwind's in dark. 56 of the 102 components are now variant-free. One visual test had to be corrected for this: it asserted darkClasses.length > 0, which the migration deliberately inverts.

The measuring tool was wrong again

Worth recording, since it is the sixth time. The property alternation put ring ahead of ring-offset, so ring-offset-panel parsed as prop ring with name offset-panel, resolved to nothing, and was silently skipped — the entire ring-offset round measured as zero changes. Side borders were missing for the same reason. Alternation takes the first match; longest-first is not optional.

Tests

role-token-coverage.test.ts pins the relationship, not the shades, so re-pointing one of these variables cannot reintroduce an invisible hover:

  • each hover differs from its surface in both modes
  • each hover moves toward contrast, not away (light darkens, dark lightens)
  • surface-hover is visible on every neutral a row can land on
  • no component names a neutral shade on hover, and none names gray anywhere
  • the tooltip arrow and its bubble come from one role
  • a solid fill hovers through the fill's own hover role

CodeBlock's copy chip is the one documented exception: bg-neutral-800 hover:bg-neutral-700 with no dark:, because it floats over highlighted code whose background comes from the syntax theme rather than from a utility. A role whose light value is white would break it, and an always-dark surface role for one call site would be worse than naming the shade — the same call as Switch's off state. The sweep asserts it is the only one left.

14,064 tests pass, lint 0 errors, format:check and typecheck clean.

Still open, still your call

  • 13 dark-only backgrounds and 12 text-white with no dark twin. Tokenising these would add dark-mode behaviour they never had.
  • text-white sitting on a solid fill wants -ink, but needs per-component identification — a text-white on a photo overlay is not the same thing.
  • The Footer / auth / payment page-level families — Login's indigo submit, Signup's and TwoFactorChallenge's teal. These are page-level palettes, not component roles, and collapsing them onto accent would restyle three whole pages.
  • 179 shade uses remain, in 41 of 102 components (was ~890 across 56).

Happy to take any of those on your call.

9 days ago

Shipped in v0.2.345 — @stacksjs/components@0.2.345 and @stacksjs/stx@0.2.345, both verified from the registry tarballs rather than the local tree.

The components:

  • the four hover roles are in use — surface-hover ×11 in 8 files, field-hover ×5 in 3, surface-raised-hover ×1, surface-sunken-hover ×1
  • zero bare hover:bg-surface and zero hover:bg-surface-sunken remain, so neither invisible pair survives
  • one neutral-shade hover left, components/CodeBlock.stx, which is the documented exception
  • zero gray- anywhere in the package
  • Tooltip's bubble and all four arrow sides come from inverse, with no stale literals
  • focus:ring-offset-panel on all six controls, ring-offset-surface on SubscriptionCheckout, no ring-offset-gray-900 anywhere
  • both solid fills hover through accent-solid-hover, with no raw indigo or blue hover left
  • Button carries no dark: class at all

And the roles themselves, imported from the published @stacksjs/stx build rather than grepped: 58 roles, and each of the four hover pairs differs from its base in both modes.

Correction

I said 56 of 102 components are variant-free. Two things were wrong with that number. The sweep used grep -q 'dark:', which also matches the word in a comment, so Button — whose only remaining dark: is in a comment explaining that it no longer needs one — counted as still having variants. And it was measured at 1f03b15bd1, before the ring-offset and Tooltip changes removed dark: from eight more files.

Counting dark: followed by a letter, on the published package: 64 of 102, up from 57 at the hover commit.

9 days ago

The remaining items — and two real bugs along the way

Five commits: d55e8ea921, 73b439be60, 78ca5a660e, 764330ea37, 789f734f03.

179 shade uses → 63, in 24 of 102 components (from ~890 originally).

Two defects, not theming

An input in error showed ordinary text in dark mode. The five text controls each carried

baseClasses  = '… dark:bg-neutral-700 dark:text-neutral-100'
stateClasses = error ? '… text-danger-fg …' : '… text-fg …'

text-fg already resolves to neutral-100 in dark, so the dark: text class said nothing the role did not. In the error branch it said something else entirely: .dark .dark\:text-neutral-100 is two classes of specificity against .text-danger-fg's one, so it won. The ring, placeholder and focus ring turned red; the text stayed light neutral. That is the error state from 60a8c7ecba, half-applied since it shipped.

It is the trap already documented on <Button> for className, arriving from the other side: there a role lost to a later class in the attribute, here a role lost to a variant with more specificity.

A step number nobody could read. <StepperStep> shared one text-white across all three states, so a step not yet reached rendered its number white on neutral-300 — 1.48:1. Only light mode was wrong (white on neutral-600 is 7.81:1), which is why it survived: whoever wrote it almost certainly checked it in dark mode. Now bg-surface-sunken text-fg-strong, 8.25 and 7.01. My first attempt used fg-muted and measured 4.11:1 in dark — one failure traded for another.

Two inks could not be read on their own fill

Measuring the table turned up what nobody had checked. White on the light fill:

ratio
yellow-5001.90:1
cyan-5002.36:1
green-5002.22:1

All below the 3:1 floor for text at any size. warning-ink already carried a dark value for exactly this reason; info-ink and success-ink did not. Both are neutral-900 now — 7.58 and 8.06 light, 4.97 and 5.57 dark. Neither role had a single use in the library, so nothing changed on screen; the fix is for the app that reaches for the role next.

accent, secondary and danger keep white and are not changed. They read 3.76, 4.12 and 3.82 in light mode — above the graphics floor, below 4.5:1 for small text — and they are blue-500, purple-500 and red-500, what every primary and destructive button already paints. Darkening them is a change to the house hues, so the test pins the range and reports it instead.

New test/utils/wcag-contrast.ts computes real ratios over the shipped palette, which is oklch rather than hex, via Oklab to linear sRGB. The existing a11y helper's checkColorContrast could not catch any of this: it matches class-name patterns and reports what is "potentially" low, while here every class was a role name and the problem was the value behind it.

Three new roles

RoleLightDarkWhy
pageneutral-50neutral-900surface says "page and panel background" and is neutral-800 in dark — the same as panel, so a page was indistinguishable from the cards on it. Four components hard-coded the step below; the checkout spelled out this exact pair.
link / link-hoverblue-500 / blue-600blue-400 / blue-300info described itself as "…, links" and nothing used it for one — it is cyan. A link rests a step lighter than an accent action and its hover moves in opposite directions per mode. No existing pair does that.

One accent hue

<Login> was an indigo page; <Signup> and <TwoFactorChallenge> were teal ones — and TwoFactorChallenge used both, an indigo focus ring above a teal button. 44 classes across 7 files onto the accent roles. Their submit buttons also hovered the wrong way (indigo-600 → indigo-500 lightens); on accent-solid-hover they darken.

Three mistakes the tooling caught, all mine

  1. Rewriting bg-indigo-50 dark:bg-indigo-900/30 left a dangling /30 on bg-accent-soft, turning a formerly opaque light value 30% transparent — and the measuring tool compared them equal, because it stripped the opacity modifier before resolving. An alpha is a visible property; it is in the comparison now.
  2. ring-accent-soft-ink/10 dark:ring-accent-soft-ink/20 — a dark: variant whose value is a role is redundant, since the token already carries both modes. A test now rejects the construction library-wide.
  3. link-hover is blue-300, a step the fixture palette did not carry, so semanticTokenCSS dropped the role. The strengthened fixture test from 4d2147cf43 caught it by name, one commit after it was written.

Deliberately not migrated

  • <TableBody>, <VirtualTable>'s body, <CommandPalette> are bg-white dark:bg-neutral-900, and bg-panel would be wrong: their headers sit on surface, whose dark value is neutral-800, so the body would match its own header in dark mode — the same collapse that produced the invisible hovers. They are not page either, since in light mode their body is white and lighter than its header. Expressing them needs a fourth rung on the surface ladder, which is a redesign, not a migration. This is the one open question I would want a call on.
  • <TabBarItem>'s count badge keeps bg-danger. Moving it to bg-danger-solid fixed dark (2.77 → 4.83) and broke light (4.83 → 3.76), below AA for 10px text. Ink migrated, fill left alone.
  • <Select>'s hover:ring-neutral-400 dark:hover:ring-neutral-500 — the only hovered ring in the library, with no next rung on the line ladder. A role for one call site would be the wrong trade.
  • fg-subtle on field reads 2.59:1. A real weak spot, shared with every framework that puts placeholders at neutral-400, and raising it changes how every form looks at rest.
  • <Hero>'s gradient, <Switch>'s achromatic parts, <CodeBlock>'s chip, <Video>'s letterbox, <Drawer>'s scrim — deliberate in both modes.

Note

Three tests in Script Processing Fixes fail on main and are not from this work — they reproduce with it removed and belong to f71f544f17, which landed from another session while this was in progress.

9 days ago

Correction to the note at the end of my last comment

I wrote that three Script Processing Fixes failures "belong to f71f544f17" as if that were established. It was half right and I had not checked it properly — CI is green on that commit, which should have stopped me asserting it.

What is actually true, measured:

NODE_ENV=development bun test packages/stx/test/process/script-setup.test.ts   # 3 fail
bun test packages/stx/test/process/script-setup.test.ts                        # 0 fail

bun test sets NODE_ENV=test, and showBoundaries = !isProduction() && !isTest(), so the new error boundary never renders during the suite. The failures appear only when something leaves NODE_ENV as development — which a full bun test packages/stx/test/ does, because several files mutate it and a save/restore that captures "original" inside another file's window makes the mutation permanent.

So it is a genuine bug in that commit, not a test-ordering annoyance: the boundary's splice ends at a nested </script> inside a string, leaving the rest of the server script's source as literal page text. It is invisible in CI because the only mode that renders the boundary is the one the suite never runs in. Filed as stacksjs/stx#2012 with the reproduction.

Nothing in the #1993 work above is affected — it reproduces with my changes removed, and my own scope is green at 14,107 passing.

9 days ago

The fourth rung — 0b5a1479e4

content: white / neutral-900.

The relation the two table bodies encode is not "one step down" but "the extreme end of the ladder, in whichever direction the mode runs":

LightDark
head (surface)neutral-50neutral-800
body (content)whiteneutral-900

The body is brighter than its header in light mode and darker in dark, and in both the header is the surface with more presence. panel cannot express it — panel and surface share neutral-800 in dark, so a body on panel would be the same colour as its own header. That is the same collapse that hid four hovers, from another direction.

page      neutral-50 / neutral-900    behind everything
content   white      / neutral-900    the paper under a chrome header
panel     white      / neutral-800    the cards on the page
field     white      / neutral-700    the controls inside those

Both table bodies are byte-identical.

content does share page's dark value. That is a decision, not an oversight: a full-bleed table body flush with the page is a normal arrangement, and its rows are separated by divide-line rather than by the surface behind them. What has to hold is that the body differs from its header and from any panel it is nested in — asserted, along with the overlap itself, so moving either role has to move that line too.

<CommandPalette> was not part of this

It is a floating dialog panel, and DialogPanel, DropdownItems, ListboxOptions and ComboboxOptions are all bg-panel. It was the only one painting bg-white dark:bg-neutral-900. On bg-panel now — one step lighter in dark mode, and the only effective colour this commit changes. A test holds all five to the role.

That test needed narrowing once: it rejected dark:bg-neutral-\d{3} outright and caught CommandPalette's dark:bg-neutral-500/50, which is the scrim behind the palette, not its surface. An alpha over whatever is beneath it is the right way to express a backdrop, so the assertion is about opaque surfaces only.

9 days ago

The tail — d381b8bc05, 63fce89e50

60 shade uses → 31, in 12 of 102 components.

Three focus rings were invisible

Six components carried the Headless UI focus treatment — a white ring, sometimes inside a blue offset — lifted off the coloured demo panel it was designed for. On an ordinary light surface ring-white/75 is 1:1, so <DropdownButton>, <PopoverButton> and <ComboboxInput> had no visible focus indicator at all. That is a keyboard user losing their place, not a theming problem.

The other three paired it with ring-offset-blue-300, which does read as a two-tone halo — but it stays blue in a rose-accented app, and it was the only focus treatment in the library that was not ring-accent-solid over the surface behind. Nine components already agreed on that; all fifteen do now, and a test holds every focus ring and every offset to a role with three named exceptions: ring-transparent for an inset-ring input, the sidebar's alpha ring over a per-space tint, and <Switch>'s achromatic white.

The rest of the batch

ComponentWasNow
Avatar's presence dotsbg-green/yellow/red-500 + dark twinsbg-success / bg-warning / bg-danger
Avatar's offline dotneutral-400 dark:neutral-600bg-fg-subtle
Avatar's dot ringborder-white dark:border-neutral-900border-panel
Tabs' active underlineborder-blue-500border-accent — its own label already was
Badge's default varianttext-neutral-800 dark:text-neutral-300text-fg-strong
Progress's info variantbg/stroke-cyan-600 dark:-500info-solid, the only one of five off a -solid role
PaymentMethodshover:bg-red-50 dark:hover:bg-red-900/20hover:bg-danger-soft, both outlines onto roles
Hero's headlinefrom-blue-500 to-purple-600from-accent to-secondary

Two measured rather than guessed. Avatar's offline dot was neutral-600 on the avatar's own neutral-700 in dark — 1.33:1, a dot you cannot see; on fg-subtle it is 2.20:1, better and still poor, and the dot is read off its ring rather than off the avatar. Navigator's selected sidebar item painted text-accent on its tint — 4.31:1 light, 3.94:1 dark, below AA; on accent-soft with accent-soft-ink it is 7.25 and 7.31.

A fourth tooling gap

from|to|via were missing from the measuring tool's property list, so rewriting from-blue-500 to-purple-600 reported zero changes. That is the fourth gap in that tool this run, after the ring/ring-offset ordering, the side borders and the opacity modifier. Every one of them had the same shape: a silent zero rather than a wrong number.


What the 31 are, and the one decision left

CountWhy it stays
Scrims and letterboxes — Drawer, CommandPalette, Video8A translucent neutral-500/75 over the page, or a near-black video frame in both modes. An alpha over whatever is beneath it is the correct expression.
<Switch>'s off state and ring offsets4Achromatic by design, already pinned by a test.
Hero's button gradient bottom stop2neutral-100 / neutral-900 is no role, and page differs in light.
<CodeBlock>'s copy chip2Dark in both modes over syntax-themed code.
<Select>'s hover:ring-neutral-400 dark:hover:ring-neutral-5002The only hovered ring in the library; no next rung on the line ladder, and a role for one call site is the wrong trade.
Panel ring in dark — DropdownItems, DefaultPaymentMethod2ring-black/5 in light, a shade in dark. The light half is an alpha.
Bare text-neutral-400 / placeholder-neutral-4005See below.

fg-subtle fails AA in both modes

The five bare text-neutral-400 sites — CommandPalette's search icon and placeholder, Footer, SubscriptionCheckout, ComboboxButton — have no dark twin, so they are neutral-400 in both modes. Moving them to fg-subtle would make them worse:

LightDark
today (neutral-400, no twin)2.59:1 on white4.01:1 on neutral-700
fg-subtle (neutral-400 / neutral-500)2.59:12.20:1

So leaving them is the measured choice, not an omission. But it surfaces the underlying problem: fg-subtle fails AA in both modes, and it is the only fg-* role that gets darker in dark mode — the others run 900→100, 700→300, 600→400, 500→400, and fg-subtle goes 400→500.

To pass 4.5:1 it would need neutral-500 light (4.73:1) and neutral-300 dark (7.01:1). That changes how every placeholder in the library looks at rest.

The reason I have not done it: fg-subtle serves two purposes with opposite requirements. It is placeholder text, which is text and must be legible — and it is disabled text, which WCAG explicitly exempts from contrast requirements and which is supposed to recede. One role cannot satisfy both. The clean fix is to split them, which is a vocabulary decision rather than a migration.

Happy to split it — fg-subtle staying put for disabled, a new role at neutral-500/neutral-300 for placeholders — on your call.

9 days ago

fg-subtle split — 063476806b

31 → 28 shade uses. But the count is not the point of this one.

The split is legible from recessive, not placeholder from disabled

I proposed splitting placeholder from disabled. Measuring first showed that was the wrong seam:

LightDark
fg-subtle (neutral-400 / neutral-500)2.59:12.20:1
danger-fg-subtle (red-300 / red-500)1.92:12.72:1

Both below 4.5:1 for text, and below even the 3:1 graphics floor in most places they landed. danger-fg-subtle being the worse of the two meant the value in an invalid field was harder to read than in a valid one.

And the uses were not placeholder-vs-disabled. Of 25, 23 had to be read: all 12 placeholders, the breadcrumb separator and its collapsed ellipsis, the pagination ellipsis, the empty-state illustration, the inputs' icons and icon buttons. Only two were genuinely recessive — a day outside the displayed month, an offline presence dot.

Which meant no new role

The legible half already had roles:

LightDark
fg-soft — placeholders, marks, icons4.73:15.83:1 on a panel
danger — the same in a field with an error4.76:13.59:1

A third role at those values would have been a second name for a colour the vocabulary already had. So both -subtle roles keep their value for what it suits, their descriptions stop inviting the other use, and a test holds them to exactly those two call sites — reaching for the word "subtle" for a label now fails.

66 effective colours change and every one moves the same way: light darkens a step, dark lightens a step.

Three things that needed measuring

The icon buttons. They were text-fg-subtle hover:text-fg-muted, which worked as a hover — light 400→600, dark 500→400, both gaining contrast — it just started at 2.59:1. Moving the rest to fg-soft and keeping fg-muted would have made that hover a no-op in dark mode, since both are neutral-400. They are fg-soft hover:fg-strong now: light 500→700, dark 400→300.

Why the dark numbers are lower on purpose. field is neutral-700 in dark so an input does not vanish into its neutral-800 panel. No shade on neutral-700 reaches 4.5:1 without being brighter than fg-muted — a placeholder more prominent than body text. 4.01:1 is the best available that keeps the hierarchy, so the test asserts 4.5 in light and 3 in dark rather than a number the ladder cannot deliver, and asserts the hierarchy itself alongside it.

A symmetry I chose to break. The four field-error roles mirrored the resting branch name for name. placeholder-fg-soft against placeholder-danger breaks that for the placeholder, and restoring it would have meant inventing danger-fg-placeholder as a duplicate of danger. The test that pinned the mirror now pins the contract — the two branches read as the same four decisions — with the asymmetry and its reason written down.

What the last 28 are

Scrims and letterboxes (8), <Switch>'s achromatic parts (4), Hero's button gradient bottom stop (2), <CodeBlock>'s chip (2), <Select>'s hover ring (2), two panel rings whose light half is an alpha (2), and the rest singletons. Every one of them is deliberate and recorded above; I do not think any should move.

From ~890 at the start of #1993.

9 days ago

Closing. The title's two numbers are the measure, so here they are.

at opennow
components hard-coding a palette shade56 of 10212 of 102
palette-shade uses~89028
components using a dark: variant5917 of 102
roles in the vocabulary1462

Published in 0.2.346 and verified against the registry tarballs rather than the working tree.

What the last 28 are

All deliberate, each recorded in a commit message and in docs/features/theming.md:

  • Scrims and letterboxes (8) — Drawer's and CommandPalette's translucent neutral-500/75 over the page, Video's near-black frame in both modes. An alpha over whatever is beneath it is the correct expression; a fixed colour is not.
  • <Switch>'s off state and ring offsets (4) — achromatic by design, pinned by a test.
  • Hero's button gradient bottom stop (2) — neutral-100 / neutral-900 is no role, and page differs in light.
  • <CodeBlock>'s copy chip (2) — dark in both modes, floating over syntax-themed code whose background comes from the highlighter rather than a utility.
  • <Select>'s hovered ring (2) — the only hovered ring in the library, with no next rung on the line ladder. A role for one call site would be the wrong trade.
  • Two panel rings (2) — ring-black/5 in light, a shade in dark; the light half is an alpha.
  • Singletons, each with a reason in place.

The 17 remaining dark: users are those same cases plus the auth pages' own surfaces.

What this turned out to be about

Not colours, in the end. The migration kept surfacing defects that the hard-coded shades had been hiding, and those were the valuable part:

  • Four hovers that could not be seen. surface shares a dark value with panel, and surface-raised with surface-sunken, so Pagination's buttons, both Accordion headers, every hoverable Table and VirtualTable row and FilterChip highlighted in light mode and did nothing at all in dark.
  • Three focus rings that could not be seen. ring-white/75 on a light surface is 1:1 — <DropdownButton>, <PopoverButton> and <ComboboxInput> had no visible focus indicator.
  • An input in error showing ordinary text in dark mode, because dark:text-neutral-100 in the base had two classes of specificity against text-danger-fg's one.
  • A step number at 1.48:1 — <StepperStep> shared one text-white across all three states, so a step not yet reached rendered white on neutral-300.
  • Two inks that could not be read on their own fill — white on cyan-500 is 2.36:1, on green-500 2.22:1.
  • fg-subtle failing AA in both modes while carrying every placeholder in the library, and danger-fg-subtle worse at 1.92:1, which made an invalid field harder to read than a valid one.
  • Three competing accent hues — <Login> indigo, <Signup> and <TwoFactorChallenge> teal, and TwoFactorChallenge used both.
  • A tooltip arrow in a different family from its own bubble, so the triangle was blue-tinted against an achromatic bubble.

None of those were in the original report and none would have been found by reading the classes; each needed both sides resolved and measured.

And the measuring tool was wrong five times

Worth recording, because the failure mode was identical every time — a silent zero, never a wrong number:

  1. ring ahead of ring-offset in its property alternation, so ring-offset-panel parsed as prop ring with name offset-panel, resolved to nothing, and the whole ring-offset round measured as no change.
  2. Side borders missing for the same reason, so the tooltip arrow fix measured as nothing.
  3. The opacity modifier stripped before resolving, so a dangling /30 left on a formerly opaque light value compared equal.
  4. from|to|via missing, so rewriting a gradient reported zero changes.
  5. Earlier in the run: a global regex's lastIndex in .test(), a positional key collision, a class pattern needing whitespace before a name, and resolving both sides with the current token table.

If there is one thing to carry forward from this issue it is that: a diff tool that can report zero is more dangerous than one that reports the wrong number, because zero is what you were hoping for.

Follow-ups left open on their own issues: #2012 (the server-script boundary leaking its tail). Reopen this if any of the 28 should move.

Sign in to comment on this issue.