@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,646 @@
1
+ ---
2
+ title: Utilities
3
+ description: glaze.format and glaze.formatHdr, standalone glaze.shadow and glaze.glow, and the color math, tone, contrast-solver, role, and guard functions Glaze exports for custom pipelines.
4
+ ---
5
+
6
+ Most code only needs `glaze()` and its output methods. The functions on this page are for formatting single values, computing an overlay outside a theme, and building your own pipeline on Glaze's math. Everything here is exported from `'@tenphi/glaze'`.
7
+
8
+ **Scales.** Hue is 0–360 everywhere. Saturation, OKHSL lightness, and stored tone are **0–1** in every converter, writer, and `ResolvedColorVariant`. Only the tone transfers `toTone` / `fromTone` / `toneFromY` / `yFromTone` speak the 0–100 tone axis that authoring uses. See [Scales](./index.md#scales).
9
+
10
+ ## `glaze.format(variant, format?, pastel?)`
11
+
12
+ Formats one resolved variant as a color string, alpha included.
13
+
14
+ | Parameter | Type | Default | Description |
15
+ | --- | --- | --- | --- |
16
+ | `variant` | `ResolvedColorVariant` | — | `{ h, s, t, alpha, pastel? }` from `resolve()`, `glaze.shadow()`, or `glaze.glow()`. |
17
+ | `format` | `GlazeColorFormat` | `'oklch'` | `'oklch'`, `'rgb'`, `'hsl'`, `'okhsl'`, or `'okhst'`. Syntax per format: [Color formats](./output.md#color-formats). |
18
+ | `pastel` | `boolean` | `false` | Gamut mapping for a variant that carries no `pastel` flag. A variant's own `pastel` always wins. |
19
+
20
+ - All five formats are accepted. The native-format guard applies to the output methods, not to `glaze.format()`.
21
+ - An `alpha` below 1 is appended as `/ A` (up to 4 decimals).
22
+
23
+ ```ts
24
+ import { glaze } from '@tenphi/glaze';
25
+
26
+ const shade = glaze.shadow({ bg: '#ffffff', intensity: 6 });
27
+
28
+ glaze.format(shade); // 'oklch(0 0 0 / 0.0814)'
29
+ glaze.format(shade, 'rgb'); // 'rgb(0 0 0 / 0.0814)'
30
+ glaze.format(shade, 'okhst'); // 'okhst(0 0% 0% / 0.0814)'
31
+
32
+ const accent = glaze.color(
33
+ { hue: 280, saturation: 80, tone: 60 },
34
+ { pastel: true },
35
+ );
36
+ const light = accent.resolve().light; // light.pastel === true
37
+
38
+ glaze.format(light); // 'oklch(0.6542 0.089 280)'
39
+
40
+ const plain = { ...light, pastel: undefined }; // read as non-pastel
41
+ glaze.format(plain); // 'oklch(0.6542 0.1604 280)'
42
+ ```
43
+
44
+ `glaze.format()` writes the SDR color only. A glow's HDR value has its own writer, below.
45
+
46
+ ## `glaze.formatHdr(variant, hdrFormat?)`
47
+
48
+ Formats a variant's HDR value, the [`hdr`](./color-definitions.md#hdr-values-resolvedhdr) a glow carries when its step passes SDR white with a `headroom` above 0.
49
+
50
+ ```ts
51
+ glaze.formatHdr(
52
+ variant: ResolvedColorVariant,
53
+ hdrFormat?: GlazeHdrFormat,
54
+ ): string | undefined
55
+ ```
56
+
57
+ | Parameter | Type | Default | Description |
58
+ | --- | --- | --- | --- |
59
+ | `variant` | `ResolvedColorVariant` | — | A variant from `resolve()` or `glaze.glow()`. |
60
+ | `hdrFormat` | `GlazeHdrFormat` | `'srgb'` | `type GlazeHdrFormat = 'srgb' \| 'srgb-linear'`. |
61
+
62
+ - `'srgb'` writes extended sRGB, `color(srgb R G B)`: the sRGB curve continued past 1, so `1` is SDR white and the brightest channel is above it. Every string exporter writes this form, and DTCG's `hdr.value` carries the same channels.
63
+ - `'srgb-linear'` writes the same color in linear light, `color(srgb-linear R G B)`: the base's linear sRGB times `2^exposure`.
64
+ - Both use 4 decimals, don't clamp, and carry no alpha: the value is opaque.
65
+ - Returns `undefined` for a variant without `hdr`.
66
+ - Any other `hdrFormat` throws `glaze: unknown HDR format "oklch". Use 'srgb' or 'srgb-linear'.` OKLCH lightness clamps at parse time, and `rgb()` / `hsl()` can't go past white. Why extended sRGB: [Extended sRGB](../hdr.mdx#extended-srgb).
67
+
68
+ ```ts
69
+ import { glaze } from '@tenphi/glaze';
70
+
71
+ const v = glaze.glow({
72
+ bg: '#ffffff',
73
+ intensity: 12,
74
+ tuning: { headroom: 1 },
75
+ });
76
+
77
+ glaze.format(v); // 'oklch(1 0 0)' — SDR, pinned at white
78
+ v.hdr; // → { h: 0, s: 0, t: 1, exposure: 0.5489… }
79
+ glaze.formatHdr(v); // 'color(srgb 1.1813 1.1813 1.1813)'
80
+ glaze.formatHdr(v, 'srgb-linear');
81
+ // → 'color(srgb-linear 1.4631 1.4631 1.4631)'
82
+
83
+ const shade = glaze.shadow({ bg: '#ffffff', intensity: 6 });
84
+ glaze.formatHdr(shade); // undefined
85
+ ```
86
+
87
+ `'#ffffff'` parses a hair below tone 100 (99.9999994), so the white pigment pins at α 1 and SDR is opaque white. A theme's `tone: 100` surface, or `bg: { h: 0, s: 0, t: 1 }`, is exact white: SDR stays transparent (α 0), and the HDR value is the same.
88
+
89
+ Theme output carries HDR values only with `modes.hdr`; see [HDR output](./output.md#hdr-output).
90
+
91
+ ## `hdrToSrgb(hdr)` / `hdrToLinearSrgb(hdr)`
92
+
93
+ The channels of an HDR value as numbers, for APIs that take channels instead of a CSS string.
94
+
95
+ ```ts
96
+ hdrToSrgb(hdr: ResolvedHdr): [number, number, number]
97
+ hdrToLinearSrgb(hdr: ResolvedHdr): [number, number, number]
98
+ ```
99
+
100
+ | Function | Returns |
101
+ | --- | --- |
102
+ | `hdrToSrgb(hdr)` | Extended sRGB channels: the numbers in `glaze.formatHdr()`'s default `color(srgb …)`. A WebGPU `'srgb'` canvas, Apple's extended sRGB, and Android's `EXTENDED_SRGB` take them as they are. |
103
+ | `hdrToLinearSrgb(hdr)` | Linear sRGB channels: the base's linear sRGB times `2^exposure`, the numbers in `color(srgb-linear …)`. |
104
+
105
+ - `hdr` is a variant's [`ResolvedHdr`](./color-definitions.md#hdr-values-resolvedhdr), not the variant.
106
+ - Neither clamps nor rounds. `1` is SDR white; the brightest channel is above it, and the others can stay below.
107
+ - Write them as strings with [`formatSrgb` / `formatSrgbLinear`](#format-writers). Which platform takes which: [Where it shows](../hdr.mdx#where-it-shows).
108
+
109
+ ```ts
110
+ import { hdrToSrgb, hdrToLinearSrgb } from '@tenphi/glaze';
111
+
112
+ // v: the 12-tone glow on white above
113
+ hdrToSrgb(v.hdr!); // [1.1812…, 1.1812…, 1.1812…]
114
+ hdrToLinearSrgb(v.hdr!); // [1.4630…, 1.4630…, 1.4630…]
115
+ ```
116
+
117
+ ## `glaze.shadow(input)` / `glaze.glow(input)`
118
+
119
+ Compute a shadow or glow overlay over one surface, outside a theme. The result is the pigment and the opacity that lands the composite exactly `intensity × k` tones darker (`shadow`) or lighter (`glow`) than `bg`, the same solve a theme runs per scheme. How it works: [Shadows and glows](../shadows-and-glows.mdx).
120
+
121
+ ```ts
122
+ glaze.shadow(input: GlazeShadowInput): ResolvedColorVariant
123
+ glaze.glow(input: GlazeGlowInput): ResolvedColorVariant
124
+ ```
125
+
126
+ `GlazeShadowInput` is an alias of `GlazeLightingInput`; `GlazeGlowInput` extends it with glow tuning (`tuning?: GlowTuning`):
127
+
128
+ | Field | Type | Description |
129
+ | --- | --- | --- |
130
+ | `bg` | `GlazeColorValue` | The surface. Any [color value](./color-tokens.md) form: hex (`#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`), `rgb()` / `hsl()` / `okhsl()` / `okhst()` / `oklch()` strings, or `{ r, g, b }` / `{ h, s, l }` / `{ h, s, t }` / `{ l, c, h }` objects. An alpha component is dropped with a warning. |
131
+ | `fg` | `GlazeColorValue` | Optional. The object casting the shadow or emitting the glow. Same forms. Without it, `k = 1`. |
132
+ | `intensity` | `number` | The tone step, 0–100. Clamped. |
133
+ | `tuning` | `ShadowTuning` / `GlowTuning` | Optional. Merges field by field over the global `shadowTuning` / `glowTuning` and the defaults. Fields: [`LightingTuning`](./color-definitions.md#lightingtuning). A glow's can set `headroom`; a shadow's `headroom` throws `glaze: glaze.shadow() tuning.headroom is not supported — …`. |
134
+
135
+ - The object factor is `k = max(0, 1 + β · d · (t_bg − t_fg) / 100)`, with `d = +1` for a shadow, `−1` for a glow, and `β = objectInfluence`.
136
+ - There are no schemes here: call it once per surface you composite onto. Per-instance config overrides don't apply; only the global tuning does.
137
+ - The result is `{ h, s, t, alpha }` with no `pastel` flag. Format it with `glaze.format()`.
138
+ - Compositing is modeled the way browsers paint (`source-over` on gamma-encoded sRGB), and the pigment is an sRGB color, so the result itself can't go past sRGB white. With a `headroom` (from `tuning` or the global `glowTuning`), a glow whose step would pass white also returns [`hdr`](./color-definitions.md#hdr-values-resolvedhdr); format it with [`glaze.formatHdr()`](#glazeformathdrvariant-hdrformat), or read its channels with [`hdrToSrgb()`](#hdrtosrgbhdr--hdrtolinearsrgbhdr).
139
+ - An out-of-reach step pins to the pigment's strongest composite and warns `glaze: glaze.glow() cannot reach a 5.0-tone step …`. A glow whose HDR value covers the step doesn't warn; one capped by its `headroom` warns `… even with 1 stop of headroom (reached 22.0). …` ([details](./configuration.md#shadow-or-glow-out-of-reach)).
140
+
141
+ ```ts
142
+ glaze.shadow({ bg: '#ffffff', intensity: 6 });
143
+ // → { h: 0, s: 0, t: 0, alpha: 0.0814… }
144
+
145
+ // The same 6-tone step needs more opacity on darker surfaces:
146
+ const mid = { h: 0, s: 0, t: 0.5 };
147
+ const deep = { h: 0, s: 0, t: 0.1 };
148
+ glaze.shadow({ bg: mid, intensity: 6 }).alpha; // 0.1066…
149
+ glaze.shadow({ bg: deep, intensity: 6 }).alpha; // 0.4769…
150
+
151
+ const lift = glaze.glow({ bg: '#1e1e22', fg: '#3a3a44', intensity: 4 });
152
+ glaze.format(lift); // 'oklch(1 0 0 / 0.0466)'
153
+
154
+ glaze.glow({
155
+ bg: 'oklch(0.3 0.02 280)',
156
+ intensity: 6,
157
+ tuning: { tint: 0.3, reach: 0.85 },
158
+ });
159
+ // → { h: 280, s: 0.3, t: 0.8712…, alpha: 0.0745… }
160
+
161
+ // A 12-tone glow on a tone-95 gray passes white: SDR pins at
162
+ // opaque white, HDR lands tone 107
163
+ glaze.glow({
164
+ bg: { h: 0, s: 0, t: 0.95 },
165
+ intensity: 12,
166
+ tuning: { headroom: 1 },
167
+ });
168
+ // → {
169
+ // h: 0,
170
+ // s: 0,
171
+ // t: 1,
172
+ // alpha: 1,
173
+ // hdr: { h: 0, s: 0, t: 1, exposure: 0.3212… },
174
+ // }
175
+ ```
176
+
177
+ ## Color math
178
+
179
+ Glaze's color science, exported as plain functions over tuples. `pastel` (default `false`) selects the hue-independent "safe" chroma bound instead of the per-hue sRGB gamut, matching a color's [`pastel`](./color-definitions.md#per-color-pastel) flag.
180
+
181
+ ### Conversions
182
+
183
+ | Function | Returns | Description |
184
+ | --- | --- | --- |
185
+ | `okhslToLinearSrgb(h, s, l, pastel?)` | `[r, g, b]` | OKHSL → linear sRGB. Channels can leave 0–1 near the gamut edge; clamp if you need to. |
186
+ | `okhslToSrgb(h, s, l, pastel?)` | `[r, g, b]` | OKHSL → gamma-encoded sRGB, clamped to 0–1. |
187
+ | `okhslToOklab(h, s, l, pastel?)` | `[L, a, b]` | OKHSL → OKLab. |
188
+ | `okhslToOklch(h, s, l, pastel?)` | `[L, C, H]` | OKHSL → OKLCh (`L` 0–1, `C` chroma, `H` degrees). Same math as the `oklch` output. |
189
+ | `oklabToOkhsl([L, a, b], pastel?)` | `[h, s, l]` | OKLab → OKHSL. Achromatic colors and the black / white extremes return `[0, 0, l]`. |
190
+ | `srgbToOkhsl([r, g, b], pastel?)` | `[h, s, l]` | Gamma-encoded sRGB (0–1) → OKHSL. A gray comes back with saturation 0: an input whose OKLab chroma is under 1e-6, well above the float residue an exact gray leaves (at most 3.7e-8). |
191
+ | `hslToSrgb(h, s, l)` | `[r, g, b]` | CSS HSL (`s`, `l` 0–1) → gamma-encoded sRGB. CSS HSL is not OKHSL; chain with `srgbToOkhsl`. |
192
+ | `srgbToHex([r, g, b])` | `` `#${string}` `` | Gamma-encoded sRGB → `#rrggbb`. Channels are clamped and rounded to 8 bits; no alpha. |
193
+ | `parseHex(hex)` | `[r, g, b] \| null` | `#rgb` / `#rrggbb` → sRGB 0–1. The `#` is optional and digits are case-insensitive. Returns `null` for a wrong length, forms with alpha, and any character that isn't a hex digit — every digit is checked, so `'#1z2z3z'` and `'#-1-2-3'` are `null`. |
194
+ | `parseHexAlpha(hex)` | `{ rgb, alpha? } \| null` | `#rgb`, `#rgba`, `#rrggbb`, or `#rrggbbaa`. `alpha` (0–1) is present only for the alpha forms. Same digit check as `parseHex`. |
195
+ | `cuspLightness(h)` | `number` | OKHSL lightness (0.001–0.999) where the sRGB gamut's chroma peaks for hue `h`. Cached per hue. |
196
+
197
+ ```ts
198
+ import {
199
+ okhslToSrgb,
200
+ okhslToOklch,
201
+ srgbToOkhsl,
202
+ srgbToHex,
203
+ parseHex,
204
+ parseHexAlpha,
205
+ cuspLightness,
206
+ } from '@tenphi/glaze';
207
+
208
+ okhslToSrgb(280, 0.6, 0.5); // [0.4118…, 0.4221…, 0.7644…]
209
+ okhslToOklch(280, 0.6, 0.5); // [0.5688…, 0.1326…, 280]
210
+ srgbToOkhsl(parseHex('#7a4dbf')!); // [298.5176…, 0.7040…, 0.4527…]
211
+ srgbToHex([122 / 255, 77 / 255, 191 / 255]); // '#7a4dbf'
212
+ parseHex('#7a4dbf80'); // null
213
+ parseHex('#1z2z3z'); // null
214
+ parseHexAlpha('#7a4dbf80');
215
+ // → { rgb: [0.4784…, 0.3019…, 0.7490…], alpha: 0.5019… }
216
+ cuspLightness(110); // 0.9619… (yellow peaks light)
217
+ cuspLightness(265); // 0.3688… (blue peaks dark)
218
+ ```
219
+
220
+ ### Luminance and contrast
221
+
222
+ | Function | Returns | Description |
223
+ | --- | --- | --- |
224
+ | `relativeLuminanceFromLinearRgb([r, g, b])` | `number` | WCAG relative luminance, `0.2126·R + 0.7152·G + 0.0722·B`, with no clamping. |
225
+ | `gamutClampedLuminance([r, g, b])` | `number` | The same after gamma-encoding, clamping to 0–1, and linearizing again — the luminance a browser actually paints. Glaze's WCAG solver uses this one. |
226
+ | `contrastRatioFromLuminance(yA, yB)` | `number` | WCAG ratio `(lighter + 0.05) / (darker + 0.05)`. Argument order doesn't matter. |
227
+ | `apcaLuminanceFromLinearRgb([r, g, b])` | `number` | APCA screen luminance `Ys`: gamma-encode, clamp to 0–1, then `0.2126·R^2.4 + 0.7152·G^2.4 + 0.0722·B^2.4`. Glaze's APCA solver uses this one. |
228
+
229
+ ```ts
230
+ gamutClampedLuminance([1.2, 0.5, -0.1]); // 0.5702
231
+ relativeLuminanceFromLinearRgb([1.2, 0.5, -0.1]); // 0.6054…
232
+ apcaLuminanceFromLinearRgb([1.2, 0.5, -0.1]); // 0.5545…
233
+ contrastRatioFromLuminance(1, 0); // 21
234
+ ```
235
+
236
+ APCA contrast is [`apcaContrast`](#apcacontrastytext-ybg) below.
237
+
238
+ ### Format writers
239
+
240
+ Write one OKHSL (or OKHST) color as a string. Every writer but `formatSrgb` and `formatSrgbLinear` takes `h` on 0–360 and `s` / `l` / `t` on **0–1** — the scale every converter returns — and scales to percentages itself.
241
+
242
+ | Function | Output |
243
+ | --- | --- |
244
+ | `formatOkhsl(h, s, l, pastel?)` | `okhsl(H S% L%)` |
245
+ | `formatOkhst(h, s, t, pastel?)` | `okhst(H S% T%)` — `t` is stored tone on 0–1 |
246
+ | `formatRgb(h, s, l, pastel?)` | `rgb(R G B)`, channels 0–255 with up to 2 decimals |
247
+ | `formatHsl(h, s, l, pastel?)` | `hsl(H S% L%)` |
248
+ | `formatOklch(h, s, l, pastel?)` | `oklch(L C H)`, `L` / `C` to 4 decimals, `H` to 2 |
249
+ | `formatSrgb([r, g, b])` | `color(srgb R G B)`, extended sRGB channels to 4 decimals, unclamped (below) |
250
+ | `formatSrgbLinear([r, g, b])` | `color(srgb-linear R G B)`, linear channels to 4 decimals, unclamped (below) |
251
+
252
+ ```ts
253
+ import {
254
+ formatOkhsl,
255
+ formatOkhst,
256
+ formatRgb,
257
+ formatHsl,
258
+ formatOklch,
259
+ } from '@tenphi/glaze';
260
+
261
+ formatOkhsl(280, 0.6, 0.95); // 'okhsl(280 60% 95%)'
262
+ formatOkhst(280, 0.6, 0.95); // 'okhst(280 60% 95%)'
263
+ formatRgb(280, 0.6, 0.95); // 'rgb(238.45 239.95 251.1)'
264
+ formatHsl(280, 0.6, 0.95); // 'hsl(232.92 61.87% 95.99%)'
265
+ formatOklch(280, 0.6, 0.95); // 'oklch(0.9571 0.015 280)'
266
+ formatOklch(0, 0, 0.5); // 'oklch(0.5688 0 0)'
267
+ ```
268
+
269
+ - With `pastel: true`, `formatOkhsl` and `formatOkhst` re-express the saturation as its standard OKHSL equivalent, so parsers that don't know about pastel render the same color: `formatOkhsl(280, 0.6, 0.6, true)` → `'okhsl(280 34.46% 60%)'`. The other writers convert the pastel color directly.
270
+ - The writers emit no alpha. For a string with alpha, use [`glaze.format()`](#glazeformatvariant-format-pastel) on a variant.
271
+ - A value above 1 means pre-2.0 percentage input: each writer warns once per process ([Percentage scale](./configuration.md#percentage-scale)) and still formats the value.
272
+
273
+ `formatSrgb(rgb)` and `formatSrgbLinear(rgb)` are the odd ones out: they take sRGB channels, not OKHSL. `formatSrgb` takes **extended sRGB** (gamma-encoded, the curve continued past 1) and writes `color(srgb R G B)`; `formatSrgbLinear` takes **linear sRGB** and writes `color(srgb-linear R G B)`. Both use 4 decimals and don't convert between the two. They don't clamp and don't warn above 1, because that is their job: `1` is SDR white, and one stop above it is `1.3533` extended or `2` linear. They are the writers behind [`glaze.formatHdr()`](#glazeformathdrvariant-hdrformat): `formatSrgb` for its default `'srgb'`, `formatSrgbLinear` for `'srgb-linear'`.
274
+
275
+ ```ts
276
+ import { formatSrgb, formatSrgbLinear } from '@tenphi/glaze';
277
+
278
+ formatSrgb([1.3533, 1.3533, 1.3533]); // 'color(srgb 1.3533 1.3533 1.3533)'
279
+ formatSrgbLinear([2, 2, 2]); // 'color(srgb-linear 2 2 2)'
280
+ formatSrgb([1.2, 0.5, -0.1]); // 'color(srgb 1.2 0.5 -0.1)'
281
+ formatSrgbLinear([1.2, 0.5, -0.1]); // 'color(srgb-linear 1.2 0.5 -0.1)'
282
+ ```
283
+
284
+ A resolved variant composes with the writers directly; `variantToOkhsl` turns its tone into OKHSL lightness:
285
+
286
+ ```ts
287
+ import {
288
+ glaze,
289
+ variantToOkhsl,
290
+ formatOkhst,
291
+ formatOkhsl,
292
+ formatRgb,
293
+ } from '@tenphi/glaze';
294
+
295
+ const v = glaze.color('#7A4DBF').resolve().light;
296
+ const { h, s, l } = variantToOkhsl(v);
297
+
298
+ formatOkhst(v.h, v.s, v.t); // 'okhst(298.52 70.41% 45.02%)'
299
+ formatOkhsl(h, s, l); // 'okhsl(298.52 70.41% 45.27%)'
300
+ formatRgb(h, s, l); // 'rgb(122 77 191)'
301
+ ```
302
+
303
+ ## OKHST tone utilities
304
+
305
+ The tone transfer behind OKHST. The model and why `eps` is `0.05`: [Tone and OKHST](../okhst.mdx).
306
+
307
+ | Export | Description |
308
+ | --- | --- |
309
+ | `toTone(l, eps = REF_EPS)` | OKHSL lightness (0–1) → tone (**0–100**). Divide by 100 for `formatOkhst` or a variant's `t`. |
310
+ | `fromTone(t, eps = REF_EPS)` | Tone (**0–100**) → OKHSL lightness (0–1). Inverse of `toTone`. |
311
+ | `toneFromY(y, eps = REF_EPS)` | Luminance → tone: `100 · ln((y + eps) / eps) / ln((1 + eps) / eps)`. Luminance 0–1 maps to tone 0–100, unclamped: `Y` above 1 (HDR) gives a tone above 100 (`Y` 2 → 121.98). |
312
+ | `yFromTone(t, eps = REF_EPS)` | Tone → luminance. Inverse of `toneFromY`, unclamped past tone 100. |
313
+ | `okhstToOkhsl({ h, s, t })` | OKHST (`t` 0–1) → `{ h, s, l }`. |
314
+ | `okhslToOkhst({ h, s, l })` | OKHSL → `{ h, s, t }` (`t` 0–1). |
315
+ | `variantToOkhsl(variant)` | A `ResolvedColorVariant` → `{ h, s, l }` for rendering. `alpha` and `pastel` stay on the variant. |
316
+ | `REF_EPS` | `0.05` — the reference eps of the canonical tone axis. |
317
+
318
+ `toTone` / `fromTone` measure luminance as a gray of that lightness, so they are exact for neutrals; a chromatic color's real luminance drifts slightly from its tone.
319
+
320
+ ```ts
321
+ import {
322
+ toTone,
323
+ fromTone,
324
+ toneFromY,
325
+ yFromTone,
326
+ okhslToOkhst,
327
+ } from '@tenphi/glaze';
328
+
329
+ toTone(0.5); // 50.6998…
330
+ fromTone(50); // 0.4941…
331
+ toneFromY(0.18); // 50.12…
332
+ yFromTone(50); // 0.1791…
333
+ okhslToOkhst({ h: 280, s: 0.6, l: 0.5 });
334
+ // → { h: 280, s: 0.6, t: 0.5069… }
335
+ ```
336
+
337
+ ## Contrast solver
338
+
339
+ The solver Glaze runs for `contrast` floors, exported for tools that work outside a theme. Concepts: [Contrast solving](../contrast.mdx). The spec forms (`ContrastSpec`, presets, high-contrast pairs) are defined in [`ContrastSpec`](./contrast.md#contrastspec).
340
+
341
+ ### Resolving a spec
342
+
343
+ | Function | Returns | Description |
344
+ | --- | --- | --- |
345
+ | `resolveMinContrast(value)` | `number` | A `MinContrast` → WCAG ratio. Presets: `'AA'` 4.5, `'AAA'` 7, `'AA-large'` 3, `'AAA-large'` 4.5. Numbers below 1 become 1. |
346
+ | `resolveApcaTarget(value)` | `number` | An APCA Lc number or `ApcaPreset` → Lc. Numbers are taken as magnitudes (`-60` → `60`). |
347
+ | `resolveContrastForMode(spec, isHighContrast, polarity?, outerExplicitHC?)` | `ResolvedContrast` | One `ContrastSpec` (already picked from any outer pair) → `{ metric, target, polarity? }` for normal or high contrast. Picks the entry of an inner `{ wcag: [...] }` / `{ apca: [...] }` pair. |
348
+ | `resolveContrastForLevel(spec, level, polarity?)` | `ResolvedContrast` | An `HCPair<ContrastSpec>` at a manual contrast level (0–100). Resolves both ends with `resolveContrastForMode` and interpolates the target linearly. |
349
+
350
+ High-contrast escalation in `resolveContrastForMode`, applied only when `isHighContrast` is `true`, `outerExplicitHC` is not set, and the spec has no inner pair:
351
+
352
+ - WCAG `'AA'` → `'AAA'` (7) and `'AA-large'` → `'AAA-large'` (4.5). AAA presets and bare numbers are unchanged.
353
+ - APCA: `+APCA_HC_ENHANCEMENT` (15) Lc, capped at `APCA_MAX_LC` (106); a target already above 106 is kept.
354
+
355
+ `polarity` (`'fg'` default, or `'bg'`) is copied onto APCA results; WCAG results have no `polarity`. `resolveContrastForLevel` returns the plain normal / high-contrast result at levels 0 and 100, and throws for any level in between when the two ends use different metrics (`glaze: a "contrast" pair cannot switch metric between its normal (wcag) and high-contrast (apca) entry — …`).
356
+
357
+ ```ts
358
+ import {
359
+ resolveContrastForMode,
360
+ resolveContrastForLevel,
361
+ } from '@tenphi/glaze';
362
+
363
+ resolveContrastForMode('AA', false);
364
+ // → { metric: 'wcag', target: 4.5 }
365
+ resolveContrastForMode('AA', true);
366
+ // → { metric: 'wcag', target: 7 }
367
+ resolveContrastForMode('AA', true, undefined, true);
368
+ // → { metric: 'wcag', target: 4.5 }
369
+ resolveContrastForMode({ wcag: ['AA', 'AA'] }, true);
370
+ // → { metric: 'wcag', target: 4.5 }
371
+ resolveContrastForMode({ apca: 75 }, true);
372
+ // → { metric: 'apca', target: 90, polarity: 'fg' }
373
+ resolveContrastForMode({ apca: 'preferred' }, true, 'bg');
374
+ // → { metric: 'apca', target: 105, polarity: 'bg' }
375
+ resolveContrastForMode({ apca: [60, 80] }, true);
376
+ // → { metric: 'apca', target: 80, polarity: 'fg' }
377
+
378
+ resolveContrastForLevel('AA', 50);
379
+ // → { metric: 'wcag', target: 5.75 }
380
+ resolveContrastForLevel({ apca: 60 }, 50, 'bg');
381
+ // → { metric: 'apca', target: 67.5, polarity: 'bg' }
382
+ ```
383
+
384
+ ### `apcaContrast(yText, yBg)`
385
+
386
+ APCA lightness contrast (APCA-W3 0.1.9, simplified) for two screen luminances. Signed: positive for dark text on a light background, negative for light text on a dark one, `0` below the noise floor.
387
+
388
+ ```ts
389
+ import { apcaContrast } from '@tenphi/glaze';
390
+
391
+ apcaContrast(0, 1); // 106.04… (black on white)
392
+ apcaContrast(1, 0); // -107.88… (white on black)
393
+ ```
394
+
395
+ The inputs are APCA screen luminance `Ys`, not WCAG relative luminance. Compute them with [`apcaLuminanceFromLinearRgb`](#luminance-and-contrast), the same function the solver and the drift check use, so the score you get is the solver's own. Re-scoring a resolved pair:
396
+
397
+ ```ts
398
+ import {
399
+ glaze,
400
+ apcaContrast,
401
+ apcaLuminanceFromLinearRgb,
402
+ okhslToLinearSrgb,
403
+ variantToOkhsl,
404
+ } from '@tenphi/glaze';
405
+ import type { ResolvedColorVariant } from '@tenphi/glaze';
406
+
407
+ const theme = glaze({ hue: 265, saturation: 70 });
408
+ theme.colors({
409
+ surface: { tone: 97 },
410
+ text: { base: 'surface', tone: 60, contrast: { apca: 75 } },
411
+ });
412
+ const resolved = theme.resolve();
413
+
414
+ const ys = (v: ResolvedColorVariant) => {
415
+ const { h, s, l } = variantToOkhsl(v);
416
+ return apcaLuminanceFromLinearRgb(okhslToLinearSrgb(h, s, l, v.pastel));
417
+ };
418
+
419
+ const text = resolved.get('text')!;
420
+ const surface = resolved.get('surface')!;
421
+ apcaContrast(ys(text.light), ys(surface.light)); // 75.5060…
422
+ apcaContrast(ys(text.dark), ys(surface.dark)); // -75.5011…
423
+ ```
424
+
425
+ Both land just past 75.5 rather than at 75: the search aims 0.5 Lc beyond the target (see [`findToneForContrast`](#findtoneforcontrastoptions)). The sign is the polarity — dark text on the light surface, light text on the dark one.
426
+
427
+ ### `findToneForContrast(options)`
428
+
429
+ Searches tone for the value nearest `preferredTone` that meets a floor against a base color. All tones are on 0–1.
430
+
431
+ | Option | Type | Default | Description |
432
+ | --- | --- | --- | --- |
433
+ | `hue` | `number` | — | Candidate hue, 0–360. |
434
+ | `saturation` | `number` | — | Candidate OKHSL saturation, 0–1. |
435
+ | `preferredTone` | `number` | — | Starting tone. Returned as is if it already clears the overshoot (see below), and kept when it clears the bare target and nothing searched does better (see `branch`). |
436
+ | `baseLinearRgb` | `[r, g, b]` | — | The base color, linear sRGB. |
437
+ | `contrast` | `ResolvedContrast` | — | `{ metric, target, polarity? }`, e.g. from `resolveContrastForMode`. |
438
+ | `toneRange` | `[number, number]` | `[0, 1]` | Search bounds. Glaze itself searches the full `[0, 1]` and only clamps `preferredTone` into the scheme's tone window (`'max'` / `'min'` excepted), so a floor can push a color past the window. |
439
+ | `epsilon` | `number` | `1e-4` | Convergence threshold. |
440
+ | `maxIterations` | `number` | `18` | Binary-search iterations per side. |
441
+ | `initialDirection` | `'lighter' \| 'darker'` | Side whose bound scores higher; a tie within float noise (a gray base at tone 50) starts `'darker'` | Side to search first. When `preferredTone` sits on a bound, the only open side. |
442
+ | `flip` | `boolean` | `false` | Also search the opposite side. Without it, a side that can't reach the target pins the result to that side's bound, unless `preferredTone` itself clears the bare target: it is then kept, met, with `branch: 'preferred'`. When both sides pass, the result nearer `preferredTone` wins; a tie keeps `initialDirection`. |
443
+ | `preferInitial` | `boolean` | `false` | With `flip`, when both sides pass, keep `initialDirection` instead of the result nearer `preferredTone`. The manual contrast level uses it so a color keeps its side across the ramp. |
444
+ | `pastel` | `boolean` | `false` | Use the pastel gamut for the candidate. |
445
+
446
+ `FindToneForContrastResult`:
447
+
448
+ | Field | Type | Description |
449
+ | --- | --- | --- |
450
+ | `tone` | `number` | The chosen tone, 0–1. |
451
+ | `contrast` | `number` | The achieved score: WCAG ratio or APCA Lc magnitude. |
452
+ | `met` | `boolean` | Whether `contrast` reaches the target. |
453
+ | `branch` | `'lighter' \| 'darker' \| 'preferred'` | The side the result was found on; a met result sits on that side of the base. `'preferred'` whenever a met result is `preferredTone` itself: it already cleared the overshoot, or it clears the bare target and no search could improve on it (no searched side met the floor, or the starting side's only position was `preferredTone`, on that side's bound). Also `'preferred'` when `toneRange` leaves no room to search and no `initialDirection` is given, met or not (with one, an unmet result names that direction). An unmet result otherwise names the side whose bound it pinned to. |
454
+ | `flipped` | `true` (optional) | Present when the result is on the side opposite `initialDirection`. |
455
+
456
+ The search aims slightly past the target (WCAG ×1.01, APCA +0.5 Lc) to absorb rounding in the output formats; `met` is judged against the exact target. WCAG scores use `gamutClampedLuminance`, APCA scores use `Ys` with `polarity` deciding which side is the text.
457
+
458
+ The two sides meet at `preferredTone`. Once it misses the overshoot target it sits between the passing tones on either side of the base, so each side can only reach its own: a met `'darker'` result sits below the base and a `'lighter'` one above it, and clears the overshoot unless it is that side's bound. A side that can't reach the target reports its bound, never a tone next to `preferredTone`. When no searched side reaches the floor but `preferredTone` clears the bare target (it sits on a bound, or `flip` is off and `initialDirection` points away from it), `preferredTone` wins over the unmet bound: it is returned met, with `branch: 'preferred'`. White against a tone-35 gray at a 7.2:1 floor comes back as `{ tone: 1, contrast: 7.23…, met: true, branch: 'preferred' }`.
459
+
460
+ For WCAG, the first side measures the closed-form gray seed (the gray tone at exactly the target ratio) before it bisects. The seed is only a probe: it narrows the search but never bounds it, so a chromatic color still lands on its own crossing nearest `preferredTone`, at about the overshoot (4.546:1 for a 4.5 floor). Without `initialDirection`, the search starts toward the bound that scores higher, and a float-noise tie between the two (a gray base at tone 50) starts darker. With `flip`, two passing sides whose distances from `preferredTone` differ by no more than twice the search resolution (`epsilon`, or the solver's 0.0001 tone step if coarser) count as a tie. A neutral color whose `preferredTone` is the base's own tone sits exactly as far from either crossing, so without that margin search noise would pick its side.
461
+
462
+ ```ts
463
+ import {
464
+ findToneForContrast,
465
+ okhslToLinearSrgb,
466
+ fromTone,
467
+ } from '@tenphi/glaze';
468
+
469
+ const surface = okhslToLinearSrgb(280, 0.1, fromTone(97));
470
+
471
+ findToneForContrast({
472
+ hue: 280,
473
+ saturation: 0.6,
474
+ preferredTone: 0.9,
475
+ baseLinearRgb: surface,
476
+ contrast: { metric: 'wcag', target: 4.5 },
477
+ });
478
+ // → { tone: 0.4846…, contrast: 4.54…, met: true, branch: 'darker' }
479
+
480
+ findToneForContrast({
481
+ hue: 280,
482
+ saturation: 0.6,
483
+ preferredTone: 0.9,
484
+ baseLinearRgb: surface,
485
+ contrast: { metric: 'wcag', target: 4.5 },
486
+ initialDirection: 'lighter',
487
+ });
488
+ // → { tone: 1, contrast: 1.09…, met: false, branch: 'lighter' }
489
+
490
+ // …with flip: true
491
+ // → {
492
+ // tone: 0.4846…,
493
+ // contrast: 4.54…,
494
+ // met: true,
495
+ // branch: 'darker',
496
+ // flipped: true,
497
+ // }
498
+ ```
499
+
500
+ ### `findValueForMixContrast(options)`
501
+
502
+ The same search over a mix parameter (0–1) between a base and a target — what [mix](../mixes.mdx) colors with `contrast` run.
503
+
504
+ | Option | Type | Default | Description |
505
+ | --- | --- | --- | --- |
506
+ | `preferredValue` | `number` | — | Starting mix value, 0–1. Returned as-is if it already clears the overshoot (WCAG ×1.01, APCA +0.5 Lc). |
507
+ | `baseLinearRgb` | `[r, g, b]` | — | The base color, linear sRGB. Contrast is measured against it. |
508
+ | `contrast` | `ResolvedContrast` | — | The floor. |
509
+ | `luminanceAtValue` | `(t: number) => number` | — | Luminance of the mixed color at `t`, in the metric's basis (WCAG Y or APCA `Ys`). The solver sees the mix's target color, blend, and color space only through this function. |
510
+ | `epsilon` | `number` | `1e-4` | Convergence threshold. |
511
+ | `maxIterations` | `number` | `20` | Binary-search iterations per side. |
512
+ | `flip` | `boolean` | `false` | Also search the opposite side of `preferredValue`. |
513
+
514
+ It starts toward whichever end (0 or 1) scores higher (a tie starts toward 0), or toward the only open side when `preferredValue` is 0 or 1, and on failure pins to the end it started toward. As with tone, the two sides meet at `preferredValue`, and a side that can't reach the target reports its end, never a value next to `preferredValue`. When no searched side reaches the floor but `preferredValue` itself clears the bare target, it is kept, met. So a mix at value 1 keeps a pure target that clears the floor, even inside the overshoot, and pins to 0, the pure base, only when the pure target misses. `FindValueForMixContrastResult` is `{ value, contrast, met, flipped? }`. The options have no `targetLinearRgb`: the solver sees the target only through `luminanceAtValue`.
515
+
516
+ ```ts
517
+ import {
518
+ findValueForMixContrast,
519
+ okhslToLinearSrgb,
520
+ relativeLuminanceFromLinearRgb,
521
+ fromTone,
522
+ } from '@tenphi/glaze';
523
+
524
+ const base = okhslToLinearSrgb(280, 0.1, fromTone(97));
525
+ const target = okhslToLinearSrgb(280, 0.6, fromTone(30));
526
+ const lerp = (t: number) =>
527
+ base.map((c, i) => c + (target[i] - c) * t) as [number, number, number];
528
+
529
+ findValueForMixContrast({
530
+ preferredValue: 0.1,
531
+ baseLinearRgb: base,
532
+ contrast: { metric: 'wcag', target: 3 },
533
+ luminanceAtValue: (t) => relativeLuminanceFromLinearRgb(lerp(t)),
534
+ });
535
+ // → { value: 0.766…, contrast: 3.03…, met: true }
536
+
537
+ // A pure target at 4.52:1 against white: inside the overshoot,
538
+ // above the bare 4.5, so value 1 is kept
539
+ const yTarget = 1.05 / 4.52 - 0.05;
540
+ findValueForMixContrast({
541
+ preferredValue: 1,
542
+ baseLinearRgb: [1, 1, 1],
543
+ contrast: { metric: 'wcag', target: 4.5 },
544
+ luminanceAtValue: (t) => 1 + (yTarget - 1) * t,
545
+ });
546
+ // → { value: 1, contrast: 4.52, met: true }
547
+ ```
548
+
549
+ ### Constants and types
550
+
551
+ | Export | Value |
552
+ | --- | --- |
553
+ | `APCA_PRESETS` | `{ preferred: 90, body: 75, content: 60, large: 45, 'non-text': 30, min: 15 }` — use cases in [APCA presets](./contrast.md#apca-presets). |
554
+ | `APCA_HC_ENHANCEMENT` | `15` — Lc added to a bare APCA target in high contrast. |
555
+ | `APCA_MAX_LC` | `106` — cap on an escalated APCA target. |
556
+
557
+ | Type | Shape |
558
+ | --- | --- |
559
+ | `ContrastPreset` | `'AA' \| 'AAA' \| 'AA-large' \| 'AAA-large'` |
560
+ | `ApcaPreset` | `'preferred' \| 'body' \| 'content' \| 'large' \| 'non-text' \| 'min'` |
561
+ | `ResolvedContrast` | `{ metric: 'wcag' \| 'apca'; target: number; polarity?: 'fg' \| 'bg' }` — `target` is a WCAG ratio or an APCA Lc magnitude. |
562
+ | `FindToneForContrastOptions` / `FindToneForContrastResult` | [`findToneForContrast`](#findtoneforcontrastoptions) |
563
+ | `FindValueForMixContrastOptions` / `FindValueForMixContrastResult` | [`findValueForMixContrast`](#findvalueformixcontrastoptions) |
564
+
565
+ ## Role helpers
566
+
567
+ A color's role fixes its APCA polarity. How Glaze picks one: [Roles and APCA polarity](../contrast.mdx#roles-and-apca-polarity). The `Role` and `RoleInput` types are in [Roles](./color-definitions.md#roles).
568
+
569
+ | Function | Returns | Description |
570
+ | --- | --- | --- |
571
+ | `normalizeRole(input)` | `Role \| undefined` | A role or alias → `'text'`, `'surface'`, or `'border'`. `undefined` for unknown strings and `undefined`. |
572
+ | `inferRoleFromName(name)` | `Role \| undefined` | Splits the name on non-alphanumerics and lowercase-to-uppercase humps and matches each token against the role keywords. The **last** match wins. |
573
+ | `roleToPolarity(role)` | `Polarity` | `'surface'` → `'bg'` (the base is the text); `'text'` and `'border'` → `'fg'`. |
574
+ | `oppositeRole(role)` | `Role` | `'surface'` → `'text'`; `'text'` and `'border'` → `'surface'`. Used for a dependent color with no role of its own. |
575
+
576
+ `Polarity` is `'fg' | 'bg'`.
577
+
578
+ ```ts
579
+ import {
580
+ normalizeRole,
581
+ inferRoleFromName,
582
+ roleToPolarity,
583
+ oppositeRole,
584
+ } from '@tenphi/glaze';
585
+
586
+ normalizeRole('ink'); // 'text'
587
+ normalizeRole('rule'); // 'border'
588
+ inferRoleFromName('button-text'); // 'text'
589
+ inferRoleFromName('inputBg'); // 'surface'
590
+ inferRoleFromName('card_border-outline'); // 'border'
591
+ inferRoleFromName('text-on-surface'); // 'surface' — last match wins
592
+ inferRoleFromName('BGColor'); // undefined — an all-caps run isn't split
593
+ inferRoleFromName('accent'); // undefined
594
+ roleToPolarity('border'); // 'fg'
595
+ oppositeRole('border'); // 'surface'
596
+ ```
597
+
598
+ ## Export guards
599
+
600
+ The checks the output methods run, exported for custom exporters.
601
+
602
+ | Function | Throws when |
603
+ | --- | --- |
604
+ | `assertNativeFormat(format, method)` | `format` is `'okhsl'` or `'okhst'`. `undefined` and native formats pass. `method` names the caller in the message. |
605
+ | `assertAllPastel(resolved, modes)` | Any variant included by `modes` is not pastel. `resolved` is a `Map<string, ResolvedColor>` (`theme.resolve()`); `modes` must have both `dark` and `highContrast` set. `light` is always checked, `dark` with `modes.dark`, `lightContrast` with `modes.highContrast`, `darkContrast` with both. |
606
+
607
+ ```ts
608
+ import {
609
+ glaze,
610
+ assertNativeFormat,
611
+ assertAllPastel,
612
+ } from '@tenphi/glaze';
613
+
614
+ assertNativeFormat('okhst', 'css');
615
+ // throws:
616
+ // glaze: okhst output is only supported by tasty() (not a native CSS
617
+ // color space).
618
+ // Use tasty({ format: 'okhst' }) or pick a native format (oklch|hsl|rgb)
619
+ // for css().
620
+
621
+ const theme = glaze(280, 80);
622
+ theme.colors({
623
+ surface: { tone: 97 },
624
+ accent: { tone: 50, pastel: true },
625
+ });
626
+ assertAllPastel(theme.resolve(), { dark: true, highContrast: false });
627
+ // throws:
628
+ // glaze: splitHue requires every color to be pastel (…).
629
+ // Non-pastel: surface.
630
+ // Set pastel: true (per-theme, per-token, or per-color) or drop splitHue.
631
+ ```
632
+
633
+ Where the output methods apply them: [Color formats](./output.md#color-formats) and [`splitHue` requirements](./output.md#requirements).
634
+
635
+ ## Snapshot type guards
636
+
637
+ `isThemeExport`, `isColorTokenExport`, and `isPaletteExport` are exported as named functions and as `glaze.*` statics. They tell authoring snapshots apart by `kind` (or by shape for legacy snapshots) and don't validate `version`. Exact rules: [Type guards](./index.md#type-guards).
638
+
639
+ ```ts
640
+ import { isThemeExport, glaze } from '@tenphi/glaze';
641
+
642
+ const data: unknown = JSON.parse(saved);
643
+ if (isThemeExport(data)) glaze.themeFrom(data);
644
+ ```
645
+
646
+ The `splitHue` planning types `HuePlan`, `HueDeclaration`, and `ChannelCtx` are documented in [Output formats](./output.md#planning-types).