@marianmeres/design-tokens 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/AGENTS.md +46 -25
  2. package/API.md +192 -7
  3. package/README.md +83 -4
  4. package/dist/color.d.ts +97 -0
  5. package/dist/color.js +535 -0
  6. package/dist/css/amber-olive-safari.css +99 -6
  7. package/dist/css/blue-orange.css +99 -6
  8. package/dist/css/cyan-red.css +99 -6
  9. package/dist/css/cyan-slate.css +99 -6
  10. package/dist/css/disco.css +99 -6
  11. package/dist/css/electric-lemonade.css +99 -6
  12. package/dist/css/emerald-amber-forest.css +99 -6
  13. package/dist/css/emerald-pink.css +99 -6
  14. package/dist/css/fuchsia-emerald.css +99 -6
  15. package/dist/css/gray.css +99 -6
  16. package/dist/css/hummingbird.css +99 -6
  17. package/dist/css/indigo-amber.css +99 -6
  18. package/dist/css/lime-fuchsia-neon.css +99 -6
  19. package/dist/css/mauve-lime-electric.css +99 -6
  20. package/dist/css/mauve-teal.css +99 -6
  21. package/dist/css/mauve.css +99 -6
  22. package/dist/css/mist-indigo-fjord.css +99 -6
  23. package/dist/css/mist-violet-aurora.css +99 -6
  24. package/dist/css/mist.css +99 -6
  25. package/dist/css/monokai-cyan.css +99 -6
  26. package/dist/css/monokai-green.css +99 -6
  27. package/dist/css/monokai-pink.css +99 -6
  28. package/dist/css/nebula.css +99 -6
  29. package/dist/css/old-glory.css +99 -6
  30. package/dist/css/olive-amber-safari.css +99 -6
  31. package/dist/css/olive.css +99 -6
  32. package/dist/css/orange-pink-sunset.css +99 -6
  33. package/dist/css/peacock.css +99 -6
  34. package/dist/css/pink-emerald.css +99 -6
  35. package/dist/css/pink-teal.css +99 -6
  36. package/dist/css/purple-yellow.css +99 -6
  37. package/dist/css/rainbow.css +99 -6
  38. package/dist/css/red-blue.css +99 -6
  39. package/dist/css/red-cyan.css +99 -6
  40. package/dist/css/red-sky-slate.css +99 -6
  41. package/dist/css/red-sky.css +99 -6
  42. package/dist/css/rose-teal.css +99 -6
  43. package/dist/css/sky-amber.css +99 -6
  44. package/dist/css/slate-cyan.css +99 -6
  45. package/dist/css/slate-teal-ocean.css +99 -6
  46. package/dist/css/stone-orange-earth.css +99 -6
  47. package/dist/css/stone.css +99 -6
  48. package/dist/css/taupe-olive-clay.css +99 -6
  49. package/dist/css/taupe-rose-blush.css +99 -6
  50. package/dist/css/taupe.css +99 -6
  51. package/dist/css/teal-rose.css +99 -6
  52. package/dist/css/velvet.css +99 -6
  53. package/dist/css/violet-lime.css +99 -6
  54. package/dist/css/violet-rose-dusk.css +99 -6
  55. package/dist/css/watermelon.css +99 -6
  56. package/dist/css/zinc.css +99 -6
  57. package/dist/generate.d.ts +104 -3
  58. package/dist/generate.js +134 -30
  59. package/dist/mod.d.ts +26 -1
  60. package/dist/mod.js +16 -1
  61. package/dist/reboot-bridge.d.ts +14 -6
  62. package/dist/reboot-bridge.js +32 -10
  63. package/dist/themes/amber-olive-safari.js +3 -2
  64. package/dist/themes/blue-orange.js +3 -2
  65. package/dist/themes/cyan-red.js +3 -2
  66. package/dist/themes/cyan-slate.js +3 -2
  67. package/dist/themes/disco.js +3 -2
  68. package/dist/themes/electric-lemonade.js +3 -2
  69. package/dist/themes/emerald-amber-forest.js +3 -2
  70. package/dist/themes/emerald-pink.js +3 -2
  71. package/dist/themes/fuchsia-emerald.js +3 -2
  72. package/dist/themes/gray.js +3 -2
  73. package/dist/themes/hummingbird.js +3 -2
  74. package/dist/themes/indigo-amber.js +3 -2
  75. package/dist/themes/lime-fuchsia-neon.js +3 -2
  76. package/dist/themes/mauve-lime-electric.js +3 -2
  77. package/dist/themes/mauve-teal.js +3 -2
  78. package/dist/themes/mauve.js +3 -2
  79. package/dist/themes/mist-indigo-fjord.js +3 -2
  80. package/dist/themes/mist-violet-aurora.js +3 -2
  81. package/dist/themes/mist.js +3 -2
  82. package/dist/themes/monokai-cyan.js +3 -2
  83. package/dist/themes/monokai-green.js +3 -2
  84. package/dist/themes/monokai-pink.js +3 -2
  85. package/dist/themes/nebula.js +3 -2
  86. package/dist/themes/old-glory.js +3 -2
  87. package/dist/themes/olive-amber-safari.js +3 -2
  88. package/dist/themes/olive.js +3 -2
  89. package/dist/themes/orange-pink-sunset.js +3 -2
  90. package/dist/themes/peacock.js +3 -2
  91. package/dist/themes/pink-emerald.js +3 -2
  92. package/dist/themes/pink-teal.js +3 -2
  93. package/dist/themes/purple-yellow.js +3 -2
  94. package/dist/themes/rainbow.js +3 -2
  95. package/dist/themes/red-blue.js +3 -2
  96. package/dist/themes/red-cyan.js +3 -2
  97. package/dist/themes/red-sky-slate.js +3 -2
  98. package/dist/themes/red-sky.js +3 -2
  99. package/dist/themes/rose-teal.js +3 -2
  100. package/dist/themes/sky-amber.js +3 -2
  101. package/dist/themes/slate-cyan.js +3 -2
  102. package/dist/themes/slate-teal-ocean.js +3 -2
  103. package/dist/themes/stone-orange-earth.js +3 -2
  104. package/dist/themes/stone.js +3 -2
  105. package/dist/themes/taupe-olive-clay.js +3 -2
  106. package/dist/themes/taupe-rose-blush.js +3 -2
  107. package/dist/themes/taupe.js +3 -2
  108. package/dist/themes/teal-rose.js +3 -2
  109. package/dist/themes/velvet.js +3 -2
  110. package/dist/themes/violet-lime.js +3 -2
  111. package/dist/themes/violet-rose-dusk.js +3 -2
  112. package/dist/themes/watermelon.js +3 -2
  113. package/dist/themes/zinc.js +3 -2
  114. package/package.json +1 -1
package/AGENTS.md CHANGED
@@ -15,7 +15,9 @@
15
15
 
16
16
  CSS custom property generator for design token systems. Takes a structured
17
17
  `ThemeSchema` (light + optional dark mode) and produces CSS `--custom-property`
18
- declarations with automatic hover/active state derivation via `color-mix()`.
18
+ declarations with automatic hover/active state derivation via `color-mix()`, plus a
19
+ precomputed `@supports` fallback so the output still renders on engines that predate
20
+ `color-mix()` (Chrome/Edge < 111, Safari < 16.2, Firefox < 113).
19
21
 
20
22
  Entry points:
21
23
 
@@ -30,13 +32,14 @@ Entry points:
30
32
  /src
31
33
  mod.ts — main entry: re-exports functions, types, colors
32
34
  types.ts — core type definitions (ThemeSchema, TokenSchema, etc.)
33
- generate.ts — token generation + CSS output (generateCssTokens, toCssString, generateThemeCss)
35
+ generate.ts — token generation + CSS output (generateCssTokens, toCssString, generateThemeCss) + static fallback resolution
36
+ color.ts — color parsing, sRGB<->OKLab, spec-accurate color-mix() evaluation, rgba() authoring helper
34
37
  utils.ts — hex color parsing (hexToRgb, hexToRgbTriplet)
35
38
  colors.ts — Tailwind color palette (v3 + v4.2 additions: taupe, mauve, mist, olive), as const
36
39
  reboot-bridge.ts — Bootstrap Reboot --bs-* variable mapping
37
40
  /themes
38
41
  mod.ts — auto-generated barrel + runtime registry (via scripts/build-themes-mod.ts)
39
- *.ts — individual theme files (default export: ThemeSchema)
42
+ *.ts — individual theme files (default export: ThemeSchema; `ring` uses rgba(), never color-mix)
40
43
  /css — pre-built CSS files (generated by scripts/build-css.ts; filenames = kebab-case theme names)
41
44
  /tests — test files (deno test --allow-read)
42
45
  /scripts
@@ -51,13 +54,20 @@ Entry points:
51
54
 
52
55
  ### Functions (from `./src/mod.ts`)
53
56
 
54
- | Function | Purpose |
55
- | --------------------------------------------- | --------------------------------------------------------------------------------------------------- |
56
- | `generateCssTokens(schema, prefix, options?)` | Generate token key-value pairs. Third arg: `GenerateOptions` object or `"light"`/`"dark"` shorthand |
57
- | `toCssString(tokens, selector?)` | Convert tokens to a CSS declaration block (groups by base color name) |
58
- | `generateThemeCss(schema, prefix, options?)` | Generate complete CSS for light + optional dark mode; supports `cssLayer` wrapping and `prettierIgnore` pragma |
59
- | `hexToRgb(hex)` | Parse hex to `[r, g, b]` tuple |
60
- | `hexToRgbTriplet(hex)` | Convert hex to `"r, g, b"` string |
57
+ | Function | Purpose |
58
+ | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
59
+ | `generateCssTokens(schema, prefix, options?)` | Generate token key-value pairs. Third arg: `GenerateOptions` object or `"light"`/`"dark"` shorthand |
60
+ | `toCssString(tokens, selector?)` | Convert tokens to a CSS declaration block (groups by base color name) |
61
+ | `generateThemeCss(schema, prefix, options?)` | Generate complete CSS for light + optional dark mode; supports `fallback`, `cssLayer` wrapping and `prettierIgnore` pragma |
62
+ | `resolveStaticTokens(tokens)` | Evaluate every `color-mix()` in a record to a literal color |
63
+ | `resolveStaticOverrides(tokens)` | Only the tokens that carried a `color-mix()`, precomputed — use to audit legacy exposure |
64
+ | `staticFallbackCss(entries)` | Build the `@supports not (...)` block backing up a set of `{selector, tokens}` blocks |
65
+ | `rgba(color, alpha)` | `color-mix()`-free translucent tint; use this in theme schemas |
66
+ | `parseColor(value)` / `formatColor(color)` | CSS color ↔ `Rgba` (hex, rgb, hsl, oklab, oklch, keywords) |
67
+ | `mixColors(space, c1, p1, c2, p2)` | Spec-accurate `color-mix()` math (premultiplied, CSS Color 5 percentages) |
68
+ | `evaluateColorExpression(expr, lookup?)` | Resolve a `var()`/`color-mix()` expression to a literal; `null` when not statically resolvable |
69
+ | `hexToRgb(hex)` | Parse hex to `[r, g, b]` tuple |
70
+ | `hexToRgbTriplet(hex)` | Convert hex to `"r, g, b"` string |
61
71
 
62
72
  ### Functions (from `./reboot`)
63
73
 
@@ -74,18 +84,22 @@ Entry points:
74
84
 
75
85
  ### Key Types
76
86
 
77
- | Type | Purpose |
78
- | ----------------------- | ------------------------------------------------------------------------------- |
79
- | `ThemeSchema` | `{ light: TokenSchema; dark?: TokenSchema }` |
80
- | `TokenSchema` | Intent colors, role paired/single colors |
81
- | `ColorPair` | `{ DEFAULT, foreground, hover?, active?, foregroundHover?, foregroundActive? }` |
82
- | `ColorValue` | `{ DEFAULT, hover?, active? }` |
83
- | `SingleColor` | `string \| ColorValue` |
84
- | `GeneratedTokens` | `Record<string, string>` |
85
- | `GenerateOptions` | `{ mode?, deriveStates?, surfaceForegroundContrast? }` |
86
- | `GenerateThemeOptions` | `Omit<GenerateOptions, "mode"> & { cssLayer?, prettierIgnore? }` |
87
- | `RolePairedKey` | Required: `"background" \| "muted" \| "surface"` |
88
- | `RolePairedOptionalKey` | Optional: `"surface-1"` (all bundled themes ship it) |
87
+ | Type | Purpose |
88
+ | ----------------------- | ----------------------------------------------------------------------------------------- |
89
+ | `ThemeSchema` | `{ light: TokenSchema; dark?: TokenSchema }` |
90
+ | `TokenSchema` | Intent colors, role paired/single colors |
91
+ | `ColorPair` | `{ DEFAULT, foreground, hover?, active?, foregroundHover?, foregroundActive? }` |
92
+ | `ColorValue` | `{ DEFAULT, hover?, active? }` |
93
+ | `SingleColor` | `string \| ColorValue` |
94
+ | `GeneratedTokens` | `Record<string, string>` |
95
+ | `GenerateOptions` | `{ mode?, deriveStates?, fallback?, surfaceForegroundContrast? }` |
96
+ | `GenerateThemeOptions` | `Omit<GenerateOptions, "mode" \| "fallback"> & { fallback?, cssLayer?, prettierIgnore? }` |
97
+ | `TokenFallback` | `"none" \| "static"` — record-level legacy strategy |
98
+ | `ThemeFallback` | `TokenFallback \| "supports"` — stylesheet-level, default `"supports"` |
99
+ | `Rgba` | `{ r, g, b, a }`, all `0..1` |
100
+ | `MixSpace` | `"srgb" \| "srgb-linear" \| "oklab"` |
101
+ | `RolePairedKey` | Required: `"background" \| "muted" \| "surface"` |
102
+ | `RolePairedOptionalKey` | Optional: `"surface-1"` (all bundled themes ship it) |
89
103
 
90
104
  ### Constants
91
105
 
@@ -102,11 +116,14 @@ Entry points:
102
116
  2. Token naming: `--{prefix}color-{key}`, `--{prefix}color-{key}-hover`, etc.
103
117
  3. Prefix is auto-normalized — `"my"` and `"my-"` both produce `--my-color-*`. Empty string produces unprefixed.
104
118
  4. Hover/active auto-derived via `color-mix(in oklab, ...)` with a per-bucket strategy: **intent** colors (saturated brand) mix toward `black` / `white` 10%/20% (mode-aware); **role** colors (paired + single) mix toward `var(--{prefix}color-foreground)` 10%/20% for a softer, theme-tinted shift. `oklab` (rectangular) is used rather than `oklch` (polar) because polar hue interpolation collapses to a powerless hue on low-chroma colors mixed toward black/white, painting as a red/mauve shift on near-neutral themes. Disable with `deriveStates: false`.
105
- 5. Surface-intent tokens derived via `color-mix(in srgb, ...)` from intent + background. Foreground contrast defaults to 50% — tune via `surfaceForegroundContrast`. Note `surface-{intent}-border` mixes the intent color 30% **toward `background`** — it is the *outer* edge of an intent-tinted surface seen against the page, **not** an inner divider drawn on top of a surface.
119
+ 5. Surface-intent tokens derived via `color-mix(in srgb, ...)` from intent + background. Foreground contrast defaults to 50% — tune via `surfaceForegroundContrast`. Note `surface-{intent}-border` mixes the intent color 30% **toward `background`** — it is the _outer_ edge of an intent-tinted surface seen against the page, **not** an inner divider drawn on top of a surface.
106
120
  6. Theme files use `export default` with `ThemeSchema` type annotation. All bundled themes include an optional `surface-1` paired role (extra elevation).
107
121
  7. Formatting: tabs, 90-char line width, 4-space indent width (`deno fmt`)
108
122
  8. MCP handlers must return `Promise<string>` — framework contract from `jsr:@marianmeres/mcp-server/types`.
109
- 9. **Layering semantics for dividers/hairlines**: `border` is calibrated against the **page background / muted** layer (≈ background + one step) and is hand-authored per theme — it may deliberately equal a `surface` step (e.g. dark themes where `border` == `surface` == the same neutral), so it is **not** a safe divider drawn *on top of* a `surface`. For an on-surface hairline/divider use `surface-1` (one curated step from `surface` in both modes). There is intentionally no dedicated `surface-border` token — `surface-1` covers the case, and the name would collide with the outer-edge meaning of `surface-{intent}-border` (see #5).
123
+ 9. **`color-mix()` is a browser requirement, and as a token value it has no consumer-side remedy.** `color-mix()` needs Chrome/Edge 111+, Safari 16.2+, Firefox 113+. In a normal declaration that's harmless (parse-time rejection → earlier declaration wins). As a _custom property value_ the declaration parses everywhere and fails only at substitution, making the using declaration invalid at computed-value time → `unset` (transparent background / inherited text). Neither an earlier declaration nor `var(--token, #fallback)` helps, because the property is defined; its value is just unusable. Hence `generateThemeCss` defaults to `fallback: "supports"`, emitting precomputed literals in a trailing `@supports not (color: color-mix(in oklab, red, blue))` block. **The guard must stay a negated declaration test** — a bare `@supports (color-mix(...))` is the `<general-enclosed>` production, which the spec requires to evaluate to false everywhere, silently handing the fallback to modern engines too.
124
+ 10. **`deriveStates` is a design knob, not a compatibility one.** It flattens hover/active; it does NOT remove `color-mix()` (surface-`{intent}` is derived regardless, and author-supplied values pass through). Use `fallback: "static"` for `color-mix()`-free output.
125
+ 11. **Theme schemas must not contain `color-mix()`.** Use `rgba(color, alpha)` — exactly equivalent to `color-mix(in srgb, <color> <α>%, transparent)` because mixing with `transparent` is premultiplied, but with no browser requirement. A `color-mix()` in a schema propagates into that token's derived states too.
126
+ 12. **Layering semantics for dividers/hairlines**: `border` is calibrated against the **page background / muted** layer (≈ background + one step) and is hand-authored per theme — it may deliberately equal a `surface` step (e.g. dark themes where `border` == `surface` == the same neutral), so it is **not** a safe divider drawn _on top of_ a `surface`. For an on-surface hairline/divider use `surface-1` (one curated step from `surface` in both modes). There is intentionally no dedicated `surface-border` token — `surface-1` covers the case, and the name would collide with the outer-edge meaning of `surface-{intent}-border` (see #5).
110
127
 
111
128
  ## Adding a New Theme
112
129
 
@@ -124,8 +141,12 @@ Entry points:
124
141
  - [ ] Follow `deno fmt` conventions
125
142
  - [ ] Ensure new exports have JSDoc + explicit return types
126
143
  - [ ] If changing generator output (CSS text), note as potential BC change
144
+ - [ ] If changing derivation or color math, run `deno task css:build` and confirm the diff is what you expect
145
+ - [ ] Never emit `color-mix()` without a fallback path — tests assert the `@supports` block covers every `color-mix()` token exactly
127
146
 
128
147
  ## Known Limitations
129
148
 
130
149
  - **JSR does not support directory exports** → pre-built CSS is npm-only.
131
- - **Reboot `-rgb` companions** are omitted for tokens whose values are `color-mix()` expressions (auto-derived hovers). Provide explicit hex `hover` / `active` if you need full alpha-blend support through Reboot's `rgba(var(--bs-…-rgb), α)` rules.
150
+ - **Reboot `-rgb` companions** are omitted for tokens whose values are `color-mix()` expressions (auto-derived hovers). Pass `fallback: "static"` (or provide explicit hex `hover` / `active`) if you need full alpha-blend support through Reboot's `rgba(var(--bs-…-rgb), α)` rules.
151
+ - **Static fallback coverage is not total.** `evaluateColorExpression` handles `srgb` / `srgb-linear` / `oklab` interpolation over hex, `rgb()`, `hsl()`, `oklab()`, `oklch()` and the basic keywords. Author-supplied `color-mix(in oklch/hsl/lab, …)`, `currentColor`, and undefined variables are deliberately left unevaluated rather than approximated — those tokens are simply absent from the `@supports` block.
152
+ - **`cssLayer` has its own baseline**: `@layer` needs Chrome 99+, Safari 15.4+, Firefox 97+. Older engines drop the whole block, leaving tokens _undefined_ — which at least makes consumer-side `var(--token, #fallback)` fire.
package/API.md CHANGED
@@ -12,7 +12,8 @@ Generate CSS token key-value pairs from a single-mode token schema.
12
12
  - `prefix` (`string`) — CSS variable prefix. Auto-normalized: `"my"` and `"my-"` are equivalent. `""` produces unprefixed tokens.
13
13
  - `options` (`GenerateOptions | "light" | "dark"`, optional) — A mode string for quick switching, or an options object. Default: `{}`
14
14
  - `mode` (`"light" | "dark"`, optional) — Controls two things: (a) the `surface-{intent}-foreground` contrast mix (toward `black` in light, `white` in dark), and (b) the direction of **intent** hover/active derivation (toward `black` in light, `white` in dark). **Role** hover/active is mode-independent — it always mixes toward `--{prefix}color-foreground`, which itself flips per mode. Default: `"light"`
15
- - `deriveStates` (`boolean`, optional) — When `false`, missing hover/active fall back to DEFAULT instead of being derived via `color-mix()`. Useful for environments without `color-mix()` support. Default: `true`
15
+ - `deriveStates` (`boolean`, optional) — When `false`, missing hover/active fall back to DEFAULT instead of being derived via `color-mix()` — i.e. flat tokens with no visual state change. This is a _design_ knob, **not** a compatibility one: it does not remove `color-mix()` from the output, because `surface-{intent}` tokens are derived regardless and author-supplied `color-mix()` values pass through verbatim. Use `fallback` for that. Default: `true`
16
+ - `fallback` (`TokenFallback`, optional) — `"static"` evaluates every `color-mix()` at build time so the output contains none; `"none"` emits them as-is. Default: `"none"`
16
17
  - `surfaceForegroundContrast` (`number`, optional) — Mix percentage (0–100) used to derive the `surface-{intent}-foreground` token. Higher values increase contrast against the tinted surface background. Default: `50`
17
18
 
18
19
  **Returns:** `GeneratedTokens` — Key-value record of token names to CSS values (keys without the `--` prefix)
@@ -70,10 +71,11 @@ Generate complete CSS for a theme with light mode and optional dark mode.
70
71
  - `schema` (`ThemeSchema`) — Theme with required `light` and optional `dark` token schemas
71
72
  - `prefix` (`string`) — CSS variable prefix (trailing dash optional)
72
73
  - `options` (`GenerateThemeOptions`, optional) — Accepts all `GenerateOptions` (except `mode`, which is set per section) plus:
73
- - `cssLayer` (`string`, optional) — Wrap the output in `@layer {name} { ... }`. Useful for CSS cascade control.
74
+ - `fallback` (`ThemeFallback`, optional) — How to serve engines without `color-mix()` (Chrome/Edge < 111, Safari < 16.2, Firefox < 113). `"supports"` emits normal output plus a trailing `@supports not (color: color-mix(in oklab, red, blue))` block holding precomputed literals; `"static"` precomputes everything and emits no `color-mix()` at all; `"none"` is the raw output. Default: `"supports"`
75
+ - `cssLayer` (`string`, optional) — Wrap the output in `@layer {name} { ... }`. Useful for CSS cascade control. Note `@layer` itself needs Chrome 99+, Safari 15.4+, Firefox 97+.
74
76
  - `prettierIgnore` (`boolean`, optional) — Prepend a `/* prettier-ignore */` comment so Prettier preserves the generated whitespace alignment when the output is written to a file that later gets formatted. Default: `false`.
75
77
 
76
- **Returns:** `string` — Complete CSS with `:root` for light and `:root.dark` for dark mode, optionally wrapped in `@layer`.
78
+ **Returns:** `string` — Complete CSS with `:root` for light and `:root.dark` for dark mode, a trailing `@supports not (...)` fallback block unless disabled, optionally wrapped in `@layer`.
77
79
 
78
80
  **Example:**
79
81
 
@@ -132,12 +134,141 @@ hexToRgbTriplet("#2563eb"); // → "37, 99, 235"
132
134
 
133
135
  ---
134
136
 
137
+ ### `rgba(color, alpha)`
138
+
139
+ Build an `rgba()` string from a color and an alpha value — the `color-mix()`-free way to write a translucent tint in a schema.
140
+
141
+ Exactly equivalent to `color-mix(in srgb, <color> <alpha*100>%, transparent)`: mixing with `transparent` interpolates premultiplied, so only alpha changes. Unlike the `color-mix()` form it carries no browser requirement.
142
+
143
+ **Parameters:**
144
+
145
+ - `color` (`string`) — Any color `parseColor` understands
146
+ - `alpha` (`number`) — Alpha in `0..1`
147
+
148
+ **Returns:** `string` — `rgba(r, g, b, a)`, or `#rrggbb` when the result is opaque
149
+
150
+ **Throws:** `TypeError` if `color` cannot be parsed
151
+
152
+ **Example:**
153
+
154
+ ```typescript
155
+ import { colors, rgba } from "@marianmeres/design-tokens";
156
+
157
+ rgba(colors.blue[600], 0.25); // → "rgba(37, 99, 235, 0.25)"
158
+ ```
159
+
160
+ ---
161
+
162
+ ### `parseColor(value)`
163
+
164
+ Parse a CSS color into an `Rgba`. Supports hex (3/4/6/8 digit), `rgb()`/`rgba()`, `hsl()`/`hsla()`, `oklab()`, `oklch()`, and the basic color keywords, in both legacy comma and modern space/slash syntax.
165
+
166
+ Values that depend on inherited or computed state (`currentColor`, `var()`, system colors) return `null` — they are not statically resolvable.
167
+
168
+ **Returns:** `Rgba | null`
169
+
170
+ ---
171
+
172
+ ### `formatColor(color)`
173
+
174
+ Serialize an `Rgba` to the most compatible CSS form: `#rrggbb` when opaque, legacy `rgba(r, g, b, a)` otherwise. Out-of-gamut components are clamped.
175
+
176
+ **Returns:** `string`
177
+
178
+ ---
179
+
180
+ ### `mixColors(space, c1, p1, c2, p2)`
181
+
182
+ Mix two colors the way `color-mix()` does, following CSS Color 5 percentage normalization (an omitted percentage is `100 - other`; both omitted is 50/50; a sum below 100 scales the result alpha) and premultiplied-alpha interpolation.
183
+
184
+ **Parameters:**
185
+
186
+ - `space` (`MixSpace`) — `"srgb"`, `"srgb-linear"`, or `"oklab"`
187
+ - `c1`, `c2` (`Rgba`) — The colors
188
+ - `p1`, `p2` (`number | null`) — Percentages `0..100`, or `null` when omitted
189
+
190
+ **Returns:** `Rgba | null` — `null` when both percentages are zero (invalid per spec)
191
+
192
+ ---
193
+
194
+ ### `evaluateColorExpression(expr, lookup?)`
195
+
196
+ Evaluate a CSS color expression to a literal color, resolving `var()` references through `lookup` and computing nested `color-mix()` calls.
197
+
198
+ Returns `null` when any operand is not statically resolvable — an undefined variable with no fallback, an unsupported interpolation space (`oklch`, `hsl`, `lab`, …), `currentColor`, or a reference cycle. Treat `null` as "leave the original expression alone": approximating a mix that isn't modelled exactly would be worse than omitting the fallback.
199
+
200
+ **Parameters:**
201
+
202
+ - `expr` (`string`) — e.g. `color-mix(in oklab, var(--x), black 10%)`
203
+ - `lookup` (`VarLookup`, optional) — Resolves custom property names (without `--`) to their declared values
204
+
205
+ **Returns:** `Rgba | null`
206
+
207
+ **Example:**
208
+
209
+ ```typescript
210
+ import { evaluateColorExpression, formatColor } from "@marianmeres/design-tokens";
211
+
212
+ const tokens: Record<string, string> = { "app-color-primary": "#27272a" };
213
+ const c = evaluateColorExpression(
214
+ "color-mix(in oklab, var(--app-color-primary), black 10%)",
215
+ (name) => tokens[name],
216
+ );
217
+ formatColor(c!); // → "#202023"
218
+ ```
219
+
220
+ ---
221
+
222
+ ### `resolveStaticTokens(tokens)`
223
+
224
+ Evaluate every `color-mix()` expression in a token record at build time, returning a record of the same shape with literal colors in their place.
225
+
226
+ Only values containing `color-mix()` are rewritten — plain `var()` indirection is preserved, since it needs no fallback and flattening it would defeat the Reboot bridge. Values whose operands cannot be resolved statically pass through unchanged.
227
+
228
+ **Returns:** `GeneratedTokens`
229
+
230
+ ---
231
+
232
+ ### `resolveStaticOverrides(tokens)`
233
+
234
+ The subset of `tokens` whose `color-mix()` values could be precomputed, mapped to their literal equivalents. Tokens without `color-mix()`, and those whose operands are not statically resolvable, are absent.
235
+
236
+ Useful on its own to audit which tokens carry a `color-mix()` browser requirement, and which of those the generator can cover.
237
+
238
+ **Returns:** `GeneratedTokens`
239
+
240
+ **Example:**
241
+
242
+ ```typescript
243
+ import { generateCssTokens, resolveStaticOverrides } from "@marianmeres/design-tokens";
244
+ import { zinc } from "@marianmeres/design-tokens/themes";
245
+
246
+ const tokens = generateCssTokens(zinc.light, "app-");
247
+ Object.keys(resolveStaticOverrides(tokens)); // every token needing color-mix support
248
+ ```
249
+
250
+ ---
251
+
252
+ ### `staticFallbackCss(entries)`
253
+
254
+ Build the `@supports not (color: color-mix(in oklab, red, blue))` block holding precomputed literals for every `color-mix()`-valued token in `entries`. Emit it _after_ the blocks it backs up — `@supports` adds no specificity, so source order is what makes it win.
255
+
256
+ **Parameters:**
257
+
258
+ - `entries` (`{ selector: string; tokens: GeneratedTokens }[]`) — Selector / token-record pairs, in the order they are emitted
259
+
260
+ **Returns:** `string | null` — The CSS block, or `null` when nothing needs a fallback
261
+
262
+ ---
263
+
135
264
  ### `generateRebootBridge(tokens, prefix)`
136
265
 
137
266
  _Import from `@marianmeres/design-tokens/reboot`_
138
267
 
139
268
  Map design tokens to Bootstrap Reboot `--bs-*` CSS variables. Automatically generates `-rgb` triplet companions for hex color values.
140
269
 
270
+ Bootstrap derives alpha variants via `rgba(var(--bs-foo-rgb), α)`, which needs a raw `r, g, b` triplet rather than a `var()` reference — so companions are omitted for tokens whose value is a `color-mix()` expression. Generating the tokens with `fallback: "static"` makes every value a literal and closes that gap.
271
+
141
272
  **Parameters:**
142
273
 
143
274
  - `tokens` (`GeneratedTokens`) — Generated token pairs (from `generateCssTokens`)
@@ -159,9 +290,9 @@ Generate complete themed CSS including both design tokens AND the Bootstrap Rebo
159
290
 
160
291
  - `schema` (`ThemeSchema`) — Theme schema with light and optional dark modes
161
292
  - `prefix` (`string`) — CSS variable prefix (trailing dash optional)
162
- - `options` (`Omit<GenerateThemeOptions, "cssLayer">`, optional) — Forwarded to `generateCssTokens` for each mode (see `generateCssTokens`)
293
+ - `options` (`Omit<GenerateThemeOptions, "cssLayer">`, optional) — Forwarded to `generateCssTokens` for each mode (see `generateCssTokens`). `fallback` defaults to `"supports"`, as in `generateThemeCss`; `"static"` additionally completes the `--bs-*-rgb` companions.
163
294
 
164
- **Returns:** `string` — Combined CSS with token variables and `--bs-*` bridge variables
295
+ **Returns:** `string` — Combined CSS with token variables and `--bs-*` bridge variables, plus a trailing `@supports not (...)` fallback block unless disabled
165
296
 
166
297
  **Example:**
167
298
 
@@ -278,6 +409,7 @@ Either a plain CSS color string or an object with optional hover/active states.
278
409
  type GenerateOptions = {
279
410
  mode?: "light" | "dark";
280
411
  deriveStates?: boolean;
412
+ fallback?: TokenFallback;
281
413
  surfaceForegroundContrast?: number;
282
414
  };
283
415
  ```
@@ -289,13 +421,66 @@ Options passed to `generateCssTokens`. See the function docs above for field mea
289
421
  ### `GenerateThemeOptions`
290
422
 
291
423
  ```typescript
292
- type GenerateThemeOptions = Omit<GenerateOptions, "mode"> & {
424
+ type GenerateThemeOptions = Omit<GenerateOptions, "mode" | "fallback"> & {
425
+ fallback?: ThemeFallback;
293
426
  cssLayer?: string;
294
427
  prettierIgnore?: boolean;
295
428
  };
296
429
  ```
297
430
 
298
- Options passed to `generateThemeCss`. Omits `mode` (set automatically per section) and adds `cssLayer` for optional `@layer` wrapping plus `prettierIgnore` to prepend a `/* prettier-ignore */` pragma for build pipelines that pass the output through Prettier.
431
+ Options passed to `generateThemeCss`. Omits `mode` (set automatically per section), widens `fallback` to include `"supports"`, and adds `cssLayer` for optional `@layer` wrapping plus `prettierIgnore` to prepend a `/* prettier-ignore */` pragma for build pipelines that pass the output through Prettier.
432
+
433
+ ---
434
+
435
+ ### `TokenFallback`
436
+
437
+ ```typescript
438
+ type TokenFallback = "none" | "static";
439
+ ```
440
+
441
+ Legacy-engine strategy for a token _record_. `"none"` emits `color-mix()` expressions as-is; `"static"` evaluates them at build time so the record contains only literal colors.
442
+
443
+ ---
444
+
445
+ ### `ThemeFallback`
446
+
447
+ ```typescript
448
+ type ThemeFallback = TokenFallback | "supports";
449
+ ```
450
+
451
+ Legacy-engine strategy for a _stylesheet_. Adds `"supports"`: emit the `color-mix()` values normally, then repeat the affected tokens as precomputed literals inside `@supports not (color: color-mix(in oklab, red, blue))`. Modern engines are unaffected — the base block is byte-for-byte identical to `"none"` — while older ones get working colors.
452
+
453
+ The guard is a _negated declaration test_ on purpose. A bare `@supports (color-mix(...))` is the `<general-enclosed>` production, which the spec requires to evaluate to false in every browser, so it would hand the fallback to modern engines too.
454
+
455
+ ---
456
+
457
+ ### `Rgba`
458
+
459
+ ```typescript
460
+ type Rgba = { r: number; g: number; b: number; a: number };
461
+ ```
462
+
463
+ A color with red/green/blue components in `0..1` (gamma-encoded sRGB) and alpha in `0..1`.
464
+
465
+ ---
466
+
467
+ ### `MixSpace`
468
+
469
+ ```typescript
470
+ type MixSpace = "srgb" | "srgb-linear" | "oklab";
471
+ ```
472
+
473
+ Interpolation color spaces understood by `mixColors`. Deliberately narrow — these are the spaces the generator emits. Author-supplied `color-mix()` in any other space (`oklch`, `hsl`, `lab`, …) is left unevaluated rather than approximated.
474
+
475
+ ---
476
+
477
+ ### `VarLookup`
478
+
479
+ ```typescript
480
+ type VarLookup = (name: string) => string | undefined;
481
+ ```
482
+
483
+ Resolves a custom property name (without the leading `--`) to its declared value.
299
484
 
300
485
  ---
301
486
 
package/README.md CHANGED
@@ -10,6 +10,15 @@ bridge that maps tokens to `--bs-*` variables. Same mental model as
10
10
  [@marianmeres/stuic](https://github.com/marianmeres/stuic)'s token system, but
11
11
  framework-agnostic — no Tailwind dependency.
12
12
 
13
+ ## Browser support
14
+
15
+ Generated themes work everywhere. Derived tokens use `color-mix()`, which needs
16
+ **Chrome/Edge 111+, Safari 16.2+, Firefox 113+** (all early-to-mid 2023) — so
17
+ `generateThemeCss` also emits a precomputed fallback for older engines by default. See
18
+ [Legacy browsers](#legacy-browsers) if you want to change that, and
19
+ [Why the fallback matters](#why-the-fallback-matters) for why it is not something a
20
+ consumer could patch downstream.
21
+
13
22
  ## Install
14
23
 
15
24
  ```sh
@@ -52,7 +61,7 @@ const theme: ThemeSchema = {
52
61
  // *on* a surface. Use `surface-1` for on-surface dividers.
53
62
  border: { DEFAULT: "#d4d4d4" },
54
63
  input: { DEFAULT: "#ffffff" },
55
- ring: "color-mix(in srgb, #2563eb 25%, transparent)",
64
+ ring: "rgba(37, 99, 235, 0.25)",
56
65
  },
57
66
  },
58
67
  },
@@ -72,6 +81,9 @@ All bundled themes are available as pre-built CSS files with the `stuic-` prefix
72
81
  <link rel="stylesheet" href="node_modules/@marianmeres/design-tokens/css/mauve-teal.css">
73
82
  ```
74
83
 
84
+ These are built with the default `fallback: "supports"`, so they render correctly on
85
+ pre-2023 engines without any work on your side.
86
+
75
87
  Or via bundler import:
76
88
 
77
89
  ```ts
@@ -83,7 +95,7 @@ import "@marianmeres/design-tokens/css/mauve-teal.css";
83
95
  Use the bundled `colors` map (Tailwind palette including v4.2 additions) to avoid looking up hex values:
84
96
 
85
97
  ```ts
86
- import { colors, generateThemeCss } from "@marianmeres/design-tokens";
98
+ import { colors, generateThemeCss, rgba } from "@marianmeres/design-tokens";
87
99
  import type { ThemeSchema } from "@marianmeres/design-tokens";
88
100
 
89
101
  const myTheme: ThemeSchema = {
@@ -119,7 +131,7 @@ const myTheme: ThemeSchema = {
119
131
  foreground: colors.slate[900],
120
132
  border: { DEFAULT: colors.slate[300] },
121
133
  input: { DEFAULT: colors.slate[50], hover: colors.slate[100] },
122
- ring: `color-mix(in srgb, ${colors.blue[600]} 25%, transparent)`,
134
+ ring: rgba(colors.blue[600], 0.25),
123
135
  },
124
136
  },
125
137
  },
@@ -133,6 +145,11 @@ const css = generateThemeCss(myTheme, "my-");
133
145
  Hover/active states are auto-derived via `color-mix(in oklab, ...)` when omitted. You can
134
146
  provide explicit values to override the derivation.
135
147
 
148
+ The `rgba()` helper is the `color-mix()`-free way to write a translucent tint. It is
149
+ exactly equivalent to `color-mix(in srgb, <color> 25%, transparent)` — mixing with
150
+ `transparent` is premultiplied, so only alpha changes — but it needs no modern-browser
151
+ support and it keeps your schema free of expressions the generator has to work around.
152
+
136
153
  ## Bundled themes
137
154
 
138
155
  ```ts
@@ -159,12 +176,69 @@ if (theme) {
159
176
 
160
177
  ```ts
161
178
  generateThemeCss(zinc, "app-", {
162
- deriveStates: false, // disable color-mix hover/active derivation
179
+ fallback: "supports", // legacy-browser strategy (default) — see below
180
+ deriveStates: false, // flat hover/active instead of derived states
163
181
  surfaceForegroundContrast: 70, // stronger contrast on surface-{intent}-foreground
164
182
  cssLayer: "tokens", // wrap output in @layer tokens { ... }
165
183
  });
166
184
  ```
167
185
 
186
+ Note that `deriveStates: false` is a _design_ knob — it makes hover/active flat, but does
187
+ not remove `color-mix()` from the output (`surface-{intent}` tokens are derived regardless,
188
+ and author-supplied `color-mix()` values pass through). Use `fallback` for that.
189
+
190
+ ## Legacy browsers
191
+
192
+ `color-mix()` needs Chrome/Edge 111+, Safari 16.2+, Firefox 113+. The `fallback` option
193
+ controls what older engines get:
194
+
195
+ | `fallback` | Output |
196
+ | --------------------- | ------------------------------------------------------------------------------------ |
197
+ | `"supports"`(default) | Normal output, plus a trailing `@supports not (...)` block with precomputed literals |
198
+ | `"static"` | Every `color-mix()` evaluated at build time — the output contains none at all |
199
+ | `"none"` | `color-mix()` only; breaks on older engines |
200
+
201
+ ```css
202
+ /* fallback: "supports" */
203
+ :root {
204
+ --app-color-primary: #27272a;
205
+ --app-color-primary-hover: color-mix(in oklab, var(--app-color-primary), black 10%);
206
+ }
207
+ @supports not (color: color-mix(in oklab, red, blue)) {
208
+ :root {
209
+ --app-color-primary-hover: #202023;
210
+ }
211
+ }
212
+ ```
213
+
214
+ `"supports"` is the default because it is purely additive — the `:root` block is
215
+ byte-for-byte what `"none"` produces, so modern rendering cannot shift — at a cost of
216
+ roughly +28% output size (about +0.6 KB gzipped per theme).
217
+
218
+ Pick `"static"` if you want the smallest output (it is ~35% _smaller_ than `"none"`, since
219
+ hex literals are shorter than mix expressions) and you don't need derived tokens to track
220
+ runtime overrides of their base color. It also completes the Reboot bridge's `--bs-*-rgb`
221
+ companions, which can't exist while token values are expressions.
222
+
223
+ Values the generator cannot evaluate at build time — an author-supplied `oklch`/`hsl`
224
+ interpolation space, `currentColor`, an undefined variable — are left as-is rather than
225
+ approximated, so they are simply absent from the fallback block.
226
+
227
+ ### Why the fallback matters
228
+
229
+ `color-mix()` in an ordinary declaration is harmless on old browsers: the declaration is
230
+ rejected at parse time and an earlier one wins, which is the standard progressive-
231
+ enhancement pattern. As a **token value** it behaves differently. Custom properties accept
232
+ almost any token sequence, so the declaration parses everywhere and only fails at
233
+ substitution — making the _using_ declaration invalid at computed-value time, which
234
+ resolves the property to `unset`: transparent for a background, inherited for `color`.
235
+
236
+ That failure is unreachable from consumer CSS. It happens after the cascade has picked a
237
+ winner, so an earlier declaration is not a fallback, and `var(--token, #333)` doesn't fire
238
+ either — the property is defined, its value is just unusable. The result is a control that
239
+ keeps its size, position, and hit area and simply stops being visible. Hence the fix lives
240
+ here rather than in consumer stylesheets.
241
+
168
242
  ## Reboot bridge
169
243
 
170
244
  Maps design tokens to Bootstrap Reboot's `--bs-*` CSS variables:
@@ -177,6 +251,11 @@ const css = generateThemedCss(zinc, "app-");
177
251
  // → :root { --app-color-primary: ...; --bs-body-bg: ...; --bs-link-color: ...; }
178
252
  ```
179
253
 
254
+ Takes the same `fallback` option, defaulting to `"supports"`. Bootstrap derives alpha
255
+ variants via `rgba(var(--bs-foo-rgb), α)`, which needs a raw `r, g, b` triplet — so
256
+ `--bs-*-rgb` companions are only emitted for tokens that resolve to a literal color. Pass
257
+ `fallback: "static"` if you need them all.
258
+
180
259
  ## API
181
260
 
182
261
  See [API.md](API.md) for the complete API documentation.
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Color parsing and mixing math.
3
+ *
4
+ * Exists so the generator can evaluate its own `color-mix()` expressions at
5
+ * build time and emit literal colors for engines that predate `color-mix()`
6
+ * (Chrome/Edge < 111, Safari < 16.2, Firefox < 113). See the `fallback` option
7
+ * in {@link ./generate.ts}.
8
+ *
9
+ * Everything here mirrors CSS Color 4/5 semantics: premultiplied-alpha
10
+ * interpolation, the `color-mix()` percentage normalization rules, and the
11
+ * OKLab transfer functions.
12
+ *
13
+ * @module
14
+ */
15
+ /**
16
+ * A color with red/green/blue components in `0..1` (gamma-encoded sRGB) and
17
+ * alpha in `0..1`.
18
+ */
19
+ export type Rgba = {
20
+ r: number;
21
+ g: number;
22
+ b: number;
23
+ a: number;
24
+ };
25
+ /**
26
+ * Interpolation color spaces understood by {@link mixColors}.
27
+ *
28
+ * Deliberately narrow — these are the spaces the generator emits. Author-
29
+ * supplied `color-mix()` in any other space (`oklch`, `hsl`, `lab`, …) is left
30
+ * unevaluated rather than approximated; see {@link evaluateColorExpression}.
31
+ */
32
+ export type MixSpace = "srgb" | "srgb-linear" | "oklab";
33
+ /**
34
+ * Parse a CSS color into {@link Rgba}. Returns null for anything not
35
+ * statically resolvable.
36
+ *
37
+ * Supported: hex (3/4/6/8 digit), `rgb()`/`rgba()`, `hsl()`/`hsla()`,
38
+ * `oklab()`, `oklch()`, and the basic color keywords. Values that depend on
39
+ * inherited or computed state (`currentColor`, `var()`, system colors) are
40
+ * intentionally NOT supported here — see {@link evaluateColorExpression} for
41
+ * `var()` resolution.
42
+ */
43
+ export declare function parseColor(value: string): Rgba | null;
44
+ /**
45
+ * Serialize to the most compatible CSS form: `#rrggbb` when fully opaque,
46
+ * legacy `rgba(r, g, b, a)` otherwise. Legacy `rgba()` is deliberate — the
47
+ * whole point of the static fallback is reaching engines that predate the
48
+ * modern space-separated syntax.
49
+ */
50
+ export declare function formatColor(color: Rgba): string;
51
+ /**
52
+ * Mix two colors the way `color-mix()` does.
53
+ *
54
+ * Percentages follow CSS Color 5 normalization: an omitted percentage is
55
+ * `100 - other`, both omitted is 50/50, and when the two sum to less than 100
56
+ * the result's alpha is scaled by that sum. Interpolation is premultiplied by
57
+ * alpha, which is why mixing an opaque color with `transparent` preserves the
58
+ * color and only lowers alpha.
59
+ *
60
+ * @param space - Interpolation space
61
+ * @param c1 - First color
62
+ * @param p1 - First percentage (0-100), or null when omitted
63
+ * @param c2 - Second color
64
+ * @param p2 - Second percentage (0-100), or null when omitted
65
+ * @returns The mixed color, or null if both percentages are zero (invalid)
66
+ */
67
+ export declare function mixColors(space: MixSpace, c1: Rgba, p1: number | null, c2: Rgba, p2: number | null): Rgba | null;
68
+ /** Look up the value of a custom property by its name, without the leading `--`. */
69
+ export type VarLookup = (name: string) => string | undefined;
70
+ /**
71
+ * Evaluate a CSS color expression to a literal color, resolving `var()`
72
+ * references through `lookup` and computing nested `color-mix()` calls.
73
+ *
74
+ * Returns null when any operand is not statically resolvable — an undefined
75
+ * variable with no fallback, an unsupported interpolation space (`oklch`,
76
+ * `hsl`, `lab`, …), `currentColor`, a reference cycle, and so on. Callers
77
+ * should treat null as "leave the original expression alone" rather than as an
78
+ * error; approximating a mix we don't model exactly would be worse than
79
+ * omitting the fallback.
80
+ *
81
+ * @param expr - The expression, e.g. `color-mix(in oklab, var(--x), black 10%)`
82
+ * @param lookup - Resolves custom property names to their declared values
83
+ */
84
+ export declare function evaluateColorExpression(expr: string, lookup?: VarLookup): Rgba | null;
85
+ /**
86
+ * Build an `rgba()` string from an opaque color and an alpha value.
87
+ *
88
+ * Provided for theme authors: `rgba(colors.blue[600], 0.25)` is exactly
89
+ * equivalent to `color-mix(in srgb, <blue-600> 25%, transparent)` (mixing with
90
+ * `transparent` is premultiplied, so only alpha changes) but carries no
91
+ * `color-mix()` browser requirement.
92
+ *
93
+ * @param color - Any color {@link parseColor} understands
94
+ * @param alpha - Alpha in `0..1`
95
+ * @throws If `color` cannot be parsed
96
+ */
97
+ export declare function rgba(color: string, alpha: number): string;