ReviewOS

stacks/ts-css

feat(theme): emit theme tokens as custom properties AND have utilities reference them (Tailwind v4 `@theme` parity)

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

Summary

There is no way to define a colour token once in crosswind.config and get both (a) the :root custom property and (b) utilities that reference that property. cssVariables: true gets you (a) but inlines the literal value into the utility, so overriding the variable at runtime — the standard light/dark mechanism — changes nothing. Pointing a theme colour at var(--x) gets you (b) but emits no definition, so the variables must be hand-written in CSS somewhere else. Either way the token block ends up duplicated by hand.

Separately, there is no CSS entry point: @apply is not implemented and build() takes no input stylesheet, so authored component CSS cannot go through the same pipeline as utilities.

Repro

// cssVariables: true, theme.extend.colors = { bg: '#0a0c0e', panel: '#141719' }
:root {
  --bg: #0a0c0e;
  --panel: #141719;
}

.bg-bg { background-color: #0a0c0e; }   /* ← the literal, not var(--bg) */

So @media (prefers-color-scheme: dark) { :root { --bg: #080b12 } } re-points the custom property and no utility notices.

// cssVariables: false, theme.extend.colors = { bg: 'var(--bg)' }
.bg-bg { background-color: var(--bg); }  /* ← themeable, but --bg is defined nowhere */

Impact (real app)

analyticshq took the second branch. config/crosswind.ts:23-39 registers eight tokens as var() references:

colors: {
  'bg': 'var(--bg)', 'panel': 'var(--panel)', 'border': 'var(--border)',
  'text': 'var(--text)', 'text-2': 'var(--text-2)', 'text-3': 'var(--text-3)',
  'accent': 'var(--accent)', 'bar': 'var(--bar)',
}

The definitions are then hand-written three times, each with its own @media (prefers-color-scheme: dark) override — resources/layouts/auth.stx:24-33, resources/views/dashboard.stx:1071-1076 and resources/views/account.stx:88-93 (the latter two near-identical, differing only in --bar and the mono stack). A fourth, independent token set lives in public/marketing.css — 905 lines / 34,503 bytes of hand-authored CSS committed straight to public/ and served unhashed at /marketing.css (resources/layouts/marketing.stx:22): no minification, no hashing, no cache busting, and no crosswind pass over it.

The layout file records how that started (resources/layouts/auth.stx:14-17):

The <style> block below was copy-pasted, byte for byte, into login.stx and register.stx, and into sites/new.stx minus the five .oauth-btn/.divider rules it does not use.

Evidence in crosswind

packages/crosswind/src/generator.ts

  • :2401 if (this.config.cssVariables) { … } — the only consumer of the flag
  • :2514-2537 generateCSSVariables() — emits :root { --<name>: <value> } from extendColors
  • The colour rules resolve theme.colors[name] to its raw value; nothing rewrites it to var(--name), which is why the utility carries the literal.

packages/crosswind/src/config.ts:521 — cssVariables: false by default.

No @apply: grep -rn '@apply' packages/crosswind/src/*.ts → no matches. packages/crosswind/src/build.ts exposes build(config) (:17) and buildAndWrite(config) (:91) — both generate utilities from scanned content; neither accepts an authored CSS entry to transform.

Prior art

  • Tailwind v4 @theme — one declaration emits the :root custom property and every generated utility references it (background-color: var(--color-bg)), so overriding the property in a media query or on [data-theme] re-themes the whole sheet. This is exactly the shape needed here.
  • Tailwind v3 — @apply plus a processed entry CSS, so authored component rules go through the same pipeline as utilities.
  • Vue/Svelte scoped <style> — component CSS is owned by the component and still processed.

Ask

  1. Add a theme mode where a token declared as a literal emits both :root { --<name>: <literal> } and utilities that reference var(--<name>), so a runtime override re-themes every utility. (cssVariables: 'reference' alongside today's true, or a @theme-equivalent config key.)
  2. Support per-scheme token values in config — { bg: { light: '#fbfbfc', dark: '#080b12' } } — emitting the :root block plus the prefers-color-scheme / [data-theme] override, so the dark block does not have to be hand-written per page.
  3. Accept an input CSS entry point in build() / the plugin, and implement @apply, so authored component CSS is minified, hashed and shipped by the same pipeline as the utilities.
  4. Keep var()-valued theme colours working as they do today — they are the only workaround available right now.

Related: #16

Sign in to comment on this issue.