@tenphi/glaze 0.0.0-snapshot.63c08ea → 0.0.0-snapshot.6b78fce

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,532 @@
1
+ ---
2
+ title: Utilities
3
+ description: glaze.format, 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.shadow(input)` / `glaze.glow(input)`
45
+
46
+ 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).
47
+
48
+ ```ts
49
+ glaze.shadow(input: GlazeShadowInput): ResolvedColorVariant
50
+ glaze.glow(input: GlazeGlowInput): ResolvedColorVariant
51
+ ```
52
+
53
+ `GlazeShadowInput` and `GlazeGlowInput` are aliases of `GlazeLightingInput`:
54
+
55
+ | Field | Type | Description |
56
+ | --- | --- | --- |
57
+ | `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. |
58
+ | `fg` | `GlazeColorValue` | Optional. The object casting the shadow or emitting the glow. Same forms. Without it, `k = 1`. |
59
+ | `intensity` | `number` | The tone step, 0–100. Clamped. |
60
+ | `tuning` | `LightingTuning` | Optional. Merges field by field over the global `shadowTuning` / `glowTuning` and the defaults. Fields: [`LightingTuning`](./color-definitions.md#lightingtuning). |
61
+
62
+ - 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`.
63
+ - 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.
64
+ - The result is `{ h, s, t, alpha }` with no `pastel` flag. Format it with `glaze.format()`.
65
+ - Compositing is modeled the way browsers paint (`source-over` on gamma-encoded sRGB), and the pigment is an sRGB color, so a glow can't go past sRGB white.
66
+ - An out-of-reach step pins to the pigment's strongest composite and warns `glaze: glaze.glow() cannot reach a 5.0-tone step …` ([details](./configuration.md#shadow-or-glow-out-of-reach)).
67
+
68
+ ```ts
69
+ glaze.shadow({ bg: '#ffffff', intensity: 6 });
70
+ // → { h: 0, s: 0, t: 0, alpha: 0.0814… }
71
+
72
+ // The same 6-tone step needs more opacity on darker surfaces:
73
+ const mid = { h: 0, s: 0, t: 0.5 };
74
+ const deep = { h: 0, s: 0, t: 0.1 };
75
+ glaze.shadow({ bg: mid, intensity: 6 }).alpha; // 0.1066…
76
+ glaze.shadow({ bg: deep, intensity: 6 }).alpha; // 0.4769…
77
+
78
+ const lift = glaze.glow({ bg: '#1e1e22', fg: '#3a3a44', intensity: 4 });
79
+ glaze.format(lift); // 'oklch(1 0 0 / 0.0466)'
80
+
81
+ glaze.glow({
82
+ bg: 'oklch(0.3 0.02 280)',
83
+ intensity: 6,
84
+ tuning: { tint: 0.3, reach: 0.85 },
85
+ });
86
+ // → { h: 280, s: 0.3, t: 0.8712…, alpha: 0.0745… }
87
+ ```
88
+
89
+ ## Color math
90
+
91
+ 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.
92
+
93
+ ### Conversions
94
+
95
+ | Function | Returns | Description |
96
+ | --- | --- | --- |
97
+ | `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. |
98
+ | `okhslToSrgb(h, s, l, pastel?)` | `[r, g, b]` | OKHSL → gamma-encoded sRGB, clamped to 0–1. |
99
+ | `okhslToOklab(h, s, l, pastel?)` | `[L, a, b]` | OKHSL → OKLab. |
100
+ | `okhslToOklch(h, s, l, pastel?)` | `[L, C, H]` | OKHSL → OKLCh (`L` 0–1, `C` chroma, `H` degrees). Same math as the `oklch` output. |
101
+ | `oklabToOkhsl([L, a, b], pastel?)` | `[h, s, l]` | OKLab → OKHSL. Achromatic colors and the black / white extremes return `[0, 0, l]`. |
102
+ | `srgbToOkhsl([r, g, b], pastel?)` | `[h, s, l]` | Gamma-encoded sRGB (0–1) → OKHSL. |
103
+ | `hslToSrgb(h, s, l)` | `[r, g, b]` | CSS HSL (`s`, `l` 0–1) → gamma-encoded sRGB. CSS HSL is not OKHSL; chain with `srgbToOkhsl`. |
104
+ | `srgbToHex([r, g, b])` | `` `#${string}` `` | Gamma-encoded sRGB → `#rrggbb`. Channels are clamped and rounded to 8 bits; no alpha. |
105
+ | `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`. |
106
+ | `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`. |
107
+ | `cuspLightness(h)` | `number` | OKHSL lightness (0.001–0.999) where the sRGB gamut's chroma peaks for hue `h`. Cached per hue. |
108
+
109
+ ```ts
110
+ import {
111
+ okhslToSrgb,
112
+ okhslToOklch,
113
+ srgbToOkhsl,
114
+ srgbToHex,
115
+ parseHex,
116
+ parseHexAlpha,
117
+ cuspLightness,
118
+ } from '@tenphi/glaze';
119
+
120
+ okhslToSrgb(280, 0.6, 0.5); // [0.4118…, 0.4221…, 0.7644…]
121
+ okhslToOklch(280, 0.6, 0.5); // [0.5688…, 0.1326…, 280]
122
+ srgbToOkhsl(parseHex('#7a4dbf')!); // [298.5176…, 0.7040…, 0.4527…]
123
+ srgbToHex([122 / 255, 77 / 255, 191 / 255]); // '#7a4dbf'
124
+ parseHex('#7a4dbf80'); // null
125
+ parseHex('#1z2z3z'); // null
126
+ parseHexAlpha('#7a4dbf80');
127
+ // → { rgb: [0.4784…, 0.3019…, 0.7490…], alpha: 0.5019… }
128
+ cuspLightness(110); // 0.9619… (yellow peaks light)
129
+ cuspLightness(265); // 0.3688… (blue peaks dark)
130
+ ```
131
+
132
+ ### Luminance and contrast
133
+
134
+ | Function | Returns | Description |
135
+ | --- | --- | --- |
136
+ | `relativeLuminanceFromLinearRgb([r, g, b])` | `number` | WCAG relative luminance, `0.2126·R + 0.7152·G + 0.0722·B`, with no clamping. |
137
+ | `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. |
138
+ | `contrastRatioFromLuminance(yA, yB)` | `number` | WCAG ratio `(lighter + 0.05) / (darker + 0.05)`. Argument order doesn't matter. |
139
+ | `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. |
140
+
141
+ ```ts
142
+ gamutClampedLuminance([1.2, 0.5, -0.1]); // 0.5702
143
+ relativeLuminanceFromLinearRgb([1.2, 0.5, -0.1]); // 0.6054…
144
+ apcaLuminanceFromLinearRgb([1.2, 0.5, -0.1]); // 0.5545…
145
+ contrastRatioFromLuminance(1, 0); // 21
146
+ ```
147
+
148
+ APCA contrast is [`apcaContrast`](#apcacontrastytext-ybg) below.
149
+
150
+ ### Format writers
151
+
152
+ Write one OKHSL (or OKHST) color as a string. Every writer takes `h` on 0–360 and `s` / `l` / `t` on **0–1** — the scale every converter returns — and scales to percentages itself.
153
+
154
+ | Function | Output |
155
+ | --- | --- |
156
+ | `formatOkhsl(h, s, l, pastel?)` | `okhsl(H S% L%)` |
157
+ | `formatOkhst(h, s, t, pastel?)` | `okhst(H S% T%)` — `t` is stored tone on 0–1 |
158
+ | `formatRgb(h, s, l, pastel?)` | `rgb(R G B)`, channels 0–255 with up to 2 decimals |
159
+ | `formatHsl(h, s, l, pastel?)` | `hsl(H S% L%)` |
160
+ | `formatOklch(h, s, l, pastel?)` | `oklch(L C H)`, `L` / `C` to 4 decimals, `H` to 2 |
161
+
162
+ ```ts
163
+ import {
164
+ formatOkhsl,
165
+ formatOkhst,
166
+ formatRgb,
167
+ formatHsl,
168
+ formatOklch,
169
+ } from '@tenphi/glaze';
170
+
171
+ formatOkhsl(280, 0.6, 0.95); // 'okhsl(280 60% 95%)'
172
+ formatOkhst(280, 0.6, 0.95); // 'okhst(280 60% 95%)'
173
+ formatRgb(280, 0.6, 0.95); // 'rgb(238.45 239.95 251.1)'
174
+ formatHsl(280, 0.6, 0.95); // 'hsl(232.92 61.87% 95.99%)'
175
+ formatOklch(280, 0.6, 0.95); // 'oklch(0.9571 0.015 280)'
176
+ formatOklch(0, 0, 0.5); // 'oklch(0.5688 0 0)'
177
+ ```
178
+
179
+ - 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.
180
+ - The writers emit no alpha. For a string with alpha, use [`glaze.format()`](#glazeformatvariant-format-pastel) on a variant.
181
+ - 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.
182
+
183
+ A resolved variant composes with the writers directly; `variantToOkhsl` turns its tone into OKHSL lightness:
184
+
185
+ ```ts
186
+ import {
187
+ glaze,
188
+ variantToOkhsl,
189
+ formatOkhst,
190
+ formatOkhsl,
191
+ formatRgb,
192
+ } from '@tenphi/glaze';
193
+
194
+ const v = glaze.color('#7A4DBF').resolve().light;
195
+ const { h, s, l } = variantToOkhsl(v);
196
+
197
+ formatOkhst(v.h, v.s, v.t); // 'okhst(298.52 70.41% 45.02%)'
198
+ formatOkhsl(h, s, l); // 'okhsl(298.52 70.41% 45.27%)'
199
+ formatRgb(h, s, l); // 'rgb(122 77 191)'
200
+ ```
201
+
202
+ ## OKHST tone utilities
203
+
204
+ The tone transfer behind OKHST. The model and why `eps` is `0.05`: [Tone and OKHST](../okhst.mdx).
205
+
206
+ | Export | Description |
207
+ | --- | --- |
208
+ | `toTone(l, eps = REF_EPS)` | OKHSL lightness (0–1) → tone (**0–100**). Divide by 100 for `formatOkhst` or a variant's `t`. |
209
+ | `fromTone(t, eps = REF_EPS)` | Tone (**0–100**) → OKHSL lightness (0–1). Inverse of `toTone`. |
210
+ | `toneFromY(y, eps = REF_EPS)` | Luminance (0–1) → tone (0–100): `100 · ln((y + eps) / eps) / ln((1 + eps) / eps)`. |
211
+ | `yFromTone(t, eps = REF_EPS)` | Tone (0–100) → luminance (0–1). Inverse of `toneFromY`. |
212
+ | `okhstToOkhsl({ h, s, t })` | OKHST (`t` 0–1) → `{ h, s, l }`. |
213
+ | `okhslToOkhst({ h, s, l })` | OKHSL → `{ h, s, t }` (`t` 0–1). |
214
+ | `variantToOkhsl(variant)` | A `ResolvedColorVariant` → `{ h, s, l }` for rendering. `alpha` and `pastel` stay on the variant. |
215
+ | `REF_EPS` | `0.05` — the reference eps of the canonical tone axis. |
216
+
217
+ `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.
218
+
219
+ ```ts
220
+ import {
221
+ toTone,
222
+ fromTone,
223
+ toneFromY,
224
+ yFromTone,
225
+ okhslToOkhst,
226
+ } from '@tenphi/glaze';
227
+
228
+ toTone(0.5); // 50.6998…
229
+ fromTone(50); // 0.4941…
230
+ toneFromY(0.18); // 50.12…
231
+ yFromTone(50); // 0.1791…
232
+ okhslToOkhst({ h: 280, s: 0.6, l: 0.5 });
233
+ // → { h: 280, s: 0.6, t: 0.5069… }
234
+ ```
235
+
236
+ ## Contrast solver
237
+
238
+ 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).
239
+
240
+ ### Resolving a spec
241
+
242
+ | Function | Returns | Description |
243
+ | --- | --- | --- |
244
+ | `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. |
245
+ | `resolveApcaTarget(value)` | `number` | An APCA Lc number or `ApcaPreset` → Lc. Numbers are taken as magnitudes (`-60` → `60`). |
246
+ | `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. |
247
+ | `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. |
248
+
249
+ High-contrast escalation in `resolveContrastForMode`, applied only when `isHighContrast` is `true`, `outerExplicitHC` is not set, and the spec has no inner pair:
250
+
251
+ - WCAG `'AA'` → `'AAA'` (7) and `'AA-large'` → `'AAA-large'` (4.5). AAA presets and bare numbers are unchanged.
252
+ - APCA: `+APCA_HC_ENHANCEMENT` (15) Lc, capped at `APCA_MAX_LC` (106).
253
+
254
+ `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 — …`).
255
+
256
+ ```ts
257
+ import {
258
+ resolveContrastForMode,
259
+ resolveContrastForLevel,
260
+ } from '@tenphi/glaze';
261
+
262
+ resolveContrastForMode('AA', false);
263
+ // → { metric: 'wcag', target: 4.5 }
264
+ resolveContrastForMode('AA', true);
265
+ // → { metric: 'wcag', target: 7 }
266
+ resolveContrastForMode('AA', true, undefined, true);
267
+ // → { metric: 'wcag', target: 4.5 }
268
+ resolveContrastForMode({ wcag: ['AA', 'AA'] }, true);
269
+ // → { metric: 'wcag', target: 4.5 }
270
+ resolveContrastForMode({ apca: 75 }, true);
271
+ // → { metric: 'apca', target: 90, polarity: 'fg' }
272
+ resolveContrastForMode({ apca: 'preferred' }, true, 'bg');
273
+ // → { metric: 'apca', target: 105, polarity: 'bg' }
274
+ resolveContrastForMode({ apca: [60, 80] }, true);
275
+ // → { metric: 'apca', target: 80, polarity: 'fg' }
276
+
277
+ resolveContrastForLevel('AA', 50);
278
+ // → { metric: 'wcag', target: 5.75 }
279
+ resolveContrastForLevel({ apca: 60 }, 50, 'bg');
280
+ // → { metric: 'apca', target: 67.5, polarity: 'bg' }
281
+ ```
282
+
283
+ ### `apcaContrast(yText, yBg)`
284
+
285
+ 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.
286
+
287
+ ```ts
288
+ import { apcaContrast } from '@tenphi/glaze';
289
+
290
+ apcaContrast(0, 1); // 106.04… (black on white)
291
+ apcaContrast(1, 0); // -107.88… (white on black)
292
+ ```
293
+
294
+ 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:
295
+
296
+ ```ts
297
+ import {
298
+ glaze,
299
+ apcaContrast,
300
+ apcaLuminanceFromLinearRgb,
301
+ okhslToLinearSrgb,
302
+ variantToOkhsl,
303
+ } from '@tenphi/glaze';
304
+ import type { ResolvedColorVariant } from '@tenphi/glaze';
305
+
306
+ const theme = glaze({ hue: 265, saturation: 70 });
307
+ theme.colors({
308
+ surface: { tone: 97 },
309
+ text: { base: 'surface', tone: 60, contrast: { apca: 75 } },
310
+ });
311
+ const resolved = theme.resolve();
312
+
313
+ const ys = (v: ResolvedColorVariant) => {
314
+ const { h, s, l } = variantToOkhsl(v);
315
+ return apcaLuminanceFromLinearRgb(okhslToLinearSrgb(h, s, l, v.pastel));
316
+ };
317
+
318
+ const text = resolved.get('text')!;
319
+ const surface = resolved.get('surface')!;
320
+ apcaContrast(ys(text.light), ys(surface.light)); // 75.5060…
321
+ apcaContrast(ys(text.dark), ys(surface.dark)); // -75.5011…
322
+ ```
323
+
324
+ 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.
325
+
326
+ ### `findToneForContrast(options)`
327
+
328
+ Searches tone for the value nearest `preferredTone` that meets a floor against a base color. All tones are on 0–1.
329
+
330
+ | Option | Type | Default | Description |
331
+ | --- | --- | --- | --- |
332
+ | `hue` | `number` | — | Candidate hue, 0–360. |
333
+ | `saturation` | `number` | — | Candidate OKHSL saturation, 0–1. |
334
+ | `preferredTone` | `number` | — | Starting tone. Returned as-is if it already passes. |
335
+ | `baseLinearRgb` | `[r, g, b]` | — | The base color, linear sRGB. |
336
+ | `contrast` | `ResolvedContrast` | — | `{ metric, target, polarity? }`, e.g. from `resolveContrastForMode`. |
337
+ | `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. |
338
+ | `epsilon` | `number` | `1e-4` | Convergence threshold. |
339
+ | `maxIterations` | `number` | `18` | Binary-search iterations per side. |
340
+ | `initialDirection` | `'lighter' \| 'darker'` | Side whose bound scores higher | Side to search first. When `preferredTone` sits on a bound, the only open side. |
341
+ | `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. When both sides pass, the result nearer `preferredTone` wins; a tie keeps `initialDirection`. |
342
+ | `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. |
343
+ | `pastel` | `boolean` | `false` | Use the pastel gamut for the candidate. |
344
+
345
+ `FindToneForContrastResult`:
346
+
347
+ | Field | Type | Description |
348
+ | --- | --- | --- |
349
+ | `tone` | `number` | The chosen tone, 0–1. |
350
+ | `contrast` | `number` | The achieved score: WCAG ratio or APCA Lc magnitude. |
351
+ | `met` | `boolean` | Whether `contrast` reaches the target. |
352
+ | `branch` | `'lighter' \| 'darker' \| 'preferred'` | The side the result was found on. `'preferred'` means `preferredTone` already passed. |
353
+ | `flipped` | `true` (optional) | Present when the result is on the side opposite `initialDirection`. |
354
+
355
+ 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.
356
+
357
+ 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`. For WCAG, the first side's search starts from the closed-form gray seed, which can narrow that side but never extend it past `preferredTone`. 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.
358
+
359
+ ```ts
360
+ import {
361
+ findToneForContrast,
362
+ okhslToLinearSrgb,
363
+ fromTone,
364
+ } from '@tenphi/glaze';
365
+
366
+ const surface = okhslToLinearSrgb(280, 0.1, fromTone(97));
367
+
368
+ findToneForContrast({
369
+ hue: 280,
370
+ saturation: 0.6,
371
+ preferredTone: 0.9,
372
+ baseLinearRgb: surface,
373
+ contrast: { metric: 'wcag', target: 4.5 },
374
+ });
375
+ // → { tone: 0.4758…, contrast: 4.67…, met: true, branch: 'darker' }
376
+
377
+ findToneForContrast({
378
+ hue: 280,
379
+ saturation: 0.6,
380
+ preferredTone: 0.9,
381
+ baseLinearRgb: surface,
382
+ contrast: { metric: 'wcag', target: 4.5 },
383
+ initialDirection: 'lighter',
384
+ });
385
+ // → { tone: 1, contrast: 1.09…, met: false, branch: 'lighter' }
386
+
387
+ // …with flip: true
388
+ // → {
389
+ // tone: 0.4846…,
390
+ // contrast: 4.54…,
391
+ // met: true,
392
+ // branch: 'darker',
393
+ // flipped: true,
394
+ // }
395
+ ```
396
+
397
+ ### `findValueForMixContrast(options)`
398
+
399
+ The same search over a mix parameter (0–1) between a base and a target — what [mix](../mixes.mdx) colors with `contrast` run.
400
+
401
+ | Option | Type | Default | Description |
402
+ | --- | --- | --- | --- |
403
+ | `preferredValue` | `number` | — | Starting mix value, 0–1. Returned as-is if it already passes. |
404
+ | `baseLinearRgb` | `[r, g, b]` | — | The base color, linear sRGB. Contrast is measured against it. |
405
+ | `contrast` | `ResolvedContrast` | — | The floor. |
406
+ | `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. |
407
+ | `epsilon` | `number` | `1e-4` | Convergence threshold. |
408
+ | `maxIterations` | `number` | `20` | Binary-search iterations per side. |
409
+ | `flip` | `boolean` | `false` | Also search the opposite side of `preferredValue`. |
410
+
411
+ It starts toward whichever end (0 or 1) scores higher, or toward the only open side when `preferredValue` is 0 or 1, and on failure pins to the end it started toward. So a mix at value 1 that misses its floor pins to 0, the pure base. As with tone, the two sides meet at `preferredValue`, and a side that can't reach the target reports its end — never `preferredValue` itself, even when that clears the bare target but not the overshoot. `FindValueForMixContrastResult` is `{ value, contrast, met, flipped? }`.
412
+
413
+ ```ts
414
+ import {
415
+ findValueForMixContrast,
416
+ okhslToLinearSrgb,
417
+ relativeLuminanceFromLinearRgb,
418
+ fromTone,
419
+ } from '@tenphi/glaze';
420
+
421
+ const base = okhslToLinearSrgb(280, 0.1, fromTone(97));
422
+ const target = okhslToLinearSrgb(280, 0.6, fromTone(30));
423
+ const lerp = (t: number) =>
424
+ base.map((c, i) => c + (target[i] - c) * t) as [number, number, number];
425
+
426
+ findValueForMixContrast({
427
+ preferredValue: 0.1,
428
+ baseLinearRgb: base,
429
+ contrast: { metric: 'wcag', target: 3 },
430
+ luminanceAtValue: (t) => relativeLuminanceFromLinearRgb(lerp(t)),
431
+ });
432
+ // → { value: 0.766…, contrast: 3.03…, met: true }
433
+ ```
434
+
435
+ ### Constants and types
436
+
437
+ | Export | Value |
438
+ | --- | --- |
439
+ | `APCA_PRESETS` | `{ preferred: 90, body: 75, content: 60, large: 45, 'non-text': 30, min: 15 }` — use cases in [APCA presets](./contrast.md#apca-presets). |
440
+ | `APCA_HC_ENHANCEMENT` | `15` — Lc added to a bare APCA target in high contrast. |
441
+ | `APCA_MAX_LC` | `106` — cap on an escalated APCA target. |
442
+
443
+ | Type | Shape |
444
+ | --- | --- |
445
+ | `ContrastPreset` | `'AA' \| 'AAA' \| 'AA-large' \| 'AAA-large'` |
446
+ | `ApcaPreset` | `'preferred' \| 'body' \| 'content' \| 'large' \| 'non-text' \| 'min'` |
447
+ | `ResolvedContrast` | `{ metric: 'wcag' \| 'apca'; target: number; polarity?: 'fg' \| 'bg' }` — `target` is a WCAG ratio or an APCA Lc magnitude. |
448
+ | `FindToneForContrastOptions` / `FindToneForContrastResult` | [`findToneForContrast`](#findtoneforcontrastoptions) |
449
+ | `FindValueForMixContrastOptions` / `FindValueForMixContrastResult` | [`findValueForMixContrast`](#findvalueformixcontrastoptions) |
450
+
451
+ ## Role helpers
452
+
453
+ 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).
454
+
455
+ | Function | Returns | Description |
456
+ | --- | --- | --- |
457
+ | `normalizeRole(input)` | `Role \| undefined` | A role or alias → `'text'`, `'surface'`, or `'border'`. `undefined` for unknown strings and `undefined`. |
458
+ | `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. |
459
+ | `roleToPolarity(role)` | `Polarity` | `'surface'` → `'bg'` (the base is the text); `'text'` and `'border'` → `'fg'`. |
460
+ | `oppositeRole(role)` | `Role` | `'surface'` → `'text'`; `'text'` and `'border'` → `'surface'`. Used for a dependent color with no role of its own. |
461
+
462
+ `Polarity` is `'fg' | 'bg'`.
463
+
464
+ ```ts
465
+ import {
466
+ normalizeRole,
467
+ inferRoleFromName,
468
+ roleToPolarity,
469
+ oppositeRole,
470
+ } from '@tenphi/glaze';
471
+
472
+ normalizeRole('ink'); // 'text'
473
+ normalizeRole('rule'); // 'border'
474
+ inferRoleFromName('button-text'); // 'text'
475
+ inferRoleFromName('inputBg'); // 'surface'
476
+ inferRoleFromName('card_border-outline'); // 'border'
477
+ inferRoleFromName('text-on-surface'); // 'surface' — last match wins
478
+ inferRoleFromName('BGColor'); // undefined — an all-caps run isn't split
479
+ inferRoleFromName('accent'); // undefined
480
+ roleToPolarity('border'); // 'fg'
481
+ oppositeRole('border'); // 'surface'
482
+ ```
483
+
484
+ ## Export guards
485
+
486
+ The checks the output methods run, exported for custom exporters.
487
+
488
+ | Function | Throws when |
489
+ | --- | --- |
490
+ | `assertNativeFormat(format, method)` | `format` is `'okhsl'` or `'okhst'`. `undefined` and native formats pass. `method` names the caller in the message. |
491
+ | `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. |
492
+
493
+ ```ts
494
+ import {
495
+ glaze,
496
+ assertNativeFormat,
497
+ assertAllPastel,
498
+ } from '@tenphi/glaze';
499
+
500
+ assertNativeFormat('okhst', 'css');
501
+ // throws:
502
+ // glaze: okhst output is only supported by tasty() (not a native CSS
503
+ // color space).
504
+ // Use tasty({ format: 'okhst' }) or pick a native format (oklch|hsl|rgb)
505
+ // for css().
506
+
507
+ const theme = glaze(280, 80);
508
+ theme.colors({
509
+ surface: { tone: 97 },
510
+ accent: { tone: 50, pastel: true },
511
+ });
512
+ assertAllPastel(theme.resolve(), { dark: true, highContrast: false });
513
+ // throws:
514
+ // glaze: splitHue requires every color to be pastel (…).
515
+ // Non-pastel: surface.
516
+ // Set pastel: true (per-theme, per-token, or per-color) or drop splitHue.
517
+ ```
518
+
519
+ Where the output methods apply them: [Color formats](./output.md#color-formats) and [`splitHue` requirements](./output.md#requirements).
520
+
521
+ ## Snapshot type guards
522
+
523
+ `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).
524
+
525
+ ```ts
526
+ import { isThemeExport, glaze } from '@tenphi/glaze';
527
+
528
+ const data: unknown = JSON.parse(saved);
529
+ if (isThemeExport(data)) glaze.themeFrom(data);
530
+ ```
531
+
532
+ The `splitHue` planning types `HuePlan`, `HueDeclaration`, and `ChannelCtx` are documented in [Output formats](./output.md#planning-types).