ReviewOS

stacks/stx

The component library's token vocabulary is neither shipped nor documented, so every component renders half-styled until an app reverse-engineers it

#1996
Closed glennmichael123 opened this 12 days ago ·
12 days ago

packages/components — measured on 0.2.321. Related to #1993 but separable: that one is about components that hard-code Tailwind palette colours, this one is about the components that correctly don't.

The problem

The library styles itself with its own semantic colour names. Counted across all 102 components:

text-fg              76      bg-surface           19
text-fg-strong       26      ring-line-strong     16
text-fg-muted        26      bg-surface-sunken    15
border-line          24      bg-surface-raised    15
text-fg-soft         21      border-line-strong   14
text-fg-subtle       19      divide-line           8
text-accent           7      ring-accent           7

That is a good vocabulary, and using it is the right call. But nothing in the package defines those names, and nothing documents them. They are not in the README, not in a shipped stylesheet, and not stated as a contract an app is expected to satisfy.

So an app that installs the library and drops in a component gets markup carrying class names that resolve to nothing. The result is not an obvious failure — it is a half-styled component. <Table> renders with divide-y applying a border width while divide-line supplies no colour. <Skeleton> renders with no background at all, because its entire fill is bg-surface-sunken. Text falls back to inherit. It looks like the component is broken rather than like the app is missing a definition.

We found this the slow way: adopting components, seeing them render wrong, and eventually grepping the library's source to extract the list above.

What would fix it

Any one of these, roughly in order of how useful they'd be:

  1. Ship a default stylesheet defining all of them against sensible values, which an app can override. Then a fresh install works out of the box and theming is opt-in rather than mandatory.
  2. Define them as CSS custom properties with fallbacks — color: var(--stx-fg, #111) — which composes with #1993's suggestion and makes both problems one fix.
  3. Document the contract: a table in the README saying "define these N utilities, or these N custom properties, in your theme". Cheapest option, and it would have saved us the reverse-engineering.

For reference, what adopting it took us

We alias them onto each app's existing palette in the crosswind config:

colors: {
  surface: 'var(--panel)',
  'surface-sunken': 'var(--bg)',
  'surface-raised': 'color-mix(in srgb, var(--accent) 8%, var(--panel))',
  fg: 'var(--text)',
  'fg-strong': 'var(--text)',
  'fg-muted': 'var(--text-2)',
  'fg-soft': 'var(--text-2)',
  'fg-subtle': 'var(--text-3)',
  line: 'var(--border)',
  'line-strong': 'color-mix(in srgb, var(--text-3) 55%, var(--border))',
  'accent-solid': 'var(--accent)',
}

Eleven lines per app, five apps. After that the colour-neutral components render correctly in each app's own design, in both light and dark, with no className at any call site — which is exactly how it should work, and it would be nice if it worked that way without each adopter deriving the list from your source first.

One note in case it is useful: divide-line is the one name we cannot satisfy, because crosswind generates no divide-* colour utility at all. That makes Table's divide-y divide-line inert for us — harmless, but worth knowing that the divider is silently absent rather than mis-coloured.

12 days ago

Fixed — option 2, which as the issue predicted turned out to be the same fix as #1993.

Every role is merged into the base theme as var(--stx-<name>, <light value>), with :root and .dark blocks emitted alongside. Verified end-to-end on a throwaway project with no config at all beyond root and pagesDir, rendering the two components this issue names by symptom:

OK   bg-surface-sunken has a colour
OK   divide-line has a colour
OK   text-fg has a colour
OK   :root defines --stx-fg
OK   .dark redefines it

bg-surface-sunken -> background-color: var(--stx-surface-sunken, oklch(92.2% 0 0));
divide-line       -> border-color: var(--stx-line, oklch(92.2% 0 0));

So <Skeleton> has its fill and <Table>'s divide-y has a colour to go with its width. The fallback inside the var() is deliberate: if the :root/.dark block ever fails to reach a page, light mode stays correct and dark mode degrades to the light colour, rather than rendering an invalid colour and taking the component's styling with it.

Base rather than an opt-in preset was the load-bearing decision, and it is the property most worth stating here: a component that says text-fg-muted has to work in an app that has never heard of the token vocabulary. Shipping the tokens separately from the components that use them would have meant adopting the library silently required a config change, and skipping it rendered every component unstyled — which is this issue.

They are still fully overridable: merged as base colours, so a project redefining accent in its own config still wins.

The vocabulary is 62 roles now, not 14

The list in this issue was the 14 that existed at 0.2.321. It is now 62, including the ones that had to be invented to finish #1993: panel and field (a white surface had no name, which is why 43 call sites stayed literal), page and content (surface described itself as "page and panel background" and was the same value as panel in dark mode), the four neutral -hover roles, inverse, link, and the -solid / -ink / -soft sets.

Option 3 as well

docs/features/theming.md carries the full table with light and dark values, what each role is for, and the measured reasons behind the non-obvious ones — why neutral and not gray, why three of the six inks are dark, why a hover is its own role rather than a hover: on the base token.

Thanks for the reverse-engineered alias table in the report; the shape of it is what made clear that the fix had to be in the base theme rather than in a preset.

Sign in to comment on this issue.