@tenphi/glaze 0.0.0-snapshot.60e8979 → 0.0.0-snapshot.63c08ea

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.
@@ -0,0 +1,378 @@
1
+ # Migration & Integration
2
+
3
+ How to plug a Glaze palette into an existing app — exporting tokens in the right shape, mapping prefixes to the names your components already consume, and migrating off a legacy color system without breaking layout, dark-mode wiring, or muscle-memory tokens.
4
+
5
+ If you're starting from scratch, see [methodology.md](methodology.md) first — that's about *designing* the palette. This doc is about *consuming* it.
6
+
7
+ ## Upgrading to the tone model (`lightness` → `tone`)
8
+
9
+ Glaze replaced OKHSL **lightness** with a contrast-uniform **tone** axis (OKHST). The Möbius dark-mode curve is gone — dark mode is now a single tone inversion remapped into a per-mode window. See [`docs/okhst.md`](okhst.md) for the model. This is a breaking change; here's what to update.
10
+
11
+ ### Rename the authoring axis
12
+
13
+ `lightness` is gone. Replace it with `tone` everywhere:
14
+
15
+ ```ts
16
+ // before
17
+ theme.colors({ surface: { lightness: 97 }, text: { base: 'surface', lightness: '-52' } });
18
+ // after
19
+ theme.colors({ surface: { tone: 97 }, text: { base: 'surface', tone: '-52' } });
20
+ ```
21
+
22
+ The same applies to `glaze.color({ ..., lightness })` (structured form) → `tone`, and the `{ h, s, l }` value object is unchanged (still OKHSL) — but you can now also pass `{ h, s, t }` (OKHST) or an `okhst(H S% T%)` string.
23
+
24
+ Tone is **0–100** like the old lightness, but the *scale is contrast-uniform*, not perceptual-lightness-uniform. Numeric values won't land at the same OKHSL lightness — equal tone steps now give equal WCAG contrast. Re-eyeball absolute values (especially mid-range ones); relative deltas and contrast-floored tokens usually need no change because they were already contrast-driven.
25
+
26
+ ### Config window shape
27
+
28
+ The lightness windows became tone windows, and `darkCurve` was removed (no curve to tune). The `[lo, hi]` tuple form carries over directly:
29
+
30
+ ```ts
31
+ // before
32
+ glaze.configure({ lightLightness: [10, 100], darkLightness: [15, 95], darkCurve: 0.5 });
33
+ // after
34
+ glaze.configure({
35
+ lightTone: [10, 100],
36
+ darkTone: [15, 95],
37
+ // darkCurve removed
38
+ });
39
+ ```
40
+
41
+ `lightLightness`/`darkLightness` → `lightTone`/`darkTone`. The window value is `[lo, hi]` (reference eps — the common form), `{ lo, hi, eps }` (advanced: explicit render curvature), or `false` to disable clamping. `false` removes the *boundaries* (full `[0, 100]` range), not the contrast-uniform tone curve. Per-token `glaze.color(value, config)` overrides use the same shape.
42
+
43
+ ### The `contrast` prop now selects a metric
44
+
45
+ A bare number or preset is still **WCAG** and needs no change. To use APCA or split a pair across the metric, use the object form:
46
+
47
+ ```ts
48
+ contrast: 4.5 // unchanged — WCAG 4.5
49
+ contrast: { wcag: 6 } // explicit WCAG
50
+ contrast: { apca: 60 } // APCA Lc floor
51
+ contrast: { wcag: [4.5, 7] } // pair inside the metric
52
+ ```
53
+
54
+ ### Forcing extremes: `'max'` / `'min'`
55
+
56
+ For colors that should sit at the scheme's tone extreme (pure-white knockouts, near-black scrims, deliberately faint disabled chips), reach for `tone: 'max'` / `tone: 'min'` instead of a large absolute number or a low contrast floor standing in for "push it all the way". `'max'` resolves to author tone 100, `'min'` to 0, and both flow through scheme mapping (so they invert in dark under `mode: 'auto'`). No `base` required.
57
+
58
+ ```ts
59
+ // before — low contrast as a proxy for "stay near the surface"
60
+ 'disabled-text': { base: 'chip', tone: '+1', contrast: 1.51, mode: 'fixed' }
61
+ // after — say it directly with tone
62
+ 'disabled-text': { base: 'chip', tone: '+18', saturation: 0.4, autoFlip: false }
63
+ ```
64
+
65
+ ### The `autoFlip` prop (previously `flip`)
66
+
67
+ The per-color configuration property `flip` has been renamed to `autoFlip` to align with the global `autoFlip` configuration option and to avoid confusion with dark-mode/scheme tone inversion (which is handled automatically by the system).
68
+
69
+ Relative `tone` offsets that overshoot `[0, 100]` now **mirror to the other side of the base by default** (controlled by the per-color `autoFlip`, which inherits the global `autoFlip`, default `true`). Previously such offsets always clamped to the boundary. If you relied on clamping — e.g. `tone: '+48'` to stack a color up to 100 — set `autoFlip: false` on that color (or `glaze.configure({ autoFlip: false })` globally) to restore the clamping behavior. `autoFlip` also governs the contrast solver's direction (its previous sole role).
70
+
71
+ ### Resolved variants store tone
72
+
73
+ `ResolvedColorVariant` now exposes `t` (tone, 0–1) instead of `l`. If you read resolved internals, convert with `variantToOkhsl(variant).l`. Token/CSS/JSON output uses native formats by default (`oklch` for `tokens()` / `json()`, `okhsl` for `tasty()`); use `tasty({ format: 'okhsl' })` or `tasty({ format: 'okhst' })` for Glaze-native spaces.
74
+
75
+ ### 0.16.0 — output format defaults and Tasty-only spaces
76
+
77
+ **Breaking changes in 0.16.0:**
78
+
79
+ | Export | Old default | New default |
80
+ |---|---|---|
81
+ | `tokens()` / `json()` (theme + palette) | `okhsl` | `oklch` |
82
+ | Standalone `.json()` | `okhsl` | `oklch` |
83
+ | `tasty()` | `okhsl` (unchanged) | `okhsl` |
84
+
85
+ `'okhsl'` and the new `'okhst'` output format are **Tasty-only**. Passing either to `css()`, `tailwind()`, `tokens()`, or `json()` throws. Migrate:
86
+
87
+ ```ts
88
+ // before
89
+ theme.tokens({ format: 'okhsl' })
90
+ theme.json()
91
+
92
+ // after — pick one
93
+ theme.tasty({ format: 'okhsl' }) // Tasty #name keys, okhsl strings
94
+ theme.tokens({ format: 'oklch' }) // native CSS (new default)
95
+ ```
96
+
97
+ **New:** `splitHue` on `css()` / `tasty()` (theme + palette) and standalone `color.css()` emits hue as a separate custom property for runtime re-skinning. Requires `format: 'oklch'` and every color to be pastel. See [api.md → Hue channel splitting](api.md#hue-channel-splitting-splithue).
98
+
99
+ ### Export snapshots
100
+
101
+ `theme.export()` / `color.export()` snapshots now carry `lightTone` / `darkTone` window objects (not `lightLightness` / `darkLightness`). Old exported JSON with the legacy keys will need its `config` block rewritten before `glaze.from()` / `glaze.colorFrom()`.
102
+
103
+ ## Choosing an export
104
+
105
+ Glaze emits the same resolved colors in six shapes. Pick one based on your renderer / tooling.
106
+
107
+ | Method | Output shape | Use it for |
108
+ |---|---|---|
109
+ | `palette.tasty(options?)` | `{ '#name': { '': value, '@media(prefers-color-scheme: dark)': value, '@media(prefers-contrast: more)': value } }` | The [Tasty](https://tasty.style/docs) style system. Single object, state aliases keyed inside each token. |
110
+ | `palette.tokens(options?)` | `{ light: { name: value }, dark: { name: value }, ... }` | Most CSS-in-JS systems. Per-variant flat maps, easy to feed into a `:root { ... }` selector via your framework's globals. |
111
+ | `palette.css(options?)` | `{ light: '--name-color: rgb(...);', dark: '...', ... }` | Framework-free CSS / static stylesheets. Variant-grouped CSS custom property strings ready to wrap in `:root` and `prefers-color-scheme` queries. |
112
+ | `palette.json(options?)` | `{ themeName: { name: { light, dark, ... } } }` | Tooling, JSON pipelines. |
113
+ | `palette.dtcg(options?)` | `{ light: { name: { $type, $value } }, dark: { ... }, ... }` | W3C [DTCG 2025.10](https://www.designtokens.org/) `.tokens.json` — Figma, Tokens Studio, Style Dictionary, Terrazzo, Penpot. One document per scheme. |
114
+ | `palette.dtcgResolver(options?)` | `{ version, sets, modifiers, resolutionOrder }` | W3C DTCG **Resolver-Module** — a single document describing every scheme variant as `sets` + a `scheme` modifier with a context per variant. For resolver tools such as Dispersa. |
115
+ | `palette.tailwind(options?)` | `'@theme { --color-*: ... } .dark { ... } ...'` | Tailwind CSS v4. A single ready-to-paste `@theme` block plus dark / high-contrast overrides. |
116
+
117
+ `tasty()`, `tokens()`, `json()`, `dtcg()`, `dtcgResolver()`, and `tailwind()` accept `modes` (`{ dark, highContrast }`). `css()` always returns all four strings (`light`, `dark`, `lightContrast`, `darkContrast`). The CSS-string exports accept `format` (`'rgb' \| 'hsl' \| 'oklch'` on `tokens`/`json`/`css`/`tailwind`; `'okhsl' \| 'okhst'` on `tasty()` only); `dtcg()` and `dtcgResolver()` use `colorSpace` (`'srgb' \| 'oklch'`) instead. See [api.md → Palette](api.md#palette) for full options.
118
+
119
+ ## Wiring exports into the app
120
+
121
+ ### Tasty
122
+
123
+ Spread the result of `palette.tasty()` into a global style call:
124
+
125
+ ```ts
126
+ import type { Styles } from '@tenphi/tasty';
127
+ import { useGlobalStyles } from '@tenphi/tasty';
128
+ import { tastyStatic } from '@tenphi/tasty/static';
129
+
130
+ export const PALETTE_TOKENS = palette.tasty({ /* prefix map */ }) as Styles;
131
+
132
+ // In your root component:
133
+ useGlobalStyles('body', PALETTE_TOKENS);
134
+
135
+ // Or, for zero-runtime builds:
136
+ tastyStatic('body', PALETTE_TOKENS);
137
+ ```
138
+
139
+ By default the dark / high-contrast variants are keyed by media-query states — `'@media(prefers-color-scheme: dark)'` and `'@media(prefers-contrast: more)'` — so the tokens react to the OS preference out of the box, with no extra Tasty setup.
140
+
141
+ If you'd rather drive the schemes from custom aliases (e.g. a manual toggle that also falls back to the OS preference), set your own [`glaze.configure({ states })`](api.md#configuration) and register what those states *mean*:
142
+
143
+ ```ts
144
+ import { setGlobalPredefinedStates } from '@tenphi/tasty';
145
+
146
+ // glaze.configure({ states: { dark: '@dark', highContrast: '@hc' } });
147
+ setGlobalPredefinedStates({
148
+ '@dark':
149
+ '@root(schema=dark) | (!@root(schema) & @media(prefers-color-scheme: dark))',
150
+ '@hc':
151
+ '@root(contrast=high) | (!@root(contrast) & @media(prefers-contrast: more))',
152
+ });
153
+ ```
154
+
155
+ The state names here must match the `states` you set in [`glaze.configure({ states })`](api.md#configuration).
156
+
157
+ You can also register the tokens as a Tasty recipe instead of spreading them globally:
158
+
159
+ ```ts
160
+ import { configure, tasty } from '@tenphi/tasty';
161
+
162
+ configure({ recipes: { 'theme-tokens': PALETTE_TOKENS } });
163
+
164
+ const Page = tasty({
165
+ styles: { recipe: 'theme-tokens', fill: '#surface', color: '#surface-text' },
166
+ });
167
+ ```
168
+
169
+ ### CSS custom properties
170
+
171
+ ```ts
172
+ const css = palette.css();
173
+ const stylesheet = `
174
+ :root { ${css.light} }
175
+ @media (prefers-color-scheme: dark) { :root { ${css.dark} } }
176
+ @media (prefers-contrast: more) { :root { ${css.lightContrast} } }
177
+ @media (prefers-color-scheme: dark) and (prefers-contrast: more) {
178
+ :root { ${css.darkContrast} }
179
+ }
180
+ `;
181
+ ```
182
+
183
+ Each property name is `--<prefix><name>-color` by default. Override the suffix:
184
+
185
+ ```ts
186
+ palette.css({ suffix: '' }); // → '--surface: rgb(...);'
187
+ palette.css({ format: 'oklch' }); // → '--surface-color: oklch(...);'
188
+ ```
189
+
190
+ ### Framework-agnostic JSON
191
+
192
+ ```ts
193
+ const data = palette.json();
194
+ // → { primary: { surface: { light: 'okhsl(...)', dark: 'okhsl(...)' } }, ... }
195
+ ```
196
+
197
+ Feed into your tooling pipeline. Each color is grouped by theme name, then by token name, then by variant — no prefix logic to undo.
198
+
199
+ ### W3C DTCG (`.tokens.json`)
200
+
201
+ ```ts
202
+ import { writeFileSync } from 'node:fs';
203
+ const dtcg = palette.dtcg();
204
+
205
+ writeFileSync('tokens.light.tokens.json', JSON.stringify(dtcg.light, null, 2));
206
+ if (dtcg.dark) {
207
+ writeFileSync('tokens.dark.tokens.json', JSON.stringify(dtcg.dark, null, 2));
208
+ }
209
+ ```
210
+
211
+ Each document is a spec-conformant token tree. One file per scheme is the most tool-compatible convention — Style Dictionary treats them as themes, Tokens Studio as sets, and Figma as variable modes. Use `colorSpace: 'oklch'` for wide-gamut, Glaze-native values (no `hex`); the default `'srgb'` emits components plus a `hex` hint that every reader understands. Feed the files straight into Style Dictionary v4+, Tokens Studio, or any DTCG-compatible tool — no Glaze-specific transform needed.
212
+
213
+ ### W3C DTCG Resolver-Module (single document)
214
+
215
+ When you want **one file** describing every scheme variant — for a resolver tool such as [Dispersa](https://github.com/dispersa-core/dispersa) — use `dtcgResolver()` instead of `dtcg()`:
216
+
217
+ ```ts
218
+ import { writeFileSync } from 'node:fs';
219
+ const resolver = palette.dtcgResolver({ modes: { highContrast: true } });
220
+
221
+ writeFileSync('resolver.json', JSON.stringify(resolver, null, 2));
222
+ ```
223
+
224
+ The document places the light tokens in `sets.base.sources[0]` (the default context) and emits a single `scheme` modifier with a context per variant (`light` / `dark` / `lightContrast` / `darkContrast`). Each non-default context holds that variant's exact resolved tokens — Glaze resolves `darkContrast` independently, so the four-context shape keeps every value correct (two independent modifiers would compose additively and produce wrong dark + high-contrast values). Rename the set, modifier, or contexts via `setName` / `modifierName` / `contextNames` if your resolver expects different labels. Prefer `dtcg()` when you need maximum per-file tool compatibility; prefer `dtcgResolver()` when a resolver tool consumes the document for you.
225
+
226
+ ### Tailwind CSS v4
227
+
228
+ ```ts
229
+ const css = palette.tailwind();
230
+ // Write to your CSS entry, or paste into a `@import "tailwindcss"` stylesheet:
231
+ // @theme { --color-primary-surface: oklch(...); ... }
232
+ // .dark { --color-primary-surface: oklch(...); ... }
233
+ ```
234
+
235
+ The `--color-*` namespace makes every color available as `bg-*` / `text-*` / `border-*`. Drive dark mode from the OS preference instead of a class with `darkSelector: '@media (prefers-color-scheme: dark)'` (it nests `:root` automatically). High-contrast overrides land under `.high-contrast` and `.dark.high-contrast` by default; both are omitted unless `modes.highContrast` is enabled.
236
+
237
+ ## Prefix map strategies
238
+
239
+ `palette.tokens()` / `tasty()` / `css()` / `dtcg()` / `tailwind()` accept a `prefix` option:
240
+
241
+ | Value | Result |
242
+ |---|---|
243
+ | `true` (default) | Every theme prefixes its tokens with `<themeName>-`. |
244
+ | `false` | No prefixes. Colliding keys produce a `console.warn`; first-write wins. |
245
+ | `Record<string, string>` | Per-theme prefix overrides. Themes not listed fall back to `<themeName>-`. |
246
+
247
+ The most common production pattern: **default theme unprefixed, every other theme prefixed with its name**:
248
+
249
+ ```ts
250
+ palette.tasty({
251
+ prefix: {
252
+ default: '',
253
+ primary: 'primary-',
254
+ success: 'success-',
255
+ danger: 'danger-',
256
+ warning: 'warning-',
257
+ note: 'note-',
258
+ },
259
+ });
260
+ ```
261
+
262
+ This makes neutral tokens consume as `#surface`, `#border`, `#disabled-surface` (no theme namespace) while status colors live under `#danger-surface`, `#success-accent-text`, etc.
263
+
264
+ ### Alias themes for legacy names
265
+
266
+ Two aliases for the same theme instance produce identical token values under different prefixes — useful when you want to support a legacy token name without duplicating definitions:
267
+
268
+ ```ts
269
+ const palette = glaze.palette({
270
+ default: defaultTheme,
271
+ primary: primaryTheme,
272
+ purple: primaryTheme, // legacy alias — same theme, different prefix
273
+ // ...
274
+ });
275
+
276
+ palette.tasty({
277
+ prefix: {
278
+ default: '',
279
+ primary: 'primary-',
280
+ purple: 'purple-', // emits #purple-surface alongside #primary-surface
281
+ },
282
+ });
283
+ ```
284
+
285
+ Both `#primary-surface` and `#purple-surface` resolve to the exact same color. Drop the alias when the legacy name is no longer referenced.
286
+
287
+ ### `primary` (the unprefixed alias)
288
+
289
+ `glaze.palette(themes, { primary })` and the per-export `primary` option duplicate one theme's tokens *without* prefix on top of any prefix map. Equivalent to listing that theme twice with different prefixes; useful when the "primary" theme is conceptually distinct from `default` and you want both sets of unprefixed tokens.
290
+
291
+ ```ts
292
+ const palette = glaze.palette(
293
+ { brand, accent },
294
+ { primary: 'brand' },
295
+ );
296
+ palette.tokens();
297
+ // → { light: { 'brand-surface': '...', 'surface': '...', 'accent-surface': '...' } }
298
+ ```
299
+
300
+ Override per call: `palette.tokens({ primary: 'accent' })`, or disable with `palette.tokens({ primary: false })`.
301
+
302
+ ## Migrating from an existing color system
303
+
304
+ The fastest path: **map first, design second**. Get every existing token name producing a Glaze-resolved color value (even if it's a rough first pass), wire the new palette in, then iterate on the resolved colors without touching component code.
305
+
306
+ ### 1. Inventory the existing tokens
307
+
308
+ Walk your current color system and bucket every token into one of these categories:
309
+
310
+ - **Surfaces** — backgrounds, panels, cards, banners.
311
+ - **Surface text** — body text, headings, captions, labels.
312
+ - **Borders / dividers / focus rings** — neutral structural lines.
313
+ - **Brand fills** — solid CTA backgrounds, badges, status banners.
314
+ - **Brand foregrounds** — link text, status text, icon colors on neutral backgrounds.
315
+ - **Disabled** — disabled chip + label.
316
+ - **Shadows / overlays** — elevation, scrims.
317
+ - **One-off colors** — syntax highlighting, charts, illustrations.
318
+
319
+ Each bucket maps cleanly to one of the patterns in [methodology.md](methodology.md). The bucket determines the *shape* of the Glaze definition (root vs dependent, `mode: 'auto'` vs `'fixed'`, contrast-floor vs absolute tone), not the value.
320
+
321
+ ### 2. Reproduce the existing values
322
+
323
+ Pick a Glaze definition shape that lands the new color *close to* the legacy hex in light mode. The methodology doc explains the shape per bucket; the API doc covers the levers (`tone`, `saturation`, `contrast`, `mode`, `hue`).
324
+
325
+ Two tactics that make matching easier:
326
+
327
+ - **Anchor strong text at the edge** instead of solving for `'AAA'`. The contrast solver stops at the floor (cr=7), which usually leaves text noticeably softer than a legacy hex like `#1a1a1a`. An absolute `tone: 2` (or wherever the legacy token sits) preserves the look.
328
+ - **Use numeric `contrast` ratios** for soft / accent / disabled tokens. Presets give you the WCAG floor and nothing more — for matching a designed palette you usually want a specific perceived weight, not the floor.
329
+
330
+ ### 3. Keep the old token names
331
+
332
+ Use a custom `prefix` map (and theme aliases if needed) so the names your components already consume keep working:
333
+
334
+ ```ts
335
+ // Old: components consume `#dark`, `#dark-02`, `#dark-03`.
336
+ // New: define them in Glaze, map the prefix so they emit unchanged.
337
+
338
+ defaultTheme.colors({
339
+ dark: { base: 'surface', tone: 2, saturation: 0.475 },
340
+ 'dark-02': { base: 'surface', tone: '-1', saturation: 0.375, contrast: [9, 11] },
341
+ 'dark-03': { base: 'surface', tone: '-1', saturation: 0.24, contrast: [4.5, 5.5] },
342
+ });
343
+
344
+ palette.tasty({ prefix: { default: '' } });
345
+ // → '#dark', '#dark-02', '#dark-03' — components don't change.
346
+ ```
347
+
348
+ Once consumers are off the legacy names, rename the Glaze tokens to match your conventions.
349
+
350
+ ### 4. Verify dark mode and HC before promoting
351
+
352
+ Glaze gives you light/dark/HC for free, but only the light mode is matched against the legacy palette. Before promoting the migration:
353
+
354
+ - Spot-check every surface, text, accent, and disabled pair in dark mode. The tone inversion plus per-color `mode` choices may produce results that *look right* in light but feel off in dark (typical fix: switch a brand color to `mode: 'fixed'`, or anchor a foreground to `surface` instead of the brand fill — see [methodology.md](methodology.md)).
355
+ - If the legacy system had no high-contrast mode, audit the HC variants Glaze emits. Anywhere the resolved cr is too low or the color blows out, add an HC pair (`tone: ['-7', '-20']`, `contrast: [4.5, 7]`, etc.).
356
+ - Run real screens, not just the token grid. The interaction of multiple Glaze tokens against each other (text on chip, hover bg vs. fill, disabled label on disabled chip) is where mismatches show up.
357
+
358
+ ### 5. Trim what `extend()` doesn't need
359
+
360
+ After migration, mark every default-only token (borders, shadows, disabled chip, code highlighting, etc.) `inherit: false`. Colored sibling themes only need the accent + tinted-surface chain — flagging the rest cuts the emitted token set per theme dramatically.
361
+
362
+ ## Common pitfalls
363
+
364
+ | Symptom | Cause | Fix |
365
+ |---|---|---|
366
+ | Disabled state stops looking disabled in dark mode. | Alpha-tinted overlay on `surface-text` (which inverts), giving asymmetric perceived contrast. | Replace with a `mode: 'auto'` color anchored to `surface` with a numeric `contrast` (see [methodology.md → Disabled chip](methodology.md#disabled-chip-contrast-driven-for-scheme-symmetry)). |
367
+ | Brand color flips to its complement in dark mode. | Default `mode: 'auto'` inverts the tone. | Set `mode: 'fixed'` so the tone is remapped (not inverted). |
368
+ | Brand text washes out against the dark surface. | Foreground was anchored to `accent-surface` (the brand fill), so contrast was only enforced against that fill — not the actual surface. | Anchor `accent-text` etc. to `surface` with `mode: 'auto'`. |
369
+ | Tokens look right in light, broken in HC. | The HC pass bypasses the tone window — solver runs over the full `[0, 100]` range. | Add explicit `[normal, hc]` pairs to `tone` / `contrast` for the affected tokens. |
370
+ | A relative `tone` like `'+48'` lands on the *wrong* (darker) side of its base. | Overshooting offsets now mirror to the other side of the base by default (`autoFlip` inherits `autoFlip`). | Set `autoFlip: false` on the color to clamp to the boundary instead, or use `tone: 'max'`/`'min'` to force the extreme. |
371
+ | `palette.tokens()` emits unexpected unprefixed names. | A `primary` was set on the palette (or per-call) and is duplicating the theme's tokens without prefix. | Pass `primary: false` to disable for that export, or rename `glaze.palette(themes, { primary })`. |
372
+ | `console.warn: token "foo" collides with theme "bar"`. | Two themes resolved to the same output key under your prefix config. | Adjust the prefix map so each token is unique, or accept the first-write-wins behavior. |
373
+ | `console.warn: color "X" cannot meet contrast`. | The requested contrast target is physically unreachable for the color's hue/saturation against its base. | Lower the floor, change the base, or accept the closest passing variant. Use the `name` override on standalone colors to make the warning identifiable. |
374
+
375
+ ## See also
376
+
377
+ - [methodology.md](methodology.md) — how to design the palette in the first place.
378
+ - [api.md](api.md) — full reference for every option mentioned here.