@marianmeres/design-tokens 1.14.0 → 1.16.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.
package/AGENTS.md CHANGED
@@ -59,6 +59,7 @@ Entry points:
59
59
  | `generateCssTokens(schema, prefix, options?)` | Generate token key-value pairs. Third arg: `GenerateOptions` object or `"light"`/`"dark"` shorthand |
60
60
  | `toCssString(tokens, selector?)` | Convert tokens to a CSS declaration block (groups by base color name) |
61
61
  | `generateThemeCss(schema, prefix, options?)` | Generate complete CSS for light + optional dark mode; supports `fallback`, `cssLayer` wrapping and `prettierIgnore` pragma |
62
+ | `mergeThemeSchema(base, overrides)` | Merge sparse `ThemeSchemaOverrides` over a complete base theme; replaces whole entries, never mints keys the base lacks |
62
63
  | `resolveStaticTokens(tokens)` | Evaluate every `color-mix()` in a record to a literal color |
63
64
  | `resolveStaticOverrides(tokens)` | Only the tokens that carried a `color-mix()`, precomputed — use to audit legacy exposure |
64
65
  | `staticFallbackCss(entries)` | Build the `@supports not (...)` block backing up a set of `{selector, tokens}` blocks |
@@ -87,7 +88,9 @@ Entry points:
87
88
  | Type | Purpose |
88
89
  | ----------------------- | ----------------------------------------------------------------------------------------- |
89
90
  | `ThemeSchema` | `{ light: TokenSchema; dark?: TokenSchema }` |
90
- | `TokenSchema` | Intent colors, role paired/single colors |
91
+ | `TokenSchema` | Intent colors, role paired/single colors — **complete** by contract (see convention 13) |
92
+ | `ThemeSchemaOverrides` | `{ light?, dark? }` sparse overrides for `mergeThemeSchema`; never passed to generators |
93
+ | `TokenSchemaOverrides` | Single-mode sparse overrides; no index signatures, so typo'd keys are compile errors |
91
94
  | `ColorPair` | `{ DEFAULT, foreground, hover?, active?, foregroundHover?, foregroundActive? }` |
92
95
  | `ColorValue` | `{ DEFAULT, hover?, active? }` |
93
96
  | `SingleColor` | `string \| ColorValue` |
@@ -124,6 +127,7 @@ Entry points:
124
127
  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
128
  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
129
  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).
130
+ 13. **Complete schema in, self-standing stylesheet out.** The generator rejects — with an error naming the schema path — any schema whose output would reference a token it does not declare: the five containers, non-string `DEFAULT`/`foreground` leaves, and the two derived operands (`colors.role.paired.background` when intent colors are present; `colors.role.single.foreground` when role hover/active derivation fires). Key-completeness beyond that stays the type's job — a self-consistent minimal schema still generates. Sparse "override" schemas are rejected by design: layering a sparse generated stylesheet over a base un-repairs the base's `@supports` legacy fallback (the override's `color-mix()` re-declaration wins the cascade, and the override's own fallback block cannot resolve operands it does not carry — see #9 for why there is no consumer-side remedy). The supported override path is `mergeThemeSchema(base, overrides)` → generate one stylesheet.
127
131
 
128
132
  ## Adding a New Theme
129
133
 
@@ -143,6 +147,7 @@ Entry points:
143
147
  - [ ] If changing generator output (CSS text), note as potential BC change
144
148
  - [ ] If changing derivation or color math, run `deno task css:build` and confirm the diff is what you expect
145
149
  - [ ] Never emit `color-mix()` without a fallback path — tests assert the `@supports` block covers every `color-mix()` token exactly
150
+ - [ ] Never emit a `var()` reference to a token the same stylesheet does not declare — the generator enforces this for the two derived operands (convention 13); tests audit the full declared-vs-referenced set
146
151
 
147
152
  ## Known Limitations
148
153
 
package/API.md CHANGED
@@ -4,7 +4,9 @@
4
4
 
5
5
  ### `generateCssTokens(schema, prefix, options?)`
6
6
 
7
- Generate CSS token key-value pairs from a single-mode token schema.
7
+ Generate CSS token key-value pairs from a complete single-mode token schema.
8
+
9
+ The schema must be complete — the generator throws an error naming the offending schema path when a container or required value is missing, or when the output would reference a token it never declares (`colors.role.paired.background` when intent colors are present; `colors.role.single.foreground` when role hover/active states are derived). To override parts of an existing theme, use [`mergeThemeSchema`](#mergethemeschemabase-overrides) instead of passing a partial schema.
8
10
 
9
11
  **Parameters:**
10
12
 
@@ -13,7 +15,7 @@ Generate CSS token key-value pairs from a single-mode token schema.
13
15
  - `options` (`GenerateOptions | "light" | "dark"`, optional) — A mode string for quick switching, or an options object. Default: `{}`
14
16
  - `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
17
  - `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"`
18
+ - `fallback` (`TokenFallback`, optional) — `"static"` evaluates statically resolvable `color-mix()` expressions at build time and emits literal colors; expressions whose operands cannot be resolved at build time (e.g. author-supplied `var()` refs to variables declared elsewhere) pass through unchanged. `"none"` emits them as-is. Default: `"none"`
17
19
  - `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`
18
20
 
19
21
  **Returns:** `GeneratedTokens` — Key-value record of token names to CSS values (keys without the `--` prefix)
@@ -92,6 +94,37 @@ const css = generateThemeCss(zinc, "app-", { cssLayer: "tokens" });
92
94
 
93
95
  ---
94
96
 
97
+ ### `mergeThemeSchema(base, overrides)`
98
+
99
+ Merge sparse overrides over a complete base theme, returning a new complete `ThemeSchema` — the supported way to express "bundled theme X, but primary is indigo". The base is never mutated; an absent or empty override mode yields the base mode unchanged.
100
+
101
+ The merge recurses through the containers (`light`/`dark` → `colors` → `intent`/`role` → `paired`/`single`) and **replaces whole entries** — an overridden `ColorPair` or `SingleColor` supersedes the base entry entirely, never merges into it, so a changed `DEFAULT` cannot silently inherit a stale explicit `hover` or a `foreground` that no longer contrasts. To keep parts of a base entry, spread it yourself: `{ ...base.light.colors.intent.primary, DEFAULT: "#4f46e5" }`.
102
+
103
+ **Parameters:**
104
+
105
+ - `base` (`ThemeSchema`) — A complete theme, e.g. a bundled theme from `./themes`
106
+ - `overrides` (`ThemeSchemaOverrides`) — Sparse overrides. Keys must already exist in the base (typo protection — a key the base does not have throws instead of minting junk tokens); non-empty `dark` overrides require the base to have a dark mode (an empty `dark: {}` is a no-op against any base). To change the key set, author it on the base schema object.
107
+
108
+ **Returns:** `ThemeSchema` — A new complete theme, ready for `generateThemeCss`
109
+
110
+ **Example:**
111
+
112
+ ```typescript
113
+ import { generateThemeCss, mergeThemeSchema } from "@marianmeres/design-tokens";
114
+ import { amberOliveSafari } from "@marianmeres/design-tokens/themes";
115
+
116
+ const theme = mergeThemeSchema(amberOliveSafari, {
117
+ light: {
118
+ colors: {
119
+ intent: { primary: { DEFAULT: "#4f46e5", foreground: "#ffffff" } },
120
+ },
121
+ },
122
+ });
123
+ const css = generateThemeCss(theme, "app-");
124
+ ```
125
+
126
+ ---
127
+
95
128
  ### `hexToRgb(hex)`
96
129
 
97
130
  Parse a hex color string to RGB components. Supports 3-digit (`#abc`) and 6-digit (`#aabbcc`) formats, with or without `#` prefix.
@@ -358,7 +391,40 @@ type TokenSchema = {
358
391
  };
359
392
  ```
360
393
 
361
- Single-mode token schema. Intent colors define primary UI actions; role colors define structural UI elements. Arbitrary string keys are allowed on every collection (for custom additions).
394
+ Complete single-mode token schema. Intent colors define primary UI actions; role colors define structural UI elements. Arbitrary string keys are allowed on every collection (for custom additions).
395
+
396
+ Completeness is load-bearing, not ceremonial: the generator derives tokens whose values reference `--{prefix}color-background` (every `surface-{intent}` token) and `--{prefix}color-foreground` (role hover/active derivation), and only `role.paired.background` / `role.single.foreground` declare those. A sparse schema would generate a stylesheet that references tokens it never declares, so the generator rejects it. To override parts of an existing theme, use [`mergeThemeSchema`](#mergethemeschemabase-overrides) with a `ThemeSchemaOverrides`.
397
+
398
+ ---
399
+
400
+ ### `ThemeSchemaOverrides`
401
+
402
+ ```typescript
403
+ type ThemeSchemaOverrides = {
404
+ light?: TokenSchemaOverrides;
405
+ dark?: TokenSchemaOverrides;
406
+ };
407
+ ```
408
+
409
+ Sparse theme overrides for `mergeThemeSchema` — **not** accepted by `generateThemeCss`; merge it over a complete `ThemeSchema` first. An absent or empty mode leaves the base mode unchanged.
410
+
411
+ ---
412
+
413
+ ### `TokenSchemaOverrides`
414
+
415
+ ```typescript
416
+ type TokenSchemaOverrides = {
417
+ colors?: {
418
+ intent?: Partial<Record<IntentColorKey, ColorPair>>;
419
+ role?: {
420
+ paired?: Partial<Record<RolePairedKey | RolePairedOptionalKey, ColorPair>>;
421
+ single?: Partial<Record<RoleSingleKey, SingleColor>>;
422
+ };
423
+ };
424
+ };
425
+ ```
426
+
427
+ Sparse single-mode overrides for `mergeThemeSchema` — **not** accepted by `generateCssTokens`. Deliberately carries no index signatures (unlike `TokenSchema`), so a typo'd key in an override literal is a compile error instead of a silently minted junk token. To introduce a genuinely new key, author it on the base schema object (e.g. `{ ...base.light.colors.intent, info: … }`).
362
428
 
363
429
  ---
364
430
 
@@ -438,7 +504,7 @@ Options passed to `generateThemeCss`. Omits `mode` (set automatically per sectio
438
504
  type TokenFallback = "none" | "static";
439
505
  ```
440
506
 
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.
507
+ Legacy-engine strategy for a token _record_. `"none"` emits `color-mix()` expressions as-is; `"static"` evaluates statically resolvable ones at build time so the record contains literal colors — expressions whose operands cannot be resolved at build time pass through unchanged.
442
508
 
443
509
  ---
444
510
 
package/README.md CHANGED
@@ -150,6 +150,49 @@ exactly equivalent to `color-mix(in srgb, <color> 25%, transparent)` — mixing
150
150
  `transparent` is premultiplied, so only alpha changes — but it needs no modern-browser
151
151
  support and it keeps your schema free of expressions the generator has to work around.
152
152
 
153
+ ## Overriding a bundled theme
154
+
155
+ To tweak an existing theme rather than author a full schema — "amber-olive-safari,
156
+ but primary is indigo" — merge sparse overrides over the bundled base with
157
+ `mergeThemeSchema` and generate from the merged result:
158
+
159
+ ```ts
160
+ import { generateThemeCss, mergeThemeSchema } from "@marianmeres/design-tokens";
161
+ import { amberOliveSafari } from "@marianmeres/design-tokens/themes";
162
+
163
+ const theme = mergeThemeSchema(amberOliveSafari, {
164
+ light: {
165
+ colors: {
166
+ intent: { primary: { DEFAULT: "#4f46e5", foreground: "#ffffff" } },
167
+ },
168
+ },
169
+ });
170
+
171
+ const css = generateThemeCss(theme, "app-");
172
+ ```
173
+
174
+ An override replaces the whole entry — the base's explicit `hover`/`active`/`foreground`
175
+ for that entry are gone, not inherited (they would no longer match your new `DEFAULT`;
176
+ omitted states are auto-derived from it instead). To keep parts of the base entry,
177
+ spread it yourself: `{ ...amberOliveSafari.light.colors.intent.primary, DEFAULT: "#4f46e5" }`.
178
+ An absent or empty mode (`dark: {}`) leaves the base mode untouched, and a key the
179
+ base does not have is an error — both in the `ThemeSchemaOverrides` type and at runtime.
180
+
181
+ Prefer literal color values (hex, or `colors.indigo[600]` from the bundled palette) over
182
+ `var()` references in overrides: a value like `var(--color-indigo-600)` cannot be
183
+ resolved at build time, so those tokens lose their precomputed legacy-browser fallback.
184
+
185
+ Do **not** try to override by layering a second generated stylesheet over a base theme's
186
+ CSS — the generator rejects sparse schemas whose output would reference tokens they never
187
+ declare (the common override shapes), and even a sparse schema that does generate breaks
188
+ subtly when layered, on engines without `color-mix()` support, while looking fine on
189
+ modern ones. Merge the schemas, generate one stylesheet.
190
+
191
+ Since the merged output is derived from the installed base, run the generate step as
192
+ part of your build (e.g. a `prebuild` hook) rather than committing its output — that
193
+ way upstream theme improvements flow in with every dependency update. If you prefer
194
+ committing it, add a CI check that regenerating leaves the file unchanged.
195
+
153
196
  ## Bundled themes
154
197
 
155
198
  ```ts
@@ -192,11 +235,11 @@ and author-supplied `color-mix()` values pass through). Use `fallback` for that.
192
235
  `color-mix()` needs Chrome/Edge 111+, Safari 16.2+, Firefox 113+. The `fallback` option
193
236
  controls what older engines get:
194
237
 
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 |
238
+ | `fallback` | Output |
239
+ | --------------------- | ----------------------------------------------------------------------------------------------------------- |
240
+ | `"supports"`(default) | Normal output, plus a trailing `@supports not (...)` block with precomputed literals |
241
+ | `"static"` | Statically resolvable `color-mix()` evaluated at build time to literals; unresolvable operands pass through |
242
+ | `"none"` | `color-mix()` only; breaks on older engines |
200
243
 
201
244
  ```css
202
245
  /* fallback: "supports" */
@@ -96,7 +96,7 @@
96
96
 
97
97
  /* prettier-ignore */
98
98
  :root.dark {
99
- --stuic-color-primary: #f59e0b;
99
+ --stuic-color-primary: #d97706;
100
100
  --stuic-color-primary-hover: color-mix(in oklab, var(--stuic-color-primary), white 10%);
101
101
  --stuic-color-primary-active: color-mix(in oklab, var(--stuic-color-primary), white 20%);
102
102
  --stuic-color-primary-foreground: #000000;
@@ -238,8 +238,8 @@
238
238
  }
239
239
 
240
240
  :root.dark {
241
- --stuic-color-primary-hover: #f7a83f;
242
- --stuic-color-primary-active: #f9b35b;
241
+ --stuic-color-primary-hover: #de8537;
242
+ --stuic-color-primary-active: #e39352;
243
243
 
244
244
  --stuic-color-accent-hover: #b3b3a6;
245
245
  --stuic-color-accent-active: #bbbbaf;
@@ -253,9 +253,9 @@
253
253
  --stuic-color-success-hover: #43c08d;
254
254
  --stuic-color-success-active: #5fc899;
255
255
 
256
- --stuic-color-surface-primary: #3d3014;
257
- --stuic-color-surface-primary-foreground: #facf85;
258
- --stuic-color-surface-primary-border: #5e4413;
256
+ --stuic-color-surface-primary: #392b14;
257
+ --stuic-color-surface-primary-foreground: #ecbb83;
258
+ --stuic-color-surface-primary-border: #553811;
259
259
  --stuic-color-surface-accent: #32322a;
260
260
  --stuic-color-surface-accent-foreground: #d5d5ce;
261
261
  --stuic-color-surface-accent-border: #48483e;
@@ -5,7 +5,10 @@ import type { GeneratedTokens, ThemeSchema, TokenSchema } from "./types.js";
5
5
  *
6
6
  * - `"none"` — emit `color-mix()` expressions as-is.
7
7
  * - `"static"` — evaluate every `color-mix()` at build time and emit literal
8
- * colors instead. The output contains no `color-mix()` at all.
8
+ * colors instead. The output contains no `color-mix()` for statically
9
+ * resolvable values; expressions whose operands cannot be resolved at build
10
+ * time (e.g. author-supplied `var()` refs to variables declared elsewhere)
11
+ * pass through unchanged.
9
12
  */
10
13
  export type TokenFallback = "none" | "static";
11
14
  /**
package/dist/generate.js CHANGED
@@ -70,6 +70,95 @@ function normalizePrefix(prefix) {
70
70
  return "";
71
71
  return prefix.endsWith("-") ? prefix : prefix + "-";
72
72
  }
73
+ /**
74
+ * Thrown when a schema would generate broken output. Internal — the message
75
+ * (which always names the offending schema path) is the public contract.
76
+ */
77
+ class TokenSchemaError extends Error {
78
+ name = "TokenSchemaError";
79
+ }
80
+ const MERGE_HINT = "To override parts of an existing theme, merge your overrides over a " +
81
+ "complete base first: mergeThemeSchema(base, overrides).";
82
+ function isPlainObject(value) {
83
+ return value !== null && typeof value === "object" && !Array.isArray(value);
84
+ }
85
+ /**
86
+ * Guard the five container objects the generator dereferences, with an error
87
+ * naming the missing path instead of `Cannot read properties of undefined`.
88
+ */
89
+ function assertSchemaContainers(schema) {
90
+ const bad = (path, got) => {
91
+ throw new TokenSchemaError(`Invalid TokenSchema: "${path}" is ${got === undefined ? "missing" : "not an object"} — a complete schema has the shape ` +
92
+ `{ colors: { intent, role: { paired, single } } }. ${MERGE_HINT}`);
93
+ };
94
+ if (!isPlainObject(schema))
95
+ bad("(schema)", schema);
96
+ if (!isPlainObject(schema.colors))
97
+ bad("colors", schema.colors);
98
+ if (!isPlainObject(schema.colors.intent))
99
+ bad("colors.intent", schema.colors.intent);
100
+ if (!isPlainObject(schema.colors.role))
101
+ bad("colors.role", schema.colors.role);
102
+ if (!isPlainObject(schema.colors.role.paired)) {
103
+ bad("colors.role.paired", schema.colors.role.paired);
104
+ }
105
+ if (!isPlainObject(schema.colors.role.single)) {
106
+ bad("colors.role.single", schema.colors.role.single);
107
+ }
108
+ }
109
+ /**
110
+ * A `ColorPair` entry must carry string `DEFAULT` and `foreground` — anything
111
+ * else would be written into the token record verbatim (`--x: undefined;`,
112
+ * `--x: 5;`) and only fail at CSS substitution time, far from the mistake.
113
+ */
114
+ function assertColorPair(value, path) {
115
+ if (!isPlainObject(value)) {
116
+ throw new TokenSchemaError(`Invalid TokenSchema: "${path}" must be an object with string ` +
117
+ `"DEFAULT" and "foreground" (got ${describe(value)}). ${MERGE_HINT}`);
118
+ }
119
+ for (const key of ["DEFAULT", "foreground"]) {
120
+ if (typeof value[key] !== "string") {
121
+ throw new TokenSchemaError(`Invalid TokenSchema: "${path}" must declare a string "${key}" ` +
122
+ `(got ${describe(value[key])}). ${MERGE_HINT}`);
123
+ }
124
+ }
125
+ assertOptionalStates(value, ["hover", "active", "foregroundHover", "foregroundActive"], path);
126
+ }
127
+ /**
128
+ * Optional state fields must be strings when carried — a non-string value
129
+ * would flow verbatim into the token record (`--x: 5;`, `--x: [object
130
+ * Object];`) or crash the static resolver three frames from the mistake.
131
+ * `null` is exempt: every consumer of these fields degrades it via `??`.
132
+ */
133
+ function assertOptionalStates(value, keys, path) {
134
+ for (const key of keys) {
135
+ const v = value[key];
136
+ if (v !== undefined && v !== null && typeof v !== "string") {
137
+ throw new TokenSchemaError(`Invalid TokenSchema: "${path}.${key}" must be a string when ` +
138
+ `present (got ${describe(v)}). ${MERGE_HINT}`);
139
+ }
140
+ }
141
+ }
142
+ /** A `SingleColor` entry must be a string or an object with a string `DEFAULT`. */
143
+ function assertSingleColor(value, path) {
144
+ if (typeof value === "string")
145
+ return;
146
+ if (!isPlainObject(value) || typeof value.DEFAULT !== "string") {
147
+ throw new TokenSchemaError(`Invalid TokenSchema: "${path}" must be a string or an object with ` +
148
+ `a string "DEFAULT" (got ${describe(value)}). ${MERGE_HINT}`);
149
+ }
150
+ assertOptionalStates(value, ["hover", "active"], path);
151
+ }
152
+ function describe(value) {
153
+ if (value === null)
154
+ return "null";
155
+ if (Array.isArray(value))
156
+ return "an array";
157
+ if (typeof value === "object") {
158
+ return `an object with keys [${Object.keys(value).join(", ")}]`;
159
+ }
160
+ return typeof value === "string" ? JSON.stringify(value) : String(value);
161
+ }
73
162
  /** Check if a single color is an object with states or a plain string */
74
163
  function isSingleColorObject(value) {
75
164
  return (value !== null &&
@@ -151,13 +240,18 @@ export function generateCssTokens(schema, prefix, options = {}) {
151
240
  const deriveStates = opts.deriveStates ?? true;
152
241
  const surfaceContrast = opts.surfaceForegroundContrast ?? 50;
153
242
  const fallback = opts.fallback ?? "none";
243
+ assertSchemaContainers(schema);
154
244
  const p = normalizePrefix(prefix);
155
245
  const tokens = {};
246
+ // Set when a role hover/active derivation actually fires — those derived
247
+ // values reference `--{p}color-foreground`, which the output must declare.
248
+ let referencesForeground = false;
156
249
  // Intent colors (auto-derive hover/active via color-mix when enabled).
157
250
  // Saturated brand colors mix toward black/white (mode-aware) — neutral
158
251
  // darken/lighten keeps the hue clean.
159
252
  const intentStrategy = { kind: "contrast", mode };
160
253
  for (const [key, pair] of Object.entries(schema.colors.intent)) {
254
+ assertColorPair(pair, `colors.intent.${key}`);
161
255
  const filled = deriveStates ? fillPairStates(pair, p, key, intentStrategy) : pair;
162
256
  generatePairedColorTokens(tokens, key, filled, p);
163
257
  }
@@ -179,20 +273,45 @@ export function generateCssTokens(schema, prefix, options = {}) {
179
273
  };
180
274
  // Role colors (paired) — auto-derive except "background"
181
275
  for (const [key, pair] of Object.entries(schema.colors.role.paired)) {
182
- const filled = key === "background" || !deriveStates
183
- ? pair
184
- : fillPairStates(pair, p, key, roleStrategy);
276
+ assertColorPair(pair, `colors.role.paired.${key}`);
277
+ const derive = key !== "background" && deriveStates;
278
+ if (derive && (pair.hover === undefined || pair.active === undefined)) {
279
+ referencesForeground = true;
280
+ }
281
+ const filled = derive ? fillPairStates(pair, p, key, roleStrategy) : pair;
185
282
  generatePairedColorTokens(tokens, key, filled, p);
186
283
  }
187
284
  // Role colors (single)
188
285
  for (const [key, color] of Object.entries(schema.colors.role.single)) {
286
+ assertSingleColor(color, `colors.role.single.${key}`);
189
287
  if (isSingleColorObject(color) && deriveStates) {
288
+ if (color.hover === undefined || color.active === undefined) {
289
+ referencesForeground = true;
290
+ }
190
291
  generateSingleColorTokens(tokens, key, fillColorValueStates(color, p, key, roleStrategy), p);
191
292
  }
192
293
  else {
193
294
  generateSingleColorTokens(tokens, key, color, p);
194
295
  }
195
296
  }
297
+ // The two derived operands: reject a schema whose output would reference a
298
+ // token it never declares. Everything else — including a schema with fewer
299
+ // keys than TokenSchema requires — generates a stylesheet that stands
300
+ // alone, so it is not the generator's business to refuse it.
301
+ if (Object.keys(schema.colors.intent).length > 0 &&
302
+ tokens[`${p}color-background`] === undefined) {
303
+ throw new TokenSchemaError(`Invalid TokenSchema: "colors.role.paired.background" is required ` +
304
+ `when intent colors are present — every surface-{intent} token ` +
305
+ `references var(--${p}color-background), which this schema's ` +
306
+ `output would never declare. ${MERGE_HINT}`);
307
+ }
308
+ if (referencesForeground && tokens[`${p}color-foreground`] === undefined) {
309
+ throw new TokenSchemaError(`Invalid TokenSchema: "colors.role.single.foreground" is required ` +
310
+ `when role hover/active states are derived — derived values ` +
311
+ `reference var(--${p}color-foreground), which this schema's ` +
312
+ `output would never declare. Declare it, or pass ` +
313
+ `deriveStates: false. ${MERGE_HINT}`);
314
+ }
196
315
  return fallback === "static" ? resolveStaticTokens(tokens) : tokens;
197
316
  }
198
317
  /**
@@ -277,25 +396,32 @@ export function staticFallbackCss(entries) {
277
396
  export function generateThemeCss(schema, prefix, options = {}) {
278
397
  const { cssLayer, prettierIgnore, fallback = "supports", ...genOptions } = options;
279
398
  const PRETTIER_IGNORE_PREFIX = "/* prettier-ignore */\n";
280
- const modes = [
281
- {
282
- selector: ":root",
283
- tokens: generateCssTokens(schema.light, prefix, {
399
+ if (!isPlainObject(schema) || schema.light === undefined) {
400
+ throw new TokenSchemaError(`Invalid ThemeSchema: expected { light: TokenSchema, dark?: TokenSchema }` +
401
+ ` (got ${describe(schema)}). ${MERGE_HINT}`);
402
+ }
403
+ // Re-throw schema validation errors with the mode they came from — the
404
+ // same path exists in both modes, so an unqualified path is ambiguous.
405
+ const modeTokens = (mode, tokenSchema) => {
406
+ try {
407
+ return generateCssTokens(tokenSchema, prefix, {
284
408
  ...genOptions,
285
- mode: "light",
409
+ mode,
286
410
  fallback: fallback === "static" ? "static" : "none",
287
- }),
288
- },
411
+ });
412
+ }
413
+ catch (e) {
414
+ if (e instanceof TokenSchemaError) {
415
+ throw new TokenSchemaError(`[${mode}] ${e.message}`);
416
+ }
417
+ throw e;
418
+ }
419
+ };
420
+ const modes = [
421
+ { selector: ":root", tokens: modeTokens("light", schema.light) },
289
422
  ];
290
423
  if (schema.dark) {
291
- modes.push({
292
- selector: ":root.dark",
293
- tokens: generateCssTokens(schema.dark, prefix, {
294
- ...genOptions,
295
- mode: "dark",
296
- fallback: fallback === "static" ? "static" : "none",
297
- }),
298
- });
424
+ modes.push({ selector: ":root.dark", tokens: modeTokens("dark", schema.dark) });
299
425
  }
300
426
  const blocks = modes.map(({ selector, tokens }) => toCssString(tokens, selector));
301
427
  if (fallback === "supports") {
@@ -0,0 +1,38 @@
1
+ import type { ThemeSchema, ThemeSchemaOverrides } from "./types.js";
2
+ /**
3
+ * Merge sparse overrides over a complete base theme, returning a new complete
4
+ * {@link ThemeSchema} — the supported way to express "bundled theme X, but
5
+ * primary is indigo". The base is never mutated; an absent or empty override
6
+ * mode yields the base mode unchanged.
7
+ *
8
+ * The merge recurses through the containers (`light`/`dark` → `colors` →
9
+ * `intent`/`role` → `paired`/`single`) and **replaces whole leaves** — a
10
+ * `ColorPair` or `SingleColor` override supersedes the base entry entirely,
11
+ * never merges into it. Deliberate: merging inside a leaf would let a changed
12
+ * `DEFAULT` silently inherit a stale explicit `hover`/`active` or a
13
+ * `foreground` that no longer contrasts. To keep parts of a base leaf, spread
14
+ * it yourself: `{ ...base.light.colors.intent.primary, DEFAULT: "#4f46e5" }`.
15
+ *
16
+ * Override keys must already exist in the base (typo protection); non-empty
17
+ * dark overrides require the base to have a dark mode. To change the key set,
18
+ * author it on the base schema object.
19
+ *
20
+ * @param base - A complete theme (e.g. a bundled theme from `./themes`)
21
+ * @param overrides - Sparse overrides; see {@link ThemeSchemaOverrides}
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * import { amberOliveSafari } from "@marianmeres/design-tokens/themes";
26
+ * import { generateThemeCss, mergeThemeSchema } from "@marianmeres/design-tokens";
27
+ *
28
+ * const theme = mergeThemeSchema(amberOliveSafari, {
29
+ * light: {
30
+ * colors: {
31
+ * intent: { primary: { DEFAULT: "#4f46e5", foreground: "#ffffff" } },
32
+ * },
33
+ * },
34
+ * });
35
+ * const css = generateThemeCss(theme, "app-");
36
+ * ```
37
+ */
38
+ export declare function mergeThemeSchema(base: ThemeSchema, overrides: ThemeSchemaOverrides): ThemeSchema;
package/dist/merge.js ADDED
@@ -0,0 +1,135 @@
1
+ function isPlainObject(value) {
2
+ return value !== null && typeof value === "object" && !Array.isArray(value);
3
+ }
4
+ /** Reject override keys outside `allowed` — the runtime analogue of the
5
+ * excess-property check the {@link ThemeSchemaOverrides} type performs on
6
+ * literals, for overrides that arrive as JSON (MCP, config files). */
7
+ function assertKnownKeys(obj, allowed, path) {
8
+ for (const key of Object.keys(obj)) {
9
+ if (!allowed.includes(key)) {
10
+ throw new Error(`Invalid ThemeSchemaOverrides: unknown key "${path}.${key}" ` +
11
+ `(expected ${allowed.map((a) => `"${a}"`).join(", ")}).`);
12
+ }
13
+ }
14
+ }
15
+ /**
16
+ * Replace whole leaves in `target` with clones from `source`. A key the base
17
+ * does not already have is an error, not an addition — the most likely cause
18
+ * is a typo, and merging a typo'd key would silently mint junk tokens while
19
+ * the intended token keeps its base value.
20
+ */
21
+ function replaceLeaves(target, source, path) {
22
+ if (!isPlainObject(source)) {
23
+ throw new Error(`Invalid ThemeSchemaOverrides: "${path}" must be an object.`);
24
+ }
25
+ for (const [key, leaf] of Object.entries(source)) {
26
+ // Object.hasOwn, not `in`: the prototype chain would let keys like
27
+ // "constructor" / "toString" / "__proto__" slip past the guard (and on
28
+ // Node, assigning "__proto__" would fire the real setter).
29
+ if (!Object.hasOwn(target, key)) {
30
+ throw new Error(`Invalid ThemeSchemaOverrides: "${path}.${key}" does not exist ` +
31
+ `in the base theme (base has: ${Object.keys(target).join(", ")}). Overrides replace existing keys only — to introduce a ` +
32
+ `new key, author it on the base schema object.`);
33
+ }
34
+ if (leaf === undefined)
35
+ continue;
36
+ target[key] = structuredClone(leaf);
37
+ }
38
+ }
39
+ /** Merge one mode: clone the base, then replace overridden leaves. */
40
+ function mergeMode(baseMode, overrides, path) {
41
+ const merged = structuredClone(baseMode);
42
+ if (overrides == null)
43
+ return merged;
44
+ if (!isPlainObject(overrides)) {
45
+ throw new Error(`Invalid ThemeSchemaOverrides: "${path}" must be an object.`);
46
+ }
47
+ assertKnownKeys(overrides, ["colors"], path);
48
+ const colors = overrides.colors;
49
+ if (colors == null)
50
+ return merged;
51
+ if (!isPlainObject(colors)) {
52
+ throw new Error(`Invalid ThemeSchemaOverrides: "${path}.colors" must be an object.`);
53
+ }
54
+ assertKnownKeys(colors, ["intent", "role"], `${path}.colors`);
55
+ if (colors.intent != null) {
56
+ replaceLeaves(merged.colors.intent, colors.intent, `${path}.colors.intent`);
57
+ }
58
+ const role = colors.role;
59
+ if (role != null) {
60
+ if (!isPlainObject(role)) {
61
+ throw new Error(`Invalid ThemeSchemaOverrides: "${path}.colors.role" must be an object.`);
62
+ }
63
+ assertKnownKeys(role, ["paired", "single"], `${path}.colors.role`);
64
+ if (role.paired != null) {
65
+ replaceLeaves(merged.colors.role.paired, role.paired, `${path}.colors.role.paired`);
66
+ }
67
+ if (role.single != null) {
68
+ replaceLeaves(merged.colors.role.single, role.single, `${path}.colors.role.single`);
69
+ }
70
+ }
71
+ return merged;
72
+ }
73
+ /**
74
+ * Merge sparse overrides over a complete base theme, returning a new complete
75
+ * {@link ThemeSchema} — the supported way to express "bundled theme X, but
76
+ * primary is indigo". The base is never mutated; an absent or empty override
77
+ * mode yields the base mode unchanged.
78
+ *
79
+ * The merge recurses through the containers (`light`/`dark` → `colors` →
80
+ * `intent`/`role` → `paired`/`single`) and **replaces whole leaves** — a
81
+ * `ColorPair` or `SingleColor` override supersedes the base entry entirely,
82
+ * never merges into it. Deliberate: merging inside a leaf would let a changed
83
+ * `DEFAULT` silently inherit a stale explicit `hover`/`active` or a
84
+ * `foreground` that no longer contrasts. To keep parts of a base leaf, spread
85
+ * it yourself: `{ ...base.light.colors.intent.primary, DEFAULT: "#4f46e5" }`.
86
+ *
87
+ * Override keys must already exist in the base (typo protection); non-empty
88
+ * dark overrides require the base to have a dark mode. To change the key set,
89
+ * author it on the base schema object.
90
+ *
91
+ * @param base - A complete theme (e.g. a bundled theme from `./themes`)
92
+ * @param overrides - Sparse overrides; see {@link ThemeSchemaOverrides}
93
+ *
94
+ * @example
95
+ * ```ts
96
+ * import { amberOliveSafari } from "@marianmeres/design-tokens/themes";
97
+ * import { generateThemeCss, mergeThemeSchema } from "@marianmeres/design-tokens";
98
+ *
99
+ * const theme = mergeThemeSchema(amberOliveSafari, {
100
+ * light: {
101
+ * colors: {
102
+ * intent: { primary: { DEFAULT: "#4f46e5", foreground: "#ffffff" } },
103
+ * },
104
+ * },
105
+ * });
106
+ * const css = generateThemeCss(theme, "app-");
107
+ * ```
108
+ */
109
+ export function mergeThemeSchema(base, overrides) {
110
+ if (!isPlainObject(base) || !isPlainObject(base.light)) {
111
+ throw new Error(`mergeThemeSchema: "base" must be a complete ThemeSchema with a ` +
112
+ `"light" mode (e.g. a bundled theme).`);
113
+ }
114
+ const o = overrides ?? {};
115
+ if (!isPlainObject(o)) {
116
+ throw new Error(`Invalid ThemeSchemaOverrides: expected an object.`);
117
+ }
118
+ assertKnownKeys(o, ["light", "dark"], "overrides");
119
+ if (o.dark != null) {
120
+ if (!isPlainObject(o.dark)) {
121
+ throw new Error(`Invalid ThemeSchemaOverrides: "dark" must be an object.`);
122
+ }
123
+ // An *empty* dark override is "no changes to dark" — harmless against
124
+ // any base. Only actual dark overrides require the base to have one.
125
+ if (Object.keys(o.dark).length > 0 && base.dark == null) {
126
+ throw new Error(`Invalid ThemeSchemaOverrides: "dark" overrides were given but ` +
127
+ `the base theme has no dark mode — author the dark mode on ` +
128
+ `the base schema object instead.`);
129
+ }
130
+ }
131
+ return {
132
+ light: mergeMode(base.light, o.light, "light"),
133
+ ...(base.dark ? { dark: mergeMode(base.dark, o.dark, "dark") } : {}),
134
+ };
135
+ }
package/dist/mod.d.ts CHANGED
@@ -9,6 +9,8 @@
9
9
  export { generateCssTokens } from "./generate.js";
10
10
  /** Generate complete CSS for a theme (light + optional dark mode). */
11
11
  export { generateThemeCss } from "./generate.js";
12
+ /** Merge sparse overrides over a complete base theme ("bundled theme X, but primary is indigo"). */
13
+ export { mergeThemeSchema } from "./merge.js";
12
14
  /** Convert a tokens record to a CSS declaration block string. */
13
15
  export { toCssString } from "./generate.js";
14
16
  /** Evaluate every `color-mix()` in a token record to a literal color. */
@@ -25,9 +27,9 @@ export type { GenerateThemeOptions } from "./generate.js";
25
27
  export type { TokenFallback } from "./generate.js";
26
28
  /** Legacy-engine fallback strategy for a stylesheet: adds `"supports"`. */
27
29
  export type { ThemeFallback } from "./generate.js";
28
- /** Base color value with optional hover/active pseudo states. */
30
+ /** Color pair: `DEFAULT` plus required `foreground`, with optional hover/active pseudo states. */
29
31
  export type { ColorPair } from "./types.js";
30
- /** Color value: a hex string or an object with DEFAULT and optional pseudo states. */
32
+ /** Base color value: an object with `DEFAULT` and optional hover/active pseudo states. */
31
33
  export type { ColorValue } from "./types.js";
32
34
  /** Generated CSS token key-value pairs (without the `--` prefix on keys). */
33
35
  export type { GeneratedTokens } from "./types.js";
@@ -43,8 +45,12 @@ export type { RoleSingleKey } from "./types.js";
43
45
  export type { SingleColor } from "./types.js";
44
46
  /** A complete theme definition with required light mode and optional dark mode. */
45
47
  export type { ThemeSchema } from "./types.js";
46
- /** Complete token schema for a single mode (light or dark). */
48
+ /** Complete token schema for a single mode (light or dark) — completeness is load-bearing; for sparse overrides use {@link mergeThemeSchema}. */
47
49
  export type { TokenSchema } from "./types.js";
50
+ /** Sparse single-mode overrides for {@link mergeThemeSchema} — not accepted by {@link generateCssTokens}. */
51
+ export type { TokenSchemaOverrides } from "./types.js";
52
+ /** Sparse theme overrides for {@link mergeThemeSchema} — not accepted by {@link generateThemeCss}. */
53
+ export type { ThemeSchemaOverrides } from "./types.js";
48
54
  /** Helper: partial known keys, allow additional. */
49
55
  export type { WithOptional } from "./types.js";
50
56
  /** Helper: require known keys, allow additional. */
package/dist/mod.js CHANGED
@@ -9,6 +9,8 @@
9
9
  export { generateCssTokens } from "./generate.js";
10
10
  /** Generate complete CSS for a theme (light + optional dark mode). */
11
11
  export { generateThemeCss } from "./generate.js";
12
+ /** Merge sparse overrides over a complete base theme ("bundled theme X, but primary is indigo"). */
13
+ export { mergeThemeSchema } from "./merge.js";
12
14
  /** Convert a tokens record to a CSS declaration block string. */
13
15
  export { toCssString } from "./generate.js";
14
16
  /** Evaluate every `color-mix()` in a token record to a literal color. */
@@ -61,7 +61,7 @@ const dark = {
61
61
  colors: {
62
62
  intent: {
63
63
  primary: {
64
- DEFAULT: colors.amber[500],
64
+ DEFAULT: colors.amber[600],
65
65
  foreground: colors.black,
66
66
  },
67
67
  accent: {
package/dist/types.d.ts CHANGED
@@ -39,7 +39,14 @@ export type WithOptional<TKnown extends string, TValue> = Partial<Record<TKnown,
39
39
  */
40
40
  export type WithKnown<TRequired extends string, TOptional extends string, TValue> = Record<TRequired, TValue> & Partial<Record<TOptional, TValue>> & Record<string, TValue>;
41
41
  /**
42
- * Complete token schema for a single mode (light or dark).
42
+ * Complete token schema for a single mode (light or dark). Completeness is
43
+ * load-bearing, not ceremonial: the generator derives tokens whose values
44
+ * reference `--{prefix}color-background` (every `surface-{intent}` token) and
45
+ * `--{prefix}color-foreground` (role hover/active derivation), and only
46
+ * `role.paired.background` / `role.single.foreground` declare those. A sparse
47
+ * schema would generate a stylesheet that references tokens it never declares.
48
+ * To override parts of an existing theme, use `mergeThemeSchema` with a
49
+ * {@link ThemeSchemaOverrides} instead of passing a partial schema here.
43
50
  *
44
51
  * Defines intent colors (primary, accent, destructive, warning, success),
45
52
  * role paired colors (background, muted, surface required; surface-1 optional
@@ -55,6 +62,34 @@ export type TokenSchema = {
55
62
  };
56
63
  };
57
64
  };
65
+ /**
66
+ * Sparse single-mode overrides for `mergeThemeSchema` — NOT accepted by
67
+ * `generateCssTokens`; merge it over a complete {@link TokenSchema} first.
68
+ *
69
+ * Deliberately carries no index signatures (unlike {@link TokenSchema}), so a
70
+ * typo'd key in an override literal is a compile error instead of a silently
71
+ * minted junk token. To introduce a genuinely new key, author it on the base
72
+ * schema object (e.g. `{ ...base.light.colors.intent, info: … }`) rather than
73
+ * through an override.
74
+ */
75
+ export type TokenSchemaOverrides = {
76
+ colors?: {
77
+ intent?: Partial<Record<IntentColorKey, ColorPair>>;
78
+ role?: {
79
+ paired?: Partial<Record<RolePairedKey | RolePairedOptionalKey, ColorPair>>;
80
+ single?: Partial<Record<RoleSingleKey, SingleColor>>;
81
+ };
82
+ };
83
+ };
84
+ /**
85
+ * Sparse theme overrides for `mergeThemeSchema` — NOT accepted by
86
+ * `generateThemeCss`; merge it over a complete {@link ThemeSchema} first.
87
+ * An absent or empty mode leaves the base mode unchanged.
88
+ */
89
+ export type ThemeSchemaOverrides = {
90
+ light?: TokenSchemaOverrides;
91
+ dark?: TokenSchemaOverrides;
92
+ };
58
93
  /** Generated CSS token key-value pairs (without the `--` prefix on keys). */
59
94
  export type GeneratedTokens = Record<string, string>;
60
95
  /** A complete theme definition with required light mode and optional dark mode */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/design-tokens",
3
- "version": "1.14.0",
3
+ "version": "1.16.0",
4
4
  "type": "module",
5
5
  "main": "dist/mod.js",
6
6
  "types": "dist/mod.d.ts",