@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 +6 -1
- package/API.md +70 -4
- package/README.md +48 -5
- package/dist/css/amber-olive-safari.css +6 -6
- package/dist/generate.d.ts +4 -1
- package/dist/generate.js +144 -18
- package/dist/merge.d.ts +38 -0
- package/dist/merge.js +135 -0
- package/dist/mod.d.ts +9 -3
- package/dist/mod.js +2 -0
- package/dist/themes/amber-olive-safari.js +1 -1
- package/dist/types.d.ts +36 -1
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
|
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"` |
|
|
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: #
|
|
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: #
|
|
242
|
-
--stuic-color-primary-active: #
|
|
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: #
|
|
257
|
-
--stuic-color-surface-primary-foreground: #
|
|
258
|
-
--stuic-color-surface-primary-border: #
|
|
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;
|
package/dist/generate.d.ts
CHANGED
|
@@ -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()`
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
281
|
-
{
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
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") {
|
package/dist/merge.d.ts
ADDED
|
@@ -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
|
-
/**
|
|
30
|
+
/** Color pair: `DEFAULT` plus required `foreground`, with optional hover/active pseudo states. */
|
|
29
31
|
export type { ColorPair } from "./types.js";
|
|
30
|
-
/**
|
|
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. */
|
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 */
|