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