@tenphi/cookbook 0.32.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/docs/customization-rules.md +7 -5
  2. package/docs/migration.md +52 -26
  3. package/docs/theme-and-components.md +32 -15
  4. package/docs/upstream/glaze/README.md +46 -26
  5. package/docs/upstream/glaze/docs/contrast.mdx +751 -0
  6. package/docs/upstream/glaze/docs/exporting.mdx +634 -0
  7. package/docs/upstream/glaze/docs/getting-started.mdx +489 -0
  8. package/docs/upstream/glaze/docs/hdr.mdx +523 -0
  9. package/docs/upstream/glaze/docs/methodology.mdx +922 -0
  10. package/docs/upstream/glaze/docs/migration.md +477 -286
  11. package/docs/upstream/glaze/docs/mixes.mdx +348 -0
  12. package/docs/upstream/glaze/docs/okhst.mdx +679 -0
  13. package/docs/upstream/glaze/docs/reference/color-definitions.md +552 -0
  14. package/docs/upstream/glaze/docs/reference/color-tokens.md +420 -0
  15. package/docs/upstream/glaze/docs/reference/configuration.md +527 -0
  16. package/docs/upstream/glaze/docs/reference/contrast.md +277 -0
  17. package/docs/upstream/glaze/docs/reference/index.md +225 -0
  18. package/docs/upstream/glaze/docs/reference/output.md +713 -0
  19. package/docs/upstream/glaze/docs/reference/palette.md +434 -0
  20. package/docs/upstream/glaze/docs/reference/theme.md +331 -0
  21. package/docs/upstream/glaze/docs/reference/utilities.md +646 -0
  22. package/docs/upstream/glaze/docs/schemes.mdx +635 -0
  23. package/docs/upstream/glaze/docs/shadows-and-glows.mdx +519 -0
  24. package/docs/upstream/manifest.json +2 -2
  25. package/package.json +3 -3
  26. package/docs/upstream/glaze/docs/api.md +0 -2021
  27. package/docs/upstream/glaze/docs/methodology.md +0 -686
  28. package/docs/upstream/glaze/docs/okhst.md +0 -207
@@ -0,0 +1,635 @@
1
+ ---
2
+ title: Light, dark, and high contrast
3
+ description: How one color definition adapts to light, dark, and high-contrast schemes, and the options that steer it.
4
+ ---
5
+ import { Callout } from '@tenphi/cookbook/components';
6
+ import SchemeGrid from '../docs-site/components/SchemeGrid.tsx';
7
+ import WindowMap from '../docs-site/components/schemes/WindowMap.tsx';
8
+ import SchemeExample from '../docs-site/components/schemes/SchemeExample.astro';
9
+
10
+ You define each color once. Glaze resolves it four times — light, dark, light
11
+ high contrast, and dark high contrast — by moving its **tone** through a
12
+ per-scheme pipeline. Hue and saturation stay as you authored them, apart from a
13
+ small dark desaturation, unless you give dark a seed of its own.
14
+
15
+ <SchemeGrid caption="One theme in all four schemes. Every color is resolved by Glaze for its scheme; none is tuned by hand." />
16
+
17
+ ## Four variants per color
18
+
19
+ Every color resolves to four variants:
20
+
21
+ | Variant | Scheme | Window |
22
+ | --------------- | -------------------- | --------------------------------------------------------------------------------------- |
23
+ | `light` | Light | `lightTone`, default `[10, 100]` in OKHSL lightness (tone 4.95–100) |
24
+ | `dark` | Dark | `darkTone`, default `[15, 95]` in OKHSL lightness (tone 9.25–95.9), inverted for `auto` |
25
+ | `lightContrast` | Light, high contrast | The full range, tone 0–100 |
26
+ | `darkContrast` | Dark, high contrast | The full range, inverted for `auto` |
27
+
28
+ `theme.resolve()` always returns all four. Exports include `light` and `dark`
29
+ by default; [output modes](#output-modes) choose which variants they emit.
30
+
31
+ For a regular color, each variant runs the same pipeline:
32
+
33
+ ```text
34
+ authored tone
35
+ -> invert (100 - t) when the scheme is dark and mode is 'auto'
36
+ -> remap into the scheme's tone window (the full range in high contrast)
37
+ -> store the canonical tone; convert to OKHSL lightness when formatting
38
+ ```
39
+
40
+ The tone axis itself — why equal tone steps give equal contrast, and the
41
+ transfer math — is covered in [Tone and OKHST](./okhst.mdx).
42
+
43
+ ## Dark is an inversion
44
+
45
+ Under the default `mode: 'auto'`, dark mode is a single inversion: an authored
46
+ tone `t` becomes `100 − t`, which is then remapped into the dark window. There
47
+ is no fitted curve. Because tone is contrast-shaped, the inversion itself keeps
48
+ the authored spacing — exactly for neutrals, approximately for chromatic
49
+ colors, whose luminance drifts a little with hue and saturation. The dark window
50
+ then scales every step to its span, about 0.87× at the defaults, so order and
51
+ direction carry over but steps come out a little smaller in dark. Tone deltas
52
+ scale the same way on every base; see
53
+ [How relative tone adapts](#how-relative-tone-adapts).
54
+
55
+ A surface at tone 97 becomes a dark surface, and text 52 tones below it
56
+ becomes light text above it. Hierarchy, ladders, and borders keep their order
57
+ without a second definition.
58
+
59
+ ### Tone windows
60
+
61
+ Ordinary schemes don't use the whole tone scale. Each one has a **tone window**:
62
+
63
+ ```ts
64
+ glaze.configure({
65
+ lightTone: [10, 100], // default
66
+ darkTone: [15, 95], // default
67
+ });
68
+ ```
69
+
70
+ The bounds are OKHSL lightness values. Glaze converts them to tone — the light
71
+ window spans tone 4.95 to 100, the dark window 9.25 to 95.9 — and spreads the
72
+ whole authored `0–100` scale across that interval. The window is a remap, not a
73
+ clamp: an authored tone of 0 renders at tone 4.95 in light, and every other tone
74
+ moves proportionally. Per scheme and mode:
75
+
76
+ ```text
77
+ light: t' = remap(t, lightTone)
78
+ dark, auto: t' = remap(100 - t, darkTone)
79
+ dark, fixed: t' = remap(t, darkTone)
80
+ static: t' = t
81
+ high contrast: the same, with the window [0, 100]
82
+
83
+ remap(t, [lo, hi]) = tone(lo) + t / 100 × (tone(hi) - tone(lo))
84
+ ```
85
+
86
+ <WindowMap caption="Authored tones 0–100 (top row) and the tone Glaze stores for each in light and dark. The light window lifts the bottom of the scale; the dark window reverses it and trims both ends." />
87
+
88
+ A window takes three forms:
89
+
90
+ * `[lo, hi]` — OKHSL lightness bounds at the reference eps. The common form.
91
+ * `{ lo, hi, eps }` — the same bounds with an explicit render eps for that
92
+ scheme (the reference eps is `0.05`). Glaze still stores the result on the
93
+ reference scale, so deltas and contrast stay comparable. Advanced; most
94
+ palettes keep the default.
95
+ * `false` — the full range `[0, 100]`. This removes the boundaries, not the
96
+ tone transfer.
97
+
98
+ Set windows globally with `glaze.configure()` or per theme or token with a
99
+ config override (`glaze(280, 80, { darkTone: [10, 98] })`). The defaults and
100
+ every field are listed in [Configuration](./reference/configuration.md#tone-windows).
101
+
102
+ Some colors skip a window:
103
+
104
+ * **High-contrast variants** ignore both windows and use the full range. See
105
+ [High contrast](#high-contrast).
106
+ * **`mode: 'static'` colors** skip both windows in every scheme. See
107
+ [Adaptation modes](#adaptation-modes).
108
+ * **A color seeded with `from`** (a literal hex, `rgb()`, `oklch()`, … value)
109
+ skips the light window, so its light variant reproduces the literal exactly,
110
+ and so does its light high-contrast variant, whose window is the full range
111
+ anyway. Dark, dark high contrast, and a contrast floor still adapt it. See
112
+ [`from`](./reference/color-definitions.md#from).
113
+ * **An extreme with a base** (`tone: 'max'` / `'min'`) can land outside the dark
114
+ window. See [Extremes in dark](#extremes-in-dark).
115
+
116
+ ## Adaptation modes
117
+
118
+ A color's `mode` decides how its tone moves between schemes. In light, `auto`
119
+ and `fixed` both remap the authored tone into the light window, and `static`
120
+ keeps it as authored. They differ in dark:
121
+
122
+ | Mode | In dark | Use it for |
123
+ | ------------------ | -------------------------------------------- | --------------------------------- |
124
+ | `'auto'` (default) | Inverted, then remapped into the dark window | Surfaces, text, borders, icons |
125
+ | `'fixed'` | Remapped into the dark window, not inverted | Brand fills, CTAs, status banners |
126
+ | `'static'` | The authored tone, as in light | Logos and other exact colors |
127
+
128
+ `auto` suits anything that should trade places with its background in dark;
129
+ `fixed` keeps a color on its side of the scale, as brand fills and inverse
130
+ surfaces need.
131
+
132
+ <SchemeExample
133
+ hue={280}
134
+ saturation={60}
135
+ colors={{
136
+ 'pale-auto': { tone: 92 },
137
+ 'deep-auto': { tone: 25 },
138
+ 'pale-fixed': { tone: 92, mode: 'fixed' },
139
+ 'deep-fixed': { tone: 25, mode: 'fixed' },
140
+ 'pale-static': { tone: 92, mode: 'static' },
141
+ 'deep-static': { tone: 25, mode: 'static' },
142
+ }}
143
+ rows={[
144
+ { label: "mode: 'auto'", show: ['pale-auto', 'deep-auto'] },
145
+ { label: "mode: 'fixed'", show: ['pale-fixed', 'deep-fixed'] },
146
+ { label: "mode: 'static'", show: ['pale-static', 'deep-static'] },
147
+ ]}
148
+ caption="The same two tones under each mode. auto swaps them, fixed keeps them on their side and only remaps them into the dark window, static renders the authored tone everywhere."
149
+ />
150
+
151
+ `static` skips more than the window: it also skips
152
+ [dark desaturation](#dark-desaturation) and any [dark seed](#dark-seed), so the
153
+ color is pinned to one tone, hue, and saturation. In high-contrast variants,
154
+ `auto` still inverts and `fixed` / `static` don't.
155
+
156
+ <Callout kind="note" title="Inversion is not autoFlip">
157
+ Dark tone inversion is controlled by `mode` alone. `autoFlip` is unrelated:
158
+ it only lets an overshooting tone delta, or a contrast floor that can't be met
159
+ on the requested side, reverse around its base. See
160
+ [autoFlip](./contrast.mdx#autoflip-crossing-to-the-other-side).
161
+ </Callout>
162
+
163
+ For a worked palette that picks a mode per token — surfaces and text `auto`,
164
+ accent fills `fixed` — see [Designing a palette](./methodology.mdx).
165
+
166
+ ## How relative tone adapts
167
+
168
+ A tone delta (`'+N'` / `'-N'`) places a dependent color relative to its base,
169
+ and the base is resolved separately in each scheme. What happens to the delta
170
+ depends on the dependent's mode:
171
+
172
+ * **`auto`** — in light, the delta is applied to the base's light tone as
173
+ written. In dark, Glaze steps from where the base sits on the authored scale
174
+ (its [anchor](#where-a-dark-step-starts)), then inverts and remaps the result
175
+ through the dark window, exactly like an authored tone. The step flips
176
+ direction, keeps its place in the ladder, and comes out the dark window's
177
+ scale times the delta, about 0.87× at the defaults: `'-4'` becomes +3.5 in
178
+ dark on a tone-97 surface and on a tone-50 base alike (and `'+4'` on the
179
+ tone-50 base becomes −3.5), and a chain of deltas steps the same way at
180
+ every link. In high contrast the windows are the full range, so at the
181
+ reference dark `eps` the step is exact in both schemes, except near the ends
182
+ of the scale ([Overshooting deltas](#overshooting-deltas)).
183
+ * **`fixed`** — the delta is applied to the base's tone in each scheme with its
184
+ sign preserved. A `'+30'` label stays 30 tones lighter than its fill in light
185
+ and in dark. A label on a `fixed` fill should be `fixed` too: the inversion
186
+ follows the label's own mode, so an `auto` label inverts in dark and lands on
187
+ the other side of its fill (30 tones above it in light, 26.0 below it in
188
+ dark).
189
+ * **`static`** — the delta is applied the same way as `fixed`, to the base's
190
+ tone in each scheme, without inversion. A static dependent of an adaptive base
191
+ therefore still moves with that base; only its own tone mapping is skipped.
192
+
193
+ <SchemeExample
194
+ hue={280}
195
+ saturation={30}
196
+ colors={{
197
+ surface: { tone: 97 },
198
+ 'surface-2': { base: 'surface', tone: '-4' },
199
+ border: { base: 'surface', tone: '-12' },
200
+ text: { base: 'surface', tone: '-52' },
201
+ fill: { tone: 52, mode: 'fixed' },
202
+ 'fill-text': { base: 'fill', tone: '+30', mode: 'fixed' },
203
+ }}
204
+ schemes={['light', 'dark', 'lightContrast', 'darkContrast']}
205
+ delta
206
+ ratio
207
+ caption="Each dependent is drawn over its base, with its tone step from the base (Δ) and the measured WCAG ratio. The auto ladder flips in dark with every step about 0.87× as large; the fixed pair keeps +30 everywhere; high contrast keeps every step exact."
208
+ />
209
+
210
+ The `text` row shows why legibility belongs to `contrast`, not to a delta: the
211
+ same `'-52'` step measures 4.93:1 in light and 3.92:1 in dark. A delta is a
212
+ visual relationship; a [contrast floor](./contrast.mdx) is a guarantee that is
213
+ solved in every scheme.
214
+
215
+ ### Where a dark step starts
216
+
217
+ An `auto` dependent steps in dark from its base's **anchor**: where the base
218
+ sits on the authored scale, from 0 to 100, which is the tone an `auto` color
219
+ would be authored at to land where the base sits in dark. Glaze finds the
220
+ anchor from how the base got its tone:
221
+
222
+ | Base | Anchor |
223
+ | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
224
+ | An `auto` color placed by a tone: a number, a `from` color, or an extreme with no base | Its authored tone |
225
+ | An `auto` tone delta | Its base's anchor plus the delta |
226
+ | An `auto` color with no `tone` or `contrast` of its own | Its base's anchor |
227
+ | Any other color: `fixed` or `static`, `auto` with a contrast floor, an `auto` extreme on a base, or a mix | Its own dark tone, read back through the dark window |
228
+
229
+ The fourth row is why a dependent follows where its base lands in dark, not
230
+ where it sits in light. A contrast floor solves the base's dark tone on its
231
+ own, and a mix blends two dark variants, yet the steps off them stay even:
232
+
233
+ <SchemeExample
234
+ hue={280}
235
+ saturation={40}
236
+ colors={{
237
+ surface: { tone: 97 },
238
+ button: { base: 'surface', contrast: 'AAA' },
239
+ 'button-hover': { base: 'button', tone: '-4' },
240
+ wash: { type: 'mix', base: 'surface', target: 'button', value: 30 },
241
+ 'wash-hover': { base: 'wash', tone: '-6' },
242
+ }}
243
+ show={['button', 'button-hover', 'wash', 'wash-hover']}
244
+ schemes={['light', 'dark', 'lightContrast', 'darkContrast']}
245
+ delta
246
+ caption="Hovers on a contrast-solved button and on a mix. The button's own step from surface differs between light and dark, because the floor is solved per scheme, yet button-hover steps +3.5 in dark and wash-hover +5.2, both 0.87× their delta; high contrast keeps them exact."
247
+ />
248
+
249
+ A `fixed` or `static` base doesn't invert: it keeps its side of the scale,
250
+ or, as a `fixed` delta, rides its own base into dark with its sign kept. An
251
+ `auto` dependent still inverts, so it mirrors to the other side of wherever
252
+ the base sits. In `glaze(280, 30)`, an `auto` `'+30'` label on a `fixed` fill
253
+ at tone 52 lands 26.0 below it in dark, and an `auto` `'-4'` hover on
254
+ `{ base: 'surface', tone: '-10', mode: 'fixed' }` over a tone-97 surface steps
255
+ +3.5. Make the dependent `fixed` too when it should keep its side with its
256
+ base, as a label on a brand fill does.
257
+
258
+ The 0.87× scale holds at the reference dark `eps`. A custom dark `eps` bends
259
+ the dark window's curve, so a dark delta lands where the same authored tone
260
+ would rather than at a fixed scale: under
261
+ `darkTone: { lo: 15, hi: 95, eps: 0.2 }`, `'+4'` and `'-4'` on a tone-50 base
262
+ step −3.1 and +3.0 in dark, exactly where tones 54 and 46 land. High contrast
263
+ keeps the dark window's `eps`, so its dark steps bend the same way.
264
+
265
+ ### Overshooting deltas
266
+
267
+ When `base + delta` falls outside `[0, 100]`, the delta is mirrored to the other
268
+ side of the base — `'+10'` on a tone-97 surface resolves to 87.1 in light
269
+ instead of clamping at 100. If the mirrored target is also out of range, the
270
+ original delta is kept and clamped on the authored side. This is `autoFlip`,
271
+ on by default; set `autoFlip: false` on the color (or globally) to always clamp
272
+ on the authored side. Under `mode: 'auto'` the flip is decided on the light
273
+ tones, and dark mirrors whichever side light took.
274
+
275
+ Because dark steps from the anchor rather than the light tone, it has two
276
+ limits of its own:
277
+
278
+ * **A step that runs off the scale.** The flip is decided on the base's light
279
+ tone, so a step that fits there can still run past 0 or 100 from the
280
+ anchor. In dark it then stops at the dark window's edge. An `auto` base
281
+ placed by a tone meets this at the bottom, because the light window lifts
282
+ authored tone 0 to 4.95: `'-4'` on a tone-2 base steps about +1.7 in dark,
283
+ and on a tone-0 base not at all. A base whose anchor is read back from its
284
+ dark tone can meet it at either end. In `glaze(280, 40)`, text with
285
+ `contrast: 12` on a tone-97 surface sits at 15.6 in light, but its dark tone
286
+ reads back to 2.7, so a `'-4'` hover on it steps +2.3 in dark, not +3.5.
287
+ A `fixed` or `static` base near the dark end meets it hardest, because it
288
+ keeps its side while the dependent inverts: an `auto` `'+20'` on a `static`
289
+ tone-10 base steps up to 30 in light, but its mirrored step in dark runs off
290
+ the scale and stops at 9.25, next to the base. Add a contrast floor if the
291
+ step has to survive, or make the dependent `fixed` or `static` too.
292
+ * **A base past the dark window.** A contrast floor or an extreme on a base can
293
+ push a base past the dark window's edge, and a `fixed` delta can ride its base
294
+ past it: a `fixed` `'-10'` chip on a tone-97 surface sits at 1.85 in dark,
295
+ below the window's 9.25. A step back toward the window moves by the full
296
+ scaled delta; a step further out stops at the window's edge or at the base,
297
+ whichever is further out, instead of snapping back to the edge.
298
+ An `ink: { base: 'surface', tone: 'min' }` on a tone-97 surface sits at
299
+ tone 100 in dark, past the window's 95.9: `'+4'` on it steps −3.5 there,
300
+ and `'-4'` stays on `ink`.
301
+
302
+ High contrast's windows are the full range. At the reference light `eps` the
303
+ light one is the identity, so an `auto` base placed by a tone sits at its
304
+ anchor there, light and dark agree on the flip, and the step fits. The first
305
+ limit still applies under a custom light `eps`, and to a base read back from
306
+ its dark tone: in `glaze(280, 40)`, a `'-8'` hover on
307
+ `{ base: 'surface', contrast: 3 }` over a tone-40 surface steps −8 in light
308
+ high contrast and +4.0 in dark high contrast.
309
+
310
+ ## Extremes in dark
311
+
312
+ `tone: 'max'` and `tone: 'min'` force a color to the end of the scheme's tone
313
+ scale without a magic number. `'max'` is authored tone 100 and `'min'` is 0, so
314
+ without a `base` they go through the ordinary scheme mapping:
315
+
316
+ * Under `auto`, they invert: `'max'` is the lightest color in light (tone 100)
317
+ and the darkest in dark (tone 9.25, the bottom of the dark window).
318
+ * Under `fixed`, they keep their end: `'max'` stays the lightest (tone 95.9 in
319
+ dark).
320
+ * Under `static`, they're pinned to the absolute end in every scheme.
321
+
322
+ With a `base`, an extreme keeps its contrast with that base instead. Remapping
323
+ the extreme through the narrower dark window would squeeze the span between
324
+ the two, so Glaze measures the tone shift the light scheme applied between base
325
+ and extreme, then replays it against the base's dark tone — mirrored under
326
+ `auto`, same-signed under `fixed`.
327
+
328
+ <SchemeExample
329
+ hue={0}
330
+ saturation={0}
331
+ colors={{
332
+ bg: { tone: 60 },
333
+ fg: { base: 'bg', tone: 'max' },
334
+ top: { tone: 'max' },
335
+ bottom: { tone: 'min' },
336
+ }}
337
+ delta
338
+ ratio
339
+ caption="fg replays the light shift (+38) as -38 in dark, keeping the same ratio. The root extremes top and bottom invert and land on the window edges."
340
+ />
341
+
342
+ The replayed tone is clamped to `[0, 100]` only, so it can cross the `darkTone`
343
+ boundary — `fg` above lands at tone 5.9, below the dark window's 9.25. That is
344
+ intended: you asked for the extreme. When the shift doesn't fit above or below
345
+ the dark base, the color pins at tone 100 or 0. `static` extremes are
346
+ unaffected. High-contrast variants follow the same rule, but their window is
347
+ already the full range, so the replay matches the plain mapping unless the base
348
+ itself sits asymmetrically across schemes.
349
+
350
+ ## Hue and saturation in dark
351
+
352
+ Tone is the only channel the scheme pipeline adapts. Hue and saturation enter
353
+ it as authored, with two adjustments for the dark schemes.
354
+
355
+ ### Dark desaturation
356
+
357
+ Dark variants get a flat saturation reduction, set by `darkDesaturation`
358
+ (default `0.1`):
359
+
360
+ ```text
361
+ S_dark = S_light × (1 − darkDesaturation)
362
+ ```
363
+
364
+ It applies to `dark` and `darkContrast`, skips `static` colors, and is bypassed
365
+ entirely for a color that has an explicit dark saturation (below). Set
366
+ `darkDesaturation` globally or per instance.
367
+
368
+ <SchemeExample
369
+ hue={280}
370
+ saturation={80}
371
+ colors={{
372
+ accent: { tone: 55 },
373
+ logo: { tone: 55, mode: 'static' },
374
+ }}
375
+ value="channels"
376
+ caption="Seed saturation 80: accent loses 10% of its saturation in dark; the static logo keeps it."
377
+ />
378
+
379
+ ### Dark seed
380
+
381
+ When a uniform reduction isn't enough — a hue that turns muddy on dark
382
+ surfaces, an accent that stays loud however far you turn the global knob — give
383
+ the theme a second seed for the dark schemes:
384
+
385
+ <SchemeExample
386
+ hue={280}
387
+ saturation={80}
388
+ darkHue={250}
389
+ darkSaturation={50}
390
+ colors={{
391
+ accent: { tone: 55 },
392
+ 'accent-alt': { tone: 55, hue: '+20' },
393
+ warning: { tone: 55, hue: 40 },
394
+ link: { tone: 55, hue: '+20', darkHue: '+35' },
395
+ highlight: { tone: 55, saturation: 0.9, darkSaturation: 0.6 },
396
+ logo: { tone: 55, mode: 'static' },
397
+ }}
398
+ value="channels"
399
+ caption="Hue and saturation per scheme. Relative hues re-anchor to the dark seed, the absolute warning hue stays put, link and highlight override dark on their own, and the static logo ignores the dark seed."
400
+ />
401
+
402
+ How the dark channels resolve:
403
+
404
+ * **Both levels.** `darkHue` / `darkSaturation` exist on the seed and on each
405
+ color, and apply to `dark` and `darkContrast`. On the seed, `darkSaturation`
406
+ is `0–100` like `saturation`; on a color it is a `0–1` factor of the dark seed
407
+ saturation, like a color's `saturation`.
408
+ * **Fallbacks.** A missing dark value falls back to its light counterpart, and
409
+ a color falls back to the seed. Omitting all of them reproduces the single-seed
410
+ output exactly.
411
+ * **Relative hue follows the dark seed.** `darkHue: '+N'` is relative to the dark
412
+ seed hue, and so is a plain `hue: '+N'` with no `darkHue`, so a whole palette
413
+ rotates together.
414
+ * **Absolute hue stays absolute.** A numeric `hue` with no `darkHue` is used
415
+ verbatim in dark.
416
+ * **No double reduction.** Any explicit dark saturation, on the seed or the
417
+ color, replaces `darkDesaturation` instead of stacking with it. The number you
418
+ write is the number you get. A
419
+ [`from`](./reference/color-definitions.md#from) color with neither
420
+ `saturation` nor `darkSaturation` of its own is the exception: it starts from
421
+ the literal's saturation, ignores a seed-level `darkSaturation`, and still
422
+ gets `darkDesaturation`. Give it a `saturation` and it reads the seed again,
423
+ dark seed included.
424
+ * **`static` ignores both**, and **shadows, glows, and mixes** have no channels
425
+ of their own: they derive hue and saturation from the colors they reference,
426
+ so they inherit dark overrides automatically.
427
+
428
+ With `splitHue` output, a dark seed makes the hue custom properties
429
+ scheme-dependent, and Glaze re-declares them in the dark block — see
430
+ [Dark hues](./reference/output.md#dark-hues). The full field rules are in
431
+ [Color definitions](./reference/color-definitions.md#dark-seed-darkhue--darksaturation); prefer tuning the seed
432
+ over scattering per-color dark overrides, as
433
+ [Designing a palette](./methodology.mdx) explains.
434
+
435
+ ## High contrast
436
+
437
+ The `lightContrast` and `darkContrast` variants serve readers who ask for more
438
+ contrast — by default through `@media(prefers-contrast: more)`. They differ from
439
+ the normal variants in three ways:
440
+
441
+ 1. **No tone windows.** Both windows are bypassed and the full `[0, 100]` range
442
+ is available, so edge tones reach the edge and contrast floors have more
443
+ room to solve. `auto` still inverts; `fixed` and `static` don't.
444
+ 2. **High-contrast pairs.** Any value written as `[normal, highContrast]` takes
445
+ its second entry.
446
+ 3. **Escalated contrast floors.** Unless you write an explicit high-contrast
447
+ value, WCAG presets step up (`AA → AAA`, `AA-large → AAA-large`) and APCA
448
+ targets gain 15 Lc. A bare WCAG number (`4.5`, `{ wcag: 6 }`) and the AAA
449
+ presets are not escalated; give them an explicit pair such as `[4.5, 7]`.
450
+ See [Contrast solving](./contrast.mdx#high-contrast).
451
+
452
+ <WindowMap
453
+ rows={[
454
+ { scheme: 'light' },
455
+ { scheme: 'lightContrast' },
456
+ { scheme: 'dark' },
457
+ { scheme: 'darkContrast' },
458
+ ]}
459
+ caption="Authored tones 0–100 in each scheme. The high-contrast rows use the full scale: auto still inverts, but nothing is trimmed."
460
+ />
461
+
462
+ High-contrast variants are always resolved. Whether they're exported is up to
463
+ [output modes](#output-modes), which leave them out by default.
464
+
465
+ ### High-contrast pairs
466
+
467
+ `HCPair<T>` is either one value used in both tiers or an explicit
468
+ `[normal, highContrast]` pair:
469
+
470
+ ```ts
471
+ type HCPair<T> = T | [T, T];
472
+ ```
473
+
474
+ It is accepted by `tone`, `contrast`, shadow and glow `intensity`, and mix
475
+ `value`:
476
+
477
+ ```ts
478
+ theme.colors({
479
+ surface: { tone: 97 },
480
+ // -8 in both tiers
481
+ divider: { base: 'surface', tone: '-8' },
482
+ // -8 normal, -16 high contrast
483
+ border: { base: 'surface', tone: ['-8', '-16'] },
484
+ // APCA preset: Lc 60, escalated to 75 in high contrast
485
+ text: { base: 'surface', contrast: { apca: 'content' } },
486
+ // Explicit pair: Lc 60 normal, Lc 90 high contrast
487
+ 'text-strong': {
488
+ base: 'surface',
489
+ contrast: { apca: ['content', 'preferred'] },
490
+ },
491
+ });
492
+ ```
493
+
494
+ For `contrast`, the pair can wrap the whole spec (`[4.5, 7]`) or sit inside the
495
+ metric (`{ wcag: [4.5, 7] }`). An explicit high-contrast value turns off the
496
+ automatic WCAG promotion or APCA boost for that color.
497
+
498
+ <SchemeExample
499
+ hue={280}
500
+ saturation={30}
501
+ colors={{
502
+ surface: { tone: 97 },
503
+ border: { base: 'surface', tone: ['-12', '-24'] },
504
+ text: { base: 'surface', tone: '-45', contrast: 'AA' },
505
+ }}
506
+ schemes={['light', 'dark', 'lightContrast', 'darkContrast']}
507
+ ratio
508
+ caption="The border doubles its step through an HC pair; the text's AA floor is promoted to AAA in high contrast."
509
+ />
510
+
511
+ High contrast is not a separate palette. Add pairs where readers should get
512
+ more separation — text and content contrast, accent fills, borders, shadows
513
+ and glows, decorative ramps that must stay perceivable — and leave the rest to
514
+ the window bypass and the automatic escalation.
515
+
516
+ ## Manual contrast level
517
+
518
+ The two tiers assume contrast is a binary OS preference. When it is a setting
519
+ your users control, set `contrastLevel` to a number from `0` to `100`: the
520
+ **normal** variants slide from the palette you authored (`0`) to its
521
+ high-contrast resolution (`100`), and every level in between is resolved at
522
+ that level. The default, `'auto'`, takes the authored normal entries as they
523
+ are. The authoring work is the same: the high-contrast pairs you already wrote
524
+ are what the slider ramps toward.
525
+
526
+ `contrastLevel` is a config field, so it works globally, per theme, per token,
527
+ and through `extend()`:
528
+
529
+ ```ts
530
+ glaze.configure({ contrastLevel: 60 });
531
+ const theme = glaze(280, 80, { contrastLevel: 60 });
532
+ glaze.color('#26fcb2', { contrastLevel: 60 });
533
+ ```
534
+
535
+ <SchemeExample
536
+ visual="levels"
537
+ hue={280}
538
+ saturation={60}
539
+ colors={{
540
+ surface: { tone: 97, saturation: 0.2 },
541
+ border: { base: 'surface', tone: ['-12', '-24'], saturation: 0.3 },
542
+ text: { base: 'surface', tone: '-45', contrast: 'AA', saturation: 0.5 },
543
+ 'accent-label': { tone: 100, mode: 'fixed' },
544
+ accent: {
545
+ base: 'accent-label',
546
+ tone: '-30',
547
+ contrast: { apca: ['content', 'body'] },
548
+ mode: 'fixed',
549
+ },
550
+ }}
551
+ after={
552
+ '// Each column is this theme with a config override:\n' +
553
+ '// { contrastLevel: 0 | 25 | 50 | 75 | 100 }'
554
+ }
555
+ caption="The normal light and dark variants at five levels. Under each: the measured WCAG ratio of text and border against the surface, and the APCA Lc of the accent label on its fill, whose floor ramps from 'content' (Lc 60) to 'body' (Lc 75). Level 0 is the authored palette; level 100 equals the theme's high-contrast tier (last column)."
556
+ />
557
+
558
+ What the level moves:
559
+
560
+ * **Authored pairs** — `tone`, `contrast`, `intensity`, and mix `value` pairs
561
+ are interpolated between their two entries.
562
+ * **The tone windows** — each endpoint walks toward the full range: light
563
+ `10 → 0`, dark `15 → 0` and `95 → 100`.
564
+ * **Contrast escalation** — the normal and escalated targets are interpolated,
565
+ so `AA` at level 50 solves for 5.75:1.
566
+
567
+ The interpolated values go through the ordinary resolve, so a contrast floor is
568
+ **solved** at every level, not approximated. Hue, saturation,
569
+ `darkDesaturation`, `opacity`, and `pastel` don't change with the level.
570
+
571
+ Level `0` reproduces `'auto'` and level `100` reproduces the high-contrast
572
+ output, bit for bit. The high-contrast tier itself ignores the level:
573
+ `lightContrast` / `darkContrast` stay the true high-contrast resolution, so a
574
+ `prefers-contrast: more` block still escalates on top of the reader's chosen
575
+ level. At a global level of `100` the tier would duplicate the normal set, so
576
+ it is dropped.
577
+
578
+ A pair with no midpoint switches from its normal entry to its high-contrast one
579
+ at level 50: one that mixes kinds (`[50, 'max']`, `[50, '+20']`), or any pair
580
+ of extremes (`['max', 'min']`), which have no magnitude to blend. A
581
+ contrast-solved color prefers the side of its base that the nearer endpoint
582
+ (level 0 or 100) resolves to, so its preferred side switches only at
583
+ level 50. At any level where the preferred side can't reach the target, the
584
+ color falls back to the other side. The full rules are in
585
+ [Manual contrast level](./reference/contrast.md#manual-contrast-level).
586
+
587
+ Because `configure()` invalidates every theme cache, a runtime slider only needs
588
+ to re-export:
589
+
590
+ ```ts
591
+ slider.oninput = () => {
592
+ glaze.configure({ contrastLevel: slider.valueAsNumber });
593
+ apply(theme.css());
594
+ };
595
+ ```
596
+
597
+ `contrastLevel: 0` is the natural rest position for a slider you ship but
598
+ default off. `configure()` never clears a field by omission, so pass
599
+ `contrastLevel: 'auto'` to leave manual mode. How `.export()` treats a level,
600
+ and what happens when a dependent and its base sit at different levels, is in
601
+ [Clearing it, exporting it, base links](./reference/contrast.md#clearing-it-exporting-it-base-links).
602
+
603
+ ## Output modes
604
+
605
+ Resolution always produces four variants; `modes` decides which ones an export
606
+ includes:
607
+
608
+ ```ts
609
+ // Light only
610
+ theme.tokens({ modes: { dark: false } });
611
+
612
+ // Light + dark (the default)
613
+ theme.tokens();
614
+
615
+ // All four
616
+ theme.tokens({ modes: { dark: true, highContrast: true } });
617
+ // → { light, dark, lightContrast, darkContrast }
618
+ ```
619
+
620
+ `modes` is accepted by `tokens()`, `tasty()`, `json()`, `dtcg()`,
621
+ `dtcgResolver()`, and `tailwind()`, on themes and palettes alike. `css()` has
622
+ no `modes` option: it always returns all four declaration blocks. A per-call
623
+ `modes` overrides `glaze.configure({ modes })`, and a global `contrastLevel` of
624
+ `100` drops the high-contrast tier even when `modes` asks for it; the full
625
+ precedence is in [Output modes](./reference/output.md#output-modes).
626
+
627
+ A third flag, `hdr`, is off by default and is only for
628
+ [HDR glows](./hdr.mdx#output-is-opt-in).
629
+
630
+ The selectors that switch variants in [Tasty](../../tasty/README.md) output come
631
+ from [`states.dark` and `states.highContrast`](./reference/output.md#state-aliases)
632
+ (by default the `prefers-color-scheme: dark` and `prefers-contrast: more` media
633
+ queries). How each export lays out its variants is in
634
+ [Output formats](./reference/output.md) and
635
+ [Exporting and integration](./exporting.mdx).