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.
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
The literal is inlined into the utility, so redefining
--surface-baseunderhtml.darkcannot change whatbg-surface-basepaints. And a nested record is just a shade scale —surface.darkis a shade that happens to be named "dark", reachable only asdark:bg-surface-darkwritten by hand at every call site.cssVariables: trueemits 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 thedark: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:
…and then own the actual value in hand-written CSS:
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
#18181bwritten in three places, and changing the panel color means finding all three.cssVariables: truelooks like it should help — it can generate the:roothalf fromtheme.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:
…emitting
:root { --panel: #fff }+html.dark { --panel: #18181b }and abg-panelutility that referencesvar(--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.