ReviewOS

stacks/ts-css

Theme tokens are single-valued: no light/dark or named-scope axis, forcing a hand-written parallel palette

#23
Open glennmichael123 opened this 9 days ago ·
9 days ago

A theme token holds exactly one value. There is no way to say "this token is X in light and Y in dark", so any app with more than one palette has to keep the token names in the config and the token values in hand-written CSS — two files that must be kept in sync by hand.

Repro

import { CSSGenerator, defaultConfig } from '@cwcss/crosswind'

const g = new CSSGenerator({
  ...defaultConfig,
  cssVariables: true,
  theme: {
    ...defaultConfig.theme,
    extend: { colors: { surface: { base: '#ecfdf5', dark: '#064e3b' } } },
  },
})
g.generate('bg-surface-base')
console.log(g.toCSS().match(/\.bg-surface-base\s*\{[^}]*\}/)[0])
// .bg-surface-base { background-color: #ecfdf5; }

The literal is inlined into the utility, so redefining --surface-base under html.dark cannot change what bg-surface-base paints. And a nested record is just a shade scale — surface.dark is a shade that happens to be named "dark", reachable only as dark:bg-surface-dark written by hand at every call site.

cssVariables: true emits a single flat :root { --k: v } block (dist/chunk-jbn8cyfb.js:453, generateCSSVariables), unconditionally, with no second block for any mode. Verified: (css.match(/:root/g) ?? []).length > 1 → false.

darkMode: 'class' | 'media' (types.d.ts:34) only picks the selector the dark: variant compiles to. It doesn't give tokens a mode axis.

Why it matters

The only arrangement that yields mode-responsive utilities is to make the token a var indirection:

theme: { extend: { colors: { panel: 'var(--panel)' } } }

…and then own the actual value in hand-written CSS:

:root      { --panel: #ffffff; }
html.dark  { --panel: #18181b; }

That works, but it means both halves must exist, and they drift. In our app that's three hand-authored blocks (light, dark, and a third named scope for marketing) totalling ~50 lines of raw custom properties — plus the same hex values duplicated into two standalone stylesheets that were written before the indirection existed. We now have #18181b written in three places, and changing the panel color means finding all three.

cssVariables: true looks like it should help — it can generate the :root half from theme.extend.colors — but only for one mode, and only if crosswind owns the literal, which breaks the indirection the whole scheme depends on. So it isn't a usable substitute.

Ask

A mode axis on token values. Shape doesn't matter much; something like:

theme: {
  extend: {
    colors: {
      panel: { light: '#ffffff', dark: '#18181b' },
    },
  },
  modes: { dark: 'html.dark', marketing: 'html.marketing' },  // selector per mode
}

…emitting :root { --panel: #fff } + html.dark { --panel: #18181b } and a bg-panel utility that references var(--panel). That would let the config own the palette outright and delete the hand-written blocks.

The named-scope part matters as much as light/dark — our third palette isn't a color scheme, it's a section of the site, and dark: can't express it at all.

Environment

crosswind 0.2.15, consumed through @stacksjs/stx 0.2.153, Bun 1.3.1.

Sign in to comment on this issue.