@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,679 @@
1
+ ---
2
+ title: Tone and OKHST
3
+ description: The contrast-uniform tone axis Glaze authors every color on, its math and guarantees, and how a tone becomes a rendered color in each scheme.
4
+ ---
5
+ import { Preview } from '@tenphi/cookbook/components';
6
+ import Example from '../docs-site/components/Example.astro';
7
+ import ToneRamp from '../docs-site/components/ToneRamp.tsx';
8
+ import ToneDrift from '../docs-site/components/okhst/ToneDrift.tsx';
9
+ import SchemeRamp from '../docs-site/components/okhst/SchemeRamp.tsx';
10
+ import ToneLadder from '../docs-site/components/okhst/ToneLadder.tsx';
11
+ import FloorCompare from '../docs-site/components/okhst/FloorCompare.tsx';
12
+ import { rampExplorer } from '../docs-site/components/okhst/ramp-explorer.ts';
13
+
14
+ export const ramp = rampExplorer();
15
+
16
+ Glaze places every color in [OKHST](https://github.com/tenphi/okhst): OKHSL's
17
+ hue and saturation, with the lightness axis replaced by **tone** — a 0–100
18
+ axis shaped so that equal tone steps give equal WCAG contrast. This page is the
19
+ reference for the color model as Glaze applies it: the transfer math, what tone
20
+ does and does not guarantee, and how an authored tone becomes a rendered color
21
+ in each scheme. The [OKHST repository](https://github.com/tenphi/okhst) is the
22
+ canonical specification of the color space itself: transfer functions,
23
+ derivation, and invariants.
24
+
25
+ To build a palette on this model, continue to
26
+ [Designing a palette](./methodology.mdx). Every option and utility named here is
27
+ listed in the [API reference](./reference/index.md).
28
+
29
+ ## OKHSL with a tone axis
30
+
31
+ OKHST keeps OKHSL's hue (`h`, 0–360) and saturation (`s`) verbatim and swaps
32
+ the third coordinate:
33
+
34
+ | Space | Coordinates | Third coordinate |
35
+ | ----- | ----------- | -------------------------------------------------- |
36
+ | OKHSL | `h, s, l` | `l`: perceptual lightness (toe-adjusted OKLab `L`) |
37
+ | OKHST | `h, s, t` | `t`: tone, a normalized logarithm of luminance |
38
+
39
+ You author tone on a 0–100 scale (`tone: 97`). Resolved variants and the
40
+ `{ h, s, t }` input object carry it on 0–1, like saturation (hue stays 0–360).
41
+
42
+ OKHSL lightness is perceptually even, but equal lightness steps produce uneven
43
+ contrast: the ratio between neighbors changes along the axis. Tone is shaped so
44
+ equal steps produce equal WCAG contrast. Two things follow: a ramp authored in
45
+ tone is a contrast-even ladder for free, and dark-mode inversion is a single
46
+ subtraction (`100 - t`) instead of a fitted curve.
47
+
48
+ ### Equal tone steps, equal contrast
49
+
50
+ <ToneRamp saturation={0} caption="Eleven neutral steps, evenly spaced in tone. The row underneath is the WCAG ratio between neighbors: 1.36:1 at every step." />
51
+
52
+ <ToneRamp saturation={0} axis="lightness" caption="The same eleven steps evenly spaced in OKHSL lightness. They look even, but the ratio between neighbors runs from 1.16:1 at the dark end up to 1.46:1 and back down to 1.29:1." />
53
+
54
+ ### Ramp explorer
55
+
56
+ Build a ramp for any hue and saturation, spaced in tone or in OKHSL lightness.
57
+ A gray tone ramp keeps the same ratio between every pair of neighbors. A
58
+ saturated one stays close: hue 280 at saturation 80 runs from 1.32:1 to 1.38:1
59
+ in tone, against 1.14:1 to 1.46:1 in lightness (see
60
+ [chromatic drift](#uniform-in-lightness-not-luminance)). Open **Values** to copy
61
+ each step as hex and `oklch()`.
62
+
63
+ <Preview title="Try it: a ramp in tone or in lightness" {...ramp} />
64
+
65
+ ## The tone transfer
66
+
67
+ For a gray (`s = 0`) at OKHSL lightness `l`, luminance has a closed form through
68
+ the OKHSL toe and the OKLab cube:
69
+
70
+ ```text
71
+ Y = toeInv(l)^3 // OKLab L = toeInv(l), cubed
72
+ l = toe(cbrt(Y)) // exact inverse
73
+ ```
74
+
75
+ Tone is a normalized natural logarithm of `Y`, offset by a small `eps`:
76
+
77
+ ```text
78
+ D = ln(1 + eps) - ln(eps)
79
+
80
+ toneFromY(Y, eps) = 100 * (ln(Y + eps) - ln(eps)) / D
81
+ yFromTone(T, eps) = exp(T / 100 * D + ln(eps)) - eps
82
+ ```
83
+
84
+ `toTone(l)` and `fromTone(t)` compose these with the gray luminance above, so
85
+ they convert between OKHSL lightness (0–1) and tone (0–100) directly. The two
86
+ directions are exact analytic inverses: a round trip is lossless to
87
+ floating-point precision. `toneFromY(0)` is 0 and `toneFromY(1)` is 100 for any
88
+ `eps`, so tone is always a clean 0–100 scale.
89
+
90
+ ### Why `eps` is 0.05
91
+
92
+ WCAG 2 contrast is `(Y_hi + 0.05) / (Y_lo + 0.05)`. With `eps = 0.05`, tone is a
93
+ normalized `ln(Y + 0.05)`, so a fixed tone difference is a fixed ratio of
94
+ `Y + 0.05` — a fixed WCAG contrast — wherever it sits on the scale. Since
95
+ `ln(1.05) - ln(0.05) = ln(21)`:
96
+
97
+ ```text
98
+ ratio(T2, T1) = (Y2 + 0.05) / (Y1 + 0.05)
99
+ = exp((T2 - T1) / 100 * ln(21))
100
+ = 21 ^ ((T2 - T1) / 100)
101
+ ```
102
+
103
+ The whole scale is 21:1, black to white, and every 10 tones multiply contrast by
104
+ `21 ^ 0.1`, about 1.36. For neutrals:
105
+
106
+ | Tone | Contrast vs. black |
107
+ | ---: | -----------------: |
108
+ | 10 | 1.36:1 |
109
+ | 20 | 1.84:1 |
110
+ | 30 | 2.49:1 |
111
+ | 40 | 3.38:1 |
112
+ | 50 | 4.58:1 |
113
+ | 60 | 6.21:1 |
114
+ | 70 | 8.42:1 |
115
+ | 80 | 11.42:1 |
116
+ | 90 | 15.49:1 |
117
+ | 100 | 21:1 |
118
+
119
+ Inverted, the tone distance between two neutrals for a target ratio `R` is
120
+ `100 * ln(R) / ln(21)`:
121
+
122
+ | Ratio | Tone distance |
123
+ | ------------------ | ------------: |
124
+ | 3:1 (`'AA-large'`) | 36.08 |
125
+ | 4.5:1 (`'AA'`) | 49.40 |
126
+ | 7:1 (`'AAA'`) | 63.92 |
127
+
128
+ These distances are exact for neutrals only;
129
+ [Same tone, same lightness](#same-tone-same-lightness) explains why.
130
+
131
+ ### Tone above 100
132
+
133
+ The transfer has no ceiling: `toneFromY` keeps counting past SDR white
134
+ (`Y = 1`), so tone runs on past 100.
135
+
136
+ | Luminance `Y` | Stops above white | Tone |
137
+ | ------------: | ----------------: | -----: |
138
+ | 1 | 0 | 100 |
139
+ | 2 | 1 | 121.98 |
140
+ | 4 | 2 | 144.34 |
141
+
142
+ Each stop, a doubling of `Y`, adds about 22 tones. The scale is still a
143
+ logarithm of `Y + 0.05`, so equal tone steps past 100 are still equal ratios of
144
+ `Y + 0.05`: 12 tones past white (`Y` 1.4631, tone 112) is the same ratio as a
145
+ 12-tone step anywhere else. Contrast does not carry over. WCAG and APCA are
146
+ defined only up to SDR white, so a tone past 100 has no contrast meaning, and
147
+ the contrast solver never searches there.
148
+
149
+ Only [HDR glows](./hdr.mdx) go past 100, in an HDR value kept next to the SDR
150
+ one; a resolved variant's own `t` stays within 0–1.
151
+
152
+ ## Same tone, same lightness
153
+
154
+ `okhstToOkhsl({ h, s, t })` passes `h` and `s` through unchanged and sets
155
+ `l = fromTone(100 * t)`. `fromTone` depends on the tone (and `eps`) alone, and OKHSL's
156
+ `l → L` map has no hue or saturation term: `h` and `s` enter only the chroma
157
+ math. That gives OKHST its core invariant:
158
+
159
+ > A given tone yields the same OKHSL lightness for every hue and saturation.
160
+
161
+ OKHST inherits OKHSL's gamut and reversibility exactly: every `(h, s, t)` is
162
+ realizable and round-trips.
163
+
164
+ ### Uniform in lightness, not luminance
165
+
166
+ The invariant is about lightness. Equal tone gives equal WCAG and APCA contrast
167
+ only for grays: a saturated red and a saturated green at the same tone share an
168
+ OKHSL lightness but differ in real luminance `Y`, because hue, saturation, and
169
+ gamut mapping all change the final sRGB values. This **chromatic drift** is the
170
+ one approximation in the model.
171
+
172
+ <ToneDrift
173
+ rows={[
174
+ { hue: 0, saturation: 0, label: 'Neutral' },
175
+ { hue: 25, saturation: 100 },
176
+ { hue: 25, saturation: 50 },
177
+ { hue: 110, saturation: 100 },
178
+ { hue: 150, saturation: 100 },
179
+ { hue: 300, saturation: 100 },
180
+ ]}
181
+ caption="Each column is drawn at the authored tone above it. The label in each cell is its measured tone: the tone of the gray with the same luminance. Neutrals match exactly; chromatic colors drift, and less so at lower saturation."
182
+ />
183
+
184
+ At full saturation, the rows above read up to 5.6 tones darker than their tone
185
+ (hue 300, purple) and 4.1 darker (hue 25, red), up to 2.7 tones lighter
186
+ (hue 150, green), and within a tone for yellow (hue 110). Across all hues the
187
+ extremes are 5.6 tones darker, near hue 300, and 2.9 lighter, near hue 143.
188
+ Drift shrinks with saturation: hue 25 at tone 50 reads 3.9 tones dark at full
189
+ saturation and 1.6 at half.
190
+
191
+ That splits the work between two tools:
192
+
193
+ * Use **tone deltas** for visual spacing within ramps and between related UI
194
+ colors. The spacing stays even and ordered for every hue.
195
+ * Use **`contrast`** when a rendered color must meet a measured WCAG or APCA
196
+ floor. The solver is the one deliberate exception to the invariant: it moves a
197
+ color's tone per hue and saturation until the measured floor is met. See
198
+ [Contrast floors](#contrast-floors-and-verification).
199
+
200
+ ## Tone windows
201
+
202
+ Each scheme renders authored tone into a **tone window**, configured by
203
+ `lightTone` and `darkTone`:
204
+
205
+ ```ts
206
+ import { glaze } from '@tenphi/glaze';
207
+
208
+ glaze.configure({
209
+ lightTone: [10, 100],
210
+ darkTone: [15, 95],
211
+ });
212
+ ```
213
+
214
+ The window's `lo` and `hi` are **OKHSL-lightness** boundaries (0–100). Glaze
215
+ converts them to tone, positions the authored tone linearly in that tone
216
+ interval, converts the result back to lightness with the window's `eps`, and
217
+ stores it as canonical tone:
218
+
219
+ ```text
220
+ loT = toTone(lo / 100, eps)
221
+ hiT = toTone(hi / 100, eps)
222
+ l = fromTone(loT + (T / 100) * (hiT - loT), eps)
223
+ t = toTone(l) / 100 // canonical tone (0–1), REF_EPS
224
+ ```
225
+
226
+ A window takes three forms:
227
+
228
+ | Form | Meaning |
229
+ | ----------------- | --------------------------------------------------------------------------------- |
230
+ | `[lo, hi]` | Boundaries at the reference `eps`. The common form. |
231
+ | `{ lo, hi, eps }` | Boundaries plus a render curvature for this scheme. Advanced. |
232
+ | `false` | The full range `[0, 100]` at the reference `eps`: no boundaries, same tone curve. |
233
+
234
+ Endpoints clamp to 0–100. A value that is none of the three forms (`null`
235
+ included), a window with `lo` above `hi` or a non-finite endpoint, and an `eps`
236
+ outside `(0, 1e6]` all throw instead of quietly inverting or breaking the
237
+ scale. The `eps` cap is numerical: the transfer divides by
238
+ `ln(1 + eps) - ln(eps)`, which loses precision as `eps` grows and cancels to 0
239
+ (`NaN` tones) from about 1e15, while past about 1e3 the curve is already linear
240
+ in luminance. The rules are in
241
+ [Configuration](./reference/configuration.md#tone-windows).
242
+
243
+ ### Calibrated defaults
244
+
245
+ | | `lightTone` | `darkTone` |
246
+ | ---------- | ----------: | ---------: |
247
+ | `lo` | 10 | 15 |
248
+ | `hi` | 100 | 95 |
249
+ | `eps` | 0.05 | 0.05 |
250
+ | Tone range | 4.95–100 | 9.25–95.9 |
251
+
252
+ The floors keep mapped tones off pure black in both schemes (OKHSL lightness 10
253
+ in light, 15 in dark), and the dark ceiling keeps them short of white at
254
+ lightness 95. `eps` is pinned to the reference 0.05 so the axis stays
255
+ WCAG-uniform inside the window. Because the remap is linear in tone, it scales
256
+ distances between absolute tones uniformly: about 95% of the authored distance
257
+ in light and 86.6% in dark.
258
+
259
+ <SchemeRamp caption="Authored tones 0 to 100 (mode 'auto') resolved in every scheme. Each label is the tone Glaze stores. Light compresses into 4.95–100; dark inverts, then compresses into 9.25–95.9; high contrast uses the full range." />
260
+
261
+ ### High contrast uses the full range
262
+
263
+ High-contrast variants run the same math with the window forced to the full
264
+ range `[0, 100]`, keeping the window's `eps`. There is no separate high-contrast
265
+ curve and no fitted dark curve.
266
+
267
+ ### Manual contrast level
268
+
269
+ A [manual contrast level](./reference/contrast.md#manual-contrast-level) generalizes that bypass
270
+ continuously. With `contrastLevel: L` and `f = L / 100`, each boundary moves
271
+ toward the full range instead of jumping to it:
272
+
273
+ ```text
274
+ lo(f) = lo + (0 - lo) * f
275
+ hi(f) = hi + (100 - hi) * f
276
+ ```
277
+
278
+ The light default walks `[10, 100] → [0, 100]` and the dark default
279
+ `[15, 95] → [0, 100]`; at level 50 they are `[5, 100]` (tone 1.63–100) and
280
+ `[7.5, 97.5]` (tone 3.15–97.97). The render `eps` is not interpolated, and a
281
+ `false` window is already the full range, so it is level-invariant.
282
+
283
+ The continuous form applies to the normal variants only. High-contrast variants
284
+ are already at the full range, so the level does not widen them further: the
285
+ bypass is what keeps the high-contrast tier independent of the level. The level
286
+ itself is covered in
287
+ [Light, dark, and high contrast](./schemes.mdx#manual-contrast-level).
288
+
289
+ ## Reference and render epsilon
290
+
291
+ Two `eps` values play separate roles:
292
+
293
+ * **Reference eps** — `REF_EPS = 0.05`, fixed. It defines the OKHST color
294
+ space and Glaze's canonical tone. `okhst()` input, `{ h, s, t }` input, the
295
+ stored `ResolvedColorVariant.t`, tone deltas, and the contrast solver all use
296
+ it, which keeps tone comparable between schemes.
297
+ * **Render eps** — the `eps` of an object tone window. A per-scheme curvature
298
+ control that only changes how authored tone maps through that scheme's
299
+ window. It defaults to the reference value, so by default the two coincide.
300
+
301
+ When a window's `eps` differs from the reference, Glaze maps with it to land a
302
+ final OKHSL lightness, then stores `toTone(l, REF_EPS)`, so deltas and contrast
303
+ calculations stay on the reference scale. For example,
304
+ `lightTone: { lo: 10, hi: 100, eps: 0.2 }` stores authored tone 50 at 63.89
305
+ instead of 52.47: mid tones bend lighter. Most palettes should keep the default.
306
+
307
+ ## The scheme pipeline
308
+
309
+ A regular color's tone goes through one pipeline per scheme:
310
+
311
+ ```text
312
+ authored tone T (0–100)
313
+ -> adaptation mode
314
+ auto + dark invert: T' = 100 - T
315
+ light / fixed keep: T' = T
316
+ static identity; skip the window
317
+ -> remap into the scheme's tone window (full range for high contrast)
318
+ -> render curvature (window eps) -> OKHSL lightness l
319
+ -> store canonical tone t = toTone(l, REF_EPS) / 100
320
+ // variant { h, s, t, alpha }, t on 0–1
321
+ -> at the rendering edge only:
322
+ fromTone(100 * t) -> l -> sRGB / luminance
323
+ optional: a contrast floor searches tone and overrides t
324
+ ```
325
+
326
+ ### Adaptation modes
327
+
328
+ The first step is the color's `mode`. Use `auto` for surfaces and foregrounds
329
+ that should trade places between light and dark, `fixed` for brand fills and
330
+ inverse surfaces that should stay on the same side of the scale, and `static`
331
+ for colors that must not adapt.
332
+ [Light, dark, and high contrast](./schemes.mdx#adaptation-modes) has the full
333
+ table and shows each mode side by side.
334
+
335
+ **Dark tone inversion is not `autoFlip`.** Inversion is ordinary scheme
336
+ adaptation, controlled by `mode`. `autoFlip` solves a different, local problem:
337
+ it may mirror a tone delta that overshoots `[0, 100]` to the other side of its
338
+ base, or let the contrast solver try the opposite side of a base.
339
+
340
+ ### Hue and saturation pass through
341
+
342
+ Tone is the only channel OKHST adapts across schemes; `h` and `s` are authoring
343
+ inputs that pass through the tone transfer untouched. The one automatic
344
+ adjustment is `darkDesaturation`, which dark schemes apply to every color except
345
+ `static` ones. A theme, or a single color, can seed a different hue and
346
+ saturation for the dark schemes with `darkHue` / `darkSaturation`; those pick the
347
+ `h` and `s` that enter the pipeline and never change how tone is transferred.
348
+ An authored dark saturation replaces the `darkDesaturation` step instead of
349
+ stacking with it: a color's own `darkSaturation` always does, and the theme's
350
+ seed does for every color that reads it. A `from` color with no `saturation` of
351
+ its own starts from the literal's saturation rather than the seed, so it keeps
352
+ the step. `static` colors ignore all of it. The rules are in
353
+ [Light, dark, and high contrast](./schemes.mdx#dark-seed) and
354
+ [Color definitions](./reference/color-definitions.md#dark-seed-darkhue--darksaturation).
355
+
356
+ ## Tone values
357
+
358
+ A color's `tone` takes one of three kinds of value. The full syntax, including
359
+ `[normal, highContrast]` pairs, is in
360
+ [Color definitions](./reference/color-definitions.md#tone-values).
361
+
362
+ ```ts
363
+ import { glaze } from '@tenphi/glaze';
364
+
365
+ const theme = glaze(280, 30);
366
+
367
+ theme.colors({
368
+ surface: { tone: 97 }, // absolute
369
+ border: { base: 'surface', tone: '-8' }, // tone delta
370
+ deep: { tone: 'min' }, // extreme
371
+ ink: { base: 'surface', tone: 'min' }, // extreme anchored to a base
372
+ });
373
+ ```
374
+
375
+ ### Absolute tone
376
+
377
+ A number places a color on the 0–100 scale independently of every other color.
378
+ Each scheme maps it through the pipeline above, so the stored tone is not the
379
+ authored one: `surface: { tone: 97 }` is stored as 97.15 in light and, inverted
380
+ to 3 and mapped into the dark window, as 11.85 in dark. A dependent color (one
381
+ with a `base`) can still take an absolute tone; it is then mapped on its own and
382
+ the base only matters to a `contrast` floor.
383
+
384
+ ### Tone deltas
385
+
386
+ A signed string is a **tone delta**: the signed difference between a dependent
387
+ color and its base.
388
+
389
+ ```ts
390
+ border: { base: 'surface', tone: '-8' }
391
+ ```
392
+
393
+ How the delta travels depends on the dependent color's `mode`:
394
+
395
+ * **`fixed` and `static` in every scheme, and `auto` in the light schemes:**
396
+ the delta is applied to the base's resolved tone in that scheme, sign
397
+ preserved. It is an exact tone step, so for neutrals it is an exact contrast
398
+ step.
399
+ * **`auto` in the dark schemes:** the delta is added to the base's **anchor**
400
+ — where the base sits on the authored scale, the tone its own dark variant is
401
+ the inversion of — and the result is inverted and mapped through the dark
402
+ window like an absolute tone. Order and direction carry over — text that
403
+ sits below its surface in light sits above it in dark.
404
+
405
+ The anchor depends on how the base got its tone, and the same rules hold in
406
+ both dark passes:
407
+
408
+ * An `auto` base placed by a tone (a number, a `from` color, an extreme with
409
+ no base) anchors at its authored tone, and an `auto` tone delta at its base's
410
+ anchor plus the delta, so a chain steps evenly. An `auto` color with no
411
+ `tone` or `contrast` of its own anchors at its base's anchor.
412
+ * Any other base, whose dark tone is not the inversion of an authored one — a
413
+ `fixed` or `static` color, an `auto` contrast-solved color, an `auto` extreme
414
+ on a base, a mix — anchors at its own dark tone read back through the dark
415
+ window (the window remap run backwards, then un-inverted), so a dependent
416
+ follows where the base lands in dark.
417
+
418
+ On every base, the step is then the dark window's scale times the delta at the
419
+ reference dark `eps`, 86.6% of the authored delta at the default windows. A
420
+ tone-50 base steps `'+4'` to −3.5 and `'-4'` to +3.5, and a `'-4'` hover on an
421
+ AAA-solved button steps +3.5 too. A custom dark `eps` bends the dark window's
422
+ curve, so a dark delta lands where the same authored tone would instead of at a
423
+ fixed scale. In dark high contrast the window is the full range, so at the
424
+ reference dark `eps` the step is exact, only mirrored, wherever it fits on the
425
+ scale (limits below). The anchor is the authored position, not the base's light
426
+ high-contrast tone, so a custom light `eps`, which moves that tone (lighter
427
+ above the reference 0.05, darker below), doesn't change the step away from the
428
+ ends.
429
+
430
+ Order carries over only when the base inverts too. An `auto` delta on a
431
+ `fixed` or `static` base inverts on its own while the base does not (it keeps
432
+ its side, or, as a `fixed` delta, rides its own base into dark), so it mirrors
433
+ to the other side of the base in dark: a `'+30'` label on a `fixed` tone-52
434
+ fill sits 30 tones above it in light and 26.0 below it in dark. Make such a
435
+ dependent `fixed` as well when it should keep its side.
436
+
437
+ If `base + delta` overshoots `[0, 100]`, `autoFlip` (on by default) mirrors the
438
+ delta to the other side of the base; if the mirror overshoots too, or `autoFlip`
439
+ is off, the delta clamps on its authored side. Under `auto` the flip is decided
440
+ on the light tones, and dark mirrors the side light took. Dark then steps on
441
+ the authored scale, which adds two limits there:
442
+
443
+ * A step that fits from the base's light tone can still run past 0 or 100
444
+ from the anchor. In dark it then stops at the dark window's edge. The light
445
+ window lifts authored tone 0 to 4.95, so an `auto` base placed by a tone
446
+ meets this at the bottom: `'-4'` steps about +1.7 on a tone-2 base and not
447
+ at all on a tone-0 one. A base anchored at its own dark tone can meet it at
448
+ either end. In `glaze(280, 40)`, `contrast: 12` text on a tone-97 surface
449
+ sits at 15.6 in light but anchors at 2.7, so a `'-4'` hover on it steps
450
+ +2.3 in dark, not +3.5.
451
+ * An anchor past the dark window — a base a contrast floor or an extreme pushed
452
+ beyond its edge, or a `fixed` delta riding its base there — is stepped from
453
+ where it sits. A step back toward the window moves by the full scaled delta;
454
+ one further out stops at the window edge or the base, whichever is further
455
+ out.
456
+
457
+ High contrast's windows are the full range, and at the reference light `eps`
458
+ the light one is the identity, so an `auto` base placed by a tone flips the
459
+ same way in both high-contrast schemes and its step fits. The first limit
460
+ still applies there under a custom light `eps`, and to a base anchored at its
461
+ own dark tone.
462
+
463
+ ### Extremes: `'max'` and `'min'`
464
+
465
+ `'max'` is author tone 100 and `'min'` is author tone 0. They select the
466
+ corresponding end of the scheme without a magic number.
467
+
468
+ * **Without a base,** an extreme flows through the pipeline like any absolute
469
+ tone: it lands on the window edge and, under `auto`, inverts in dark — `'min'`
470
+ is the darkest color in light and the lightest in dark. Use `fixed` to keep the
471
+ same end without inverting, or `static` to pin one tone everywhere.
472
+ * **With a base,** the dark scheme takes a different path. The windows are
473
+ asymmetric, so running the extreme through the dark window would squeeze the
474
+ span between it and its base and cost contrast. Instead Glaze measures the
475
+ tone shift the light scheme produced between the base and the extreme, then
476
+ replays it against the base's resolved dark tone: mirrored under `auto` (both
477
+ ends invert), same-signed under `fixed`. Only `[0, 100]` clamping applies, so
478
+ the color may render past the dark window's edge — on purpose, since you asked
479
+ for the extreme. When the shift does not fit, the color pins at tone 100 or 0.
480
+ `static` colors are unaffected. High-contrast variants follow the same rule,
481
+ but their window is already the full range, so the replay matches the plain
482
+ mapping unless the base itself sits asymmetrically across schemes. With a
483
+ `contrast` floor, an anchored extreme's starting tone is not clamped back
484
+ into the scheme window.
485
+
486
+ ```ts
487
+ const neutral = glaze(0, 0);
488
+
489
+ neutral.colors({
490
+ bg: { tone: 60 },
491
+ fg: { base: 'bg', tone: 'max' },
492
+ });
493
+ // light: bg 62.0, fg 100.0 (shift +38.0, 3.18:1)
494
+ // dark: bg 43.9, fg 5.9 (shift -38.0, 3.18:1)
495
+ ```
496
+
497
+ A root `tone: 'max'` in the same theme lands at the dark window's edge,
498
+ tone 9.25, and drops to 2.87:1 against `bg` in dark.
499
+
500
+ ### All three, light to dark
501
+
502
+ <Example
503
+ hue={280}
504
+ saturation={30}
505
+ colors={{
506
+ surface: { tone: 97 },
507
+ border: { base: 'surface', tone: '-8' },
508
+ text: { base: 'surface', tone: '-60' },
509
+ deep: { tone: 'min' },
510
+ ink: { base: 'surface', tone: 'min' },
511
+ }}
512
+ visual="none"
513
+ />
514
+
515
+ <ToneLadder
516
+ hue={280}
517
+ saturation={30}
518
+ colors={{
519
+ surface: { tone: 97 },
520
+ border: { base: 'surface', tone: '-8' },
521
+ text: { base: 'surface', tone: '-60' },
522
+ deep: { tone: 'min' },
523
+ ink: { base: 'surface', tone: 'min' },
524
+ }}
525
+ base="surface"
526
+ schemes={['light', 'dark', 'lightContrast', 'darkContrast']}
527
+ caption="Each chip shows the stored tone, the tone difference from surface, and the measured WCAG ratio against it. In dark, the deltas shrink with the window (−8 becomes +6.9, −60 becomes +52.0), deep stops at the window edge, and ink replays its full light span past it, clamped at 100. In high contrast the window is the full range and the deltas are exact."
528
+ />
529
+
530
+ The `text` color above has no contrast floor, so its ratio follows its tone
531
+ step: 6.29:1 in light and 4.85:1 in dark. Add `contrast` when the measured
532
+ ratio matters.
533
+
534
+ ## Contrast floors and verification
535
+
536
+ Tone is the contrast solver's search axis because it is contrast-uniform: the
537
+ search converges quickly and WCAG gets a closed-form first probe. But Glaze
538
+ never assumes a chromatic color has the luminance of the gray at its tone. It
539
+ measures the real color.
540
+
541
+ `contrast` takes one spec with a pluggable metric: a WCAG ratio (a bare number,
542
+ a preset such as `'AA'`, or `{ wcag }`) or an APCA Lc target (`{ apca }`).
543
+ Forms, presets, roles, and high-contrast escalation are covered in
544
+ [Contrast solving](./contrast.mdx) and
545
+ [Contrast and high contrast](./reference/contrast.md).
546
+
547
+ ### How a floor is solved
548
+
549
+ For a color with `base` and `contrast`, in each scheme Glaze:
550
+
551
+ 1. resolves the base and the color's preferred tone for that scheme, clamped
552
+ into the scheme's tone window (anchored extremes excepted);
553
+ 2. measures the requested WCAG ratio or APCA Lc at the preferred tone, using the
554
+ color's real hue and saturation — if it already clears the floor with a
555
+ small margin (×1.01 for a WCAG ratio, +0.5 Lc for APCA, to absorb rounding
556
+ in the output formats), the tone is kept (`contrast` is a floor, not a
557
+ target); a preferred tone that clears the floor but not the margin is also
558
+ kept, and counts as met, when no searched side meets the floor (white text
559
+ at tone 100 has nowhere further to go);
560
+ 3. otherwise searches tone. For WCAG the search first measures the gray tone
561
+ whose luminance hits the ratio exactly — `Y = R * (Y_base + 0.05) - 0.05`
562
+ on the lighter side, `(Y_base + 0.05) / R - 0.05` on the darker — which
563
+ only narrows where the binary search against the measured chromatic
564
+ luminance looks, so a chromatic color still lands on its own crossing. For
565
+ APCA it binary-searches against the Lc target directly. The search covers
566
+ the full 0–100 range, so a demanding floor can push a color past the
567
+ window, and it aims for the same margin past the target;
568
+ 4. warns when the target is physically unreachable in the scheme.
569
+
570
+ ```ts
571
+ import { glaze } from '@tenphi/glaze';
572
+
573
+ const theme = glaze(150, 90);
574
+
575
+ theme.colors({
576
+ surface: { tone: 97, saturation: 0 },
577
+ // 49.4 tones below the surface: exactly 4.5:1 for a neutral
578
+ 'step-text': { base: 'surface', tone: '-49.4' },
579
+ // Same starting tone, but the solver measures the real color
580
+ 'floor-text': { base: 'surface', tone: '-49.4', contrast: 'AA' },
581
+ });
582
+ ```
583
+
584
+ <FloorCompare hues={[25, 110, 150, 265, 320]} caption="Light scheme. Each row is one hue at the neutral AA distance below a neutral surface, without and with contrast: 'AA'. Hues that read lighter than their tone fall short and are moved darker; hues that read darker already pass and keep their tone. The neutral is nudged just past 4.5:1 by the search overshoot." />
585
+
586
+ ### APCA
587
+
588
+ `apcaContrast(yText, yBg)` implements APCA-W3 0.1.9 (simplified SAPC): a
589
+ soft clamp for low luminances and separate exponents for normal and reverse
590
+ polarity, returning a signed Lc whose magnitude the solver compares with the
591
+ target. Its inputs are APCA **screen luminances** over the gamma-encoded sRGB
592
+ channels, not WCAG relative luminance:
593
+
594
+ ```text
595
+ Ys = 0.2126 * R^2.4 + 0.7152 * G^2.4 + 0.0722 * B^2.4
596
+ ```
597
+
598
+ The soft-clamp constants are calibrated against `Ys`, so the solver feeds APCA
599
+ that basis. The function it uses is exported as
600
+ [`apcaLuminanceFromLinearRgb`](./reference/utilities.md#luminance-and-contrast),
601
+ so `apcaContrast` over its output reproduces the solver's own Lc. The
602
+ implementation omits APCA's font-size lookup that maps an Lc to
603
+ a usable text size.
604
+
605
+ ### The drift check
606
+
607
+ After all four scheme passes, Glaze verifies every color that has a `base` and
608
+ a `contrast` floor. It re-measures the color against its base in each scheme, in
609
+ the floor's own metric (APCA screen luminance for APCA, WCAG luminance for
610
+ WCAG) and with each variant's own `pastel` flag, and emits a `console.warn` when
611
+ the result drifts below the target by more than a small slack: 2% of a WCAG
612
+ ratio, or 1.5 Lc. It skips a scheme where the solver couldn't reach the floor,
613
+ which already has the solver's "cannot meet" warning with the same measured
614
+ value, and an achromatic color (saturation 0), whose luminance is exactly its
615
+ tone's. Because the solver already measures the real chromatic color, a floor
616
+ it meets passes this check, so in practice the advisory stays silent; it
617
+ remains as a safety net for the chromatic approximation. Warnings are
618
+ deduplicated through a 256-key session cache that starts over when full. Both
619
+ warnings are listed in [Configuration](./reference/configuration.md#warnings).
620
+
621
+ ## Input and output
622
+
623
+ Glaze parses OKHST wherever it takes a color value — `glaze.color()`, a color
624
+ definition's `from`, and the `bg` / `fg` of the standalone `glaze.shadow()` /
625
+ `glaze.glow()`:
626
+
627
+ ```ts
628
+ import { glaze } from '@tenphi/glaze';
629
+
630
+ glaze.color('okhst(152 95% 70%)');
631
+ glaze.color({ h: 152, s: 0.95, t: 0.7 });
632
+ ```
633
+
634
+ Both value forms reproduce the color exactly in the light scheme. The
635
+ structured form `glaze.color({ hue: 152, saturation: 95, tone: 70 })` is
636
+ different: its `tone` is an authored tone on the 0–100 scale, mapped through the
637
+ light window like a theme color, so it renders at tone 71.48. See
638
+ [Color tokens](./reference/color-tokens.md#glazecolorinput).
639
+
640
+ There is no native CSS `okhst()` function, so native exports use `oklch`,
641
+ `rgb`, or `hsl`. [Tasty](../../tasty/README.md) can consume Glaze's custom
642
+ serialization, so `tasty()` accepts `format: 'okhst'`, as does `glaze.format()`
643
+ for a single variant. `tokens()`, `json()`, `css()`, and `tailwind()` throw and
644
+ name a native format instead; the DTCG exports pick a color space of their own.
645
+
646
+ ```ts
647
+ const theme = glaze(280, 30);
648
+
649
+ theme.colors({ surface: { tone: 97 } });
650
+ theme.tasty({ format: 'okhst' });
651
+ // {
652
+ // '#surface': {
653
+ // '': 'okhst(280 30% 97.15%)',
654
+ // '@media(prefers-color-scheme: dark)': 'okhst(280 27% 11.85%)',
655
+ // },
656
+ // }
657
+ ```
658
+
659
+ The color formats are listed in
660
+ [Output formats](./reference/output.md#color-formats); `toTone`, `fromTone`,
661
+ `toneFromY`, `yFromTone`, `okhstToOkhsl`, `okhslToOkhst`, `variantToOkhsl`, and
662
+ `REF_EPS` are exported for your own math (see
663
+ [Utilities](./reference/utilities.md#okhst-tone-utilities)).
664
+
665
+ ## Bringing existing colors into Glaze
666
+
667
+ When you move raw CSS colors or design tokens into Glaze, preserve the source
668
+ colors first: `glaze.fromHex()` and `glaze.fromRgb()` seed a theme from a
669
+ color, `glaze.color()` takes any color value, and a color definition's `from`
670
+ places a literal color inside a theme. Then replace one-off values with root
671
+ tones, tone deltas, and contrast floors as the relationships become clear.
672
+
673
+ To carry an OKHSL lightness `L` (0–100) over as a tone, convert it with
674
+ `toTone(L / 100)`; the two are not the same number. That tone is still an
675
+ authored tone, so the light window remaps it and it renders slightly lighter:
676
+ `L` 20 becomes tone 14.36 and renders at lightness 23.8. To keep a source color
677
+ exact in light, place it with `from`, which skips the light window, or turn the
678
+ window off with `lightTone: false`. The full workflow is in
679
+ [Migration and upgrading](./migration.md#migrating-from-an-existing-color-system).