Skip to content

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

Description

@glennmichael123

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 > 1false.

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions