@tenphi/glaze 0.13.0 → 0.15.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.
package/docs/api.md CHANGED
@@ -39,7 +39,7 @@ const d = glaze.fromRgb(122, 77, 191);
39
39
  const e = glaze.from(a.export());
40
40
 
41
41
  // Per-theme config override:
42
- const rawTheme = glaze(280, 80, { lightLightness: false, darkLightness: false });
42
+ const rawTheme = glaze(280, 80, { lightTone: false, darkTone: false });
43
43
  ```
44
44
 
45
45
  The optional `config` parameter is a `GlazeConfigOverride` — see [Per-instance config override](#per-instance-config-override).
@@ -72,15 +72,15 @@ A `GlazeTheme` exposes:
72
72
  ### `theme.colors(defs)`
73
73
 
74
74
  ```ts
75
- theme.colors({ surface: { lightness: 97 } });
76
- theme.colors({ text: { lightness: 30 } });
75
+ theme.colors({ surface: { tone: 97 } });
76
+ theme.colors({ text: { tone: 30 } });
77
77
  // Both 'surface' and 'text' are now defined.
78
78
  ```
79
79
 
80
80
  ### `theme.color(name) / theme.color(name, def)`
81
81
 
82
82
  ```ts
83
- theme.color('surface', { lightness: 97, saturation: 0.75 }); // set
83
+ theme.color('surface', { tone: 97, saturation: 0.75 }); // set
84
84
  const def = theme.color('surface'); // get
85
85
  ```
86
86
 
@@ -91,11 +91,11 @@ Creates a new theme inheriting all color definitions, optionally replacing the h
91
91
  ```ts
92
92
  const danger = primary.extend({
93
93
  hue: 23,
94
- colors: { 'accent-fill': { lightness: 48, mode: 'fixed' } },
94
+ colors: { 'accent-fill': { tone: 48, mode: 'fixed' } },
95
95
  });
96
96
 
97
- // Inherit parent's config override and tighten the dark window further:
98
- const highSat = base.extend({ config: { darkLightness: [10, 100] } });
97
+ // Inherit parent's config override and widen the dark window further:
98
+ const highSat = base.extend({ config: { darkTone: [10, 100] } });
99
99
  ```
100
100
 
101
101
  `GlazeExtendOptions`:
@@ -211,56 +211,89 @@ type ColorDef = RegularColorDef | ShadowColorDef | MixColorDef;
211
211
 
212
212
  | Field | Type | Description |
213
213
  |---|---|---|
214
- | `lightness` | `HCPair<number \| RelativeValue>` | Number = absolute (0–100). String (`'+N'`/`'-N'`) = relative to base's lightness (requires `base`). Optional HC pair `[normal, hc]`. |
214
+ | `tone` | `HCPair<ToneValue>` | Number = absolute (0–100, contrast-uniform). `'+N'`/`'-N'` = relative to base's tone (requires `base`). `'max'`/`'min'` = forced to the scheme's tone extreme (no `base`). Optional HC pair `[normal, hc]`. |
215
215
  | `saturation` | `number` | Saturation factor applied to the seed saturation (0–1). Default: `1`. |
216
216
  | `hue` | `number \| RelativeValue` | Number = absolute (0–360). String (`'+N'`/`'-N'`) = relative to the **theme seed hue** (never to a base color). |
217
217
  | `base` | `string` | Name of another color in the same theme — makes this a *dependent* color. |
218
- | `contrast` | `HCPair<MinContrast>` | WCAG contrast floor against `base`. Requires `base`. |
218
+ | `contrast` | `HCPair<ContrastSpec>` | Contrast floor against `base`. Requires `base`. See [`contrast`](#contrast-floor). |
219
219
  | `mode` | `'auto' \| 'fixed' \| 'static'` | Adaptation mode. Default: `'auto'`. See [Adaptation modes](#adaptation-modes). |
220
+ | `flip` | `boolean` | Flip out-of-bounds results (relative `tone` overshoot / unmet `contrast`) to the opposite side instead of clamping. Default: the global `autoFlip` (`true`). See [`flip`](#flip). |
220
221
  | `opacity` | `number` | Fixed alpha 0–1. Output includes alpha in the CSS value. Combining with `contrast` is not recommended (a `console.warn` is emitted). |
221
222
  | `inherit` | `boolean` | Whether this color is inherited by child themes via `extend()`. Default: `true`. Set to `false` to make the color local to the current theme. |
222
223
 
223
- #### Lightness values
224
+ #### Tone values
225
+
226
+ `tone` (0–100) replaces OKHSL lightness with a contrast-uniform axis — equal tone steps give equal WCAG contrast. See [`docs/okhst.md`](okhst.md) for the math. (To port old `lightness` values, see [migration.md](migration.md).)
224
227
 
225
228
  | Form | Example | Meaning |
226
229
  |---|---|---|
227
- | Number (absolute) | `lightness: 45` | Absolute lightness 0–100. |
228
- | String (relative) | `lightness: '-52'` | Relative to base color's lightness (requires `base`). |
229
- | HC pair | `lightness: ['-7', '-20']` | `[normal, high-contrast]`. A single value applies to both. |
230
+ | Number (absolute) | `tone: 45` | Absolute tone 0–100. |
231
+ | String (relative) | `tone: '-52'` | Relative to base color's tone (requires `base`). |
232
+ | Extreme | `tone: 'max'` / `'min'` | Force to the scheme's highest (`'max'` = 100) or lowest (`'min'` = 0) tone. No `base` needed. |
233
+ | HC pair | `tone: ['-7', '-20']` | `[normal, high-contrast]`. A single value applies to both. |
234
+
235
+ **Absolute tone** on a dependent color (`base` set) positions the color independently. In dark mode it is tone-mapped (inverted + windowed) on its own. The `contrast` solver acts as a safety net.
236
+
237
+ **Relative tone** applies a signed delta to the base color's resolved tone. Because tone is contrast-uniform, a fixed delta yields a fixed contrast step. In dark mode with `mode: 'auto'`, the offset is anchored to the base's per-scheme tone. If `base + delta` falls outside `[0, 100]`, the result is clamped to the boundary, or — with `flip` (default on) — mirrored to the other side of the base.
238
+
239
+ **Extreme tone** (`'max'` / `'min'`) forces the color to the scheme's tone extreme without a contrast hack or a magic number. `'max'` resolves to author tone 100 and `'min'` to 0; both flow through scheme mapping like an absolute tone, so under `mode: 'auto'` they invert in dark (`'max'` is lightest in light, darkest in dark). Use `mode: 'static'` to pin the same extreme across schemes, or `mode: 'fixed'` to keep the same end without inverting. No `base` required.
240
+
241
+ A dependent color with `base` but no `tone` inherits the base's tone (equivalent to a delta of 0).
230
242
 
231
- **Absolute lightness** on a dependent color (`base` set) positions the color independently. In dark mode it is dark-mapped on its own. The `contrast` solver acts as a safety net.
243
+ #### `flip`
232
244
 
233
- **Relative lightness** applies a signed delta to the base color's resolved lightness. In dark mode with `mode: 'auto'`, the sign flips automatically so a `'-52'` light-mode offset becomes a `+52` dark-mode offset.
245
+ `flip` governs what happens when a result would fall outside its valid range:
234
246
 
235
- A dependent color with `base` but no `lightness` inherits the base's lightness (equivalent to a delta of 0).
247
+ - **Relative `tone` overshoot:** when `base ± delta` exceeds `[0, 100]`, `flip` mirrors the delta to the other side of the base (e.g. `'+30'` becomes `'-30'`) instead of clamping to the boundary.
248
+ - **`contrast` direction:** when the requested tone direction can't meet the floor, `flip` lets the solver try the opposite side (the same behavior as the global `autoFlip`).
236
249
 
237
- #### `contrast` (WCAG floor)
250
+ `flip` defaults to the global `autoFlip` (`true`). Set `flip: false` on a color to clamp instead of mirror — useful when you want a relative offset to stay on the authored side of the base, or to keep an unmet contrast pinned to one direction's extreme.
251
+
252
+ #### `contrast` (floor)
238
253
 
239
254
  ```ts
240
- type MinContrast = number | 'AA' | 'AAA' | 'AA-large' | 'AAA-large';
255
+ type ContrastPreset = 'AA' | 'AAA' | 'AA-large' | 'AAA-large';
256
+ type ContrastSpec =
257
+ | number // bare WCAG ratio
258
+ | ContrastPreset // named WCAG preset
259
+ | { wcag: HCPair<number | ContrastPreset> }
260
+ | { apca: HCPair<number> }; // APCA Lc target
241
261
  ```
242
262
 
243
- | Preset | Ratio |
263
+ | Preset | WCAG ratio |
244
264
  |---|---|
245
265
  | `'AA-large'` | 3 |
246
266
  | `'AA'` | 4.5 |
247
267
  | `'AAA-large'` | 4.5 |
248
268
  | `'AAA'` | 7 |
249
269
 
250
- You can also pass any numeric ratio directly (e.g., `contrast: 4.5`, `contrast: 11`). The constraint is applied independently for each scheme if the `lightness` already satisfies the floor it's kept, otherwise the solver adjusts lightness until the target is met.
270
+ A bare number or preset means **WCAG**. Use `{ wcag }` / `{ apca }` to pick the metric explicitly. The `[normal, highContrast]` pair may live at the outer level (`[4.5, 7]`, `[{ wcag: 4.5 }, { wcag: 7 }]`) or inside the metric (`{ wcag: [4.5, 7] }`, `{ apca: [45, 60] }`).
271
+
272
+ ```ts
273
+ contrast: 4.5 // WCAG 4.5
274
+ contrast: 'AAA' // WCAG 7
275
+ contrast: { wcag: 6 } // WCAG 6
276
+ contrast: { wcag: [4.5, 7] } // WCAG 4.5 normal / 7 high-contrast
277
+ contrast: { apca: 60 } // APCA Lc 60
278
+ contrast: { apca: [45, 60] } // APCA Lc 45 normal / 60 high-contrast
279
+ ```
280
+
281
+ The floor is applied independently per scheme — if the `tone` already satisfies it the tone is kept, otherwise the solver searches in tone (contrast-uniform → a closed-form WCAG seed and fast convergence) until the target is met.
282
+
283
+ By default, the solver crosses to the opposite side of the base color when the requested tone direction cannot satisfy the floor. This is controlled per-color by [`flip`](#flip) (which defaults to the global `autoFlip`). Set `glaze.configure({ autoFlip: false })` — or `flip: false` on a single color — to keep strict directionality: unmet colors pin to that direction's 0 or 100 tone extreme instead of falling back to the original requested value.
251
284
 
252
- By default, `autoFlip` lets the solver cross to the opposite side of the base color when the requested lightness direction cannot satisfy contrast. Set `glaze.configure({ autoFlip: false })` to keep strict directionality: unmet colors pin to that direction's 0 or 100 lightness extreme instead of falling back to the original requested value.
285
+ **Full tone spectrum in HC mode:** in high-contrast variants the `lightTone` and `darkTone` window constraints are bypassed entirely (the window is forced to `[0, 100]`). Colors can reach the full range, maximizing perceivable contrast.
253
286
 
254
- **Full lightness spectrum in HC mode:** in high-contrast variants the `lightLightness` and `darkLightness` window constraints are bypassed entirely. Colors can reach the full 0–100 range, maximizing perceivable contrast.
287
+ **Chromatic drift (verification):** tone is contrast-uniform for grays. A chromatic swatch at a given tone shares its OKHSL lightness with the equivalent gray but drifts in real luminance, so a contrast-floored color may land slightly under its gray-tone expectation. Glaze measures the resolved result against the base and emits a deduped advisory `console.warn` when it drifts below the target. See [`docs/okhst.md`](okhst.md) §Verification.
255
288
 
256
289
  #### Per-color hue override
257
290
 
258
291
  ```ts
259
292
  const theme = glaze(280, 80);
260
293
  theme.colors({
261
- surface: { lightness: 97 },
262
- gradientEnd: { lightness: 90, hue: '+20' }, // 280 + 20 = 300
263
- warning: { lightness: 60, hue: 40 }, // absolute
294
+ surface: { tone: 97 },
295
+ gradientEnd: { tone: 90, hue: '+20' }, // 280 + 20 = 300
296
+ warning: { tone: 60, hue: 40 }, // absolute
264
297
  });
265
298
  ```
266
299
 
@@ -289,7 +322,7 @@ See [Shadows](#shadows) below for the algorithm and tuning details.
289
322
  | `value` | `HCPair<number>` | Mix ratio 0–100 (0 = pure base, 100 = pure target). In `'transparent'` blend, this becomes the target's opacity. Supports HC pairs. |
290
323
  | `blend` | `'opaque' \| 'transparent'` | Default `'opaque'`. |
291
324
  | `space` | `'okhsl' \| 'srgb'` | Interpolation space for opaque blending. Default `'okhsl'`. Ignored for `'transparent'` (always composites in linear sRGB). |
292
- | `contrast` | `HCPair<MinContrast>` | Optional WCAG floor against `base`. The solver adjusts the mix ratio (opaque) or opacity (transparent). |
325
+ | `contrast` | `HCPair<ContrastSpec>` | Optional contrast floor against `base` (WCAG or APCA — see [`contrast`](#contrast-floor)). The solver adjusts the mix ratio (opaque) or opacity (transparent). |
293
326
  | `inherit` | `boolean` | Inheritance flag, default `true`. |
294
327
 
295
328
  See [Mix colors](#mix-colors) below.
@@ -312,10 +345,10 @@ glaze.color(color: GlazeFromInput | GlazeColorInput | GlazeColorValue, config?:
312
345
 
313
346
  | Shape | Example | Notes |
314
347
  |---|---|---|
315
- | **Bare string** | `'#26fcb2'` | Hex or CSS color function (`rgb()`, `hsl()`, `okhsl()`, `oklch()`). |
316
- | **Value object** | `{ h: 152, s: 0.95, l: 0.74 }` | OKHSL, `{ r, g, b }` (sRGB 0–255), or `{ l, c, h }` (OKLCh). |
348
+ | **Bare string** | `'#26fcb2'` | Hex or CSS color function (`rgb()`, `hsl()`, `okhsl()`, `okhst()`, `oklch()`). |
349
+ | **Value object** | `{ h: 152, s: 0.95, l: 0.74 }` | OKHSL, OKHST (`{ h, s, t }`), `{ r, g, b }` (sRGB 0–255), or `{ l, c, h }` (OKLCh). |
317
350
  | **`{ from, ...overrides }`** | `{ from: '#1a1a2e', base: bg, contrast: 'AA' }` | Value + color overrides in one object. |
318
- | **Structured** | `{ hue: 152, saturation: 95, lightness: 74 }` | Full theme-style token (hue/saturation/lightness all in 0–100). |
351
+ | **Structured** | `{ hue: 152, saturation: 95, tone: 74 }` | Full theme-style token (hue/saturation in 0–100, tone in 0–100). |
319
352
 
320
353
  `GlazeColorValue` (bare string or value-object forms) accepts:
321
354
 
@@ -325,23 +358,26 @@ glaze.color(color: GlazeFromInput | GlazeColorInput | GlazeColorValue, config?:
325
358
  | `rgb()` | `'rgb(38 252 178)'`, `'rgb(38 252 178 / 0.8)'` | Modern space syntax. Alpha dropped with warning. |
326
359
  | `hsl()` | `'hsl(152 97% 57%)'` | Modern space syntax. Alpha dropped with warning. |
327
360
  | `okhsl()` | `'okhsl(152 95% 74%)'` | Glaze's own emit format. Alpha dropped with warning. |
361
+ | `okhst()` | `'okhst(152 95% 70%)'` | OKHST tone input (third value is tone 0–100). **Input only** — never emitted. Alpha dropped with warning. |
328
362
  | `oklch()` | `'oklch(0.85 0.18 152)'` | Glaze's own emit format. Alpha dropped with warning. |
329
- | `OkhslColor` object | `{ h: 152, s: 0.95, l: 0.74 }` | Glaze's native shape (h: 0–360, s/l: 0–1). Passing 0–100 for `s`/`l` throws with a hint to use the structured form. |
363
+ | `OkhslColor` object | `{ h: 152, s: 0.95, l: 0.74 }` | OKHSL shape (h: 0–360, s/l: 0–1). Passing 0–100 for `s`/`l` throws with a hint to use the structured form. |
364
+ | `OkhstColor` object | `{ h: 152, s: 0.95, t: 0.70 }` | OKHST shape (h: 0–360, s/t: 0–1). The `t` key disambiguates it from `{ h, s, l }`. **Input only.** |
330
365
  | `RgbColor` object | `{ r: 38, g: 252, b: 178 }` | sRGB 0–255. RGB tuple `[r, g, b]` is not supported — use this object form. |
331
366
  | `OklchColor` object | `{ l: 0.85, c: 0.18, h: 152 }` | OKLCh (L/C: 0–1, H: degrees), same semantics as `oklch()` strings. |
332
367
 
333
- `GlazeColorInput` (structured form) is `{ hue, saturation, lightness, ... }`:
368
+ `GlazeColorInput` (structured form) is `{ hue, saturation, tone, ... }`:
334
369
 
335
370
  | Field | Type | Description |
336
371
  |---|---|---|
337
372
  | `hue` | `number` | 0–360. |
338
373
  | `saturation` | `number` | 0–100. |
339
- | `lightness` | `HCPair<number>` | 0–100, optional HC pair. |
374
+ | `tone` | `HCPair<number \| ExtremeValue>` | 0–100 (contrast-uniform) or `'max'`/`'min'`, optional HC pair. |
340
375
  | `saturationFactor` | `number` | Multiplier on the seed (0–1). Default: `1`. |
341
376
  | `mode` | `AdaptationMode` | Default: `'auto'`. |
377
+ | `flip` | `boolean` | Flip out-of-bounds results instead of clamping. Default: global `autoFlip`. |
342
378
  | `opacity` | `number` | Fixed alpha 0–1. |
343
379
  | `base` | `GlazeColorToken \| GlazeColorValue` | Optional dependency. See [Pairing colors](#pairing-colors). |
344
- | `contrast` | `HCPair<MinContrast>` | WCAG floor against `base`. Without `base`, anchored to the literal seed. |
380
+ | `contrast` | `HCPair<ContrastSpec>` | Contrast floor against `base` (WCAG or APCA). Without `base`, anchored to the literal seed. |
345
381
  | `name` | `string` | Debug label for warnings; doesn't change output keys. Reserved names (`'value'`, `'seed'`, `'externalBase'`) are rejected. |
346
382
 
347
383
  `GlazeFromInput` (from form) is `{ from: GlazeColorValue, ...colorOverrides }`:
@@ -351,10 +387,11 @@ glaze.color(color: GlazeFromInput | GlazeColorInput | GlazeColorValue, config?:
351
387
  | `from` | **Required.** The source color value — same forms as `GlazeColorValue`. |
352
388
  | `hue` | Number (absolute 0–360) or `'+N'`/`'-N'` (relative to seed, never to `base`). |
353
389
  | `saturation` | Override seed saturation (0–100). |
354
- | `lightness` | Number (absolute 0–100) or `'+N'`/`'-N'`. Without `base`, relative anchors to the seed; with `base`, anchors to `base`'s lightness per scheme. |
390
+ | `tone` | Number (absolute 0–100), `'+N'`/`'-N'`, or `'max'`/`'min'`. Without `base`, relative anchors to the seed; with `base`, anchors to `base`'s tone per scheme. |
355
391
  | `saturationFactor` | Multiplier on the seed (0–1). |
356
392
  | `mode` | `'auto'` (default) / `'fixed'` / `'static'`. |
357
- | `contrast` | WCAG floor. Without `base`, anchored to the literal seed; with `base`, solved per scheme. |
393
+ | `flip` | Flip out-of-bounds results instead of clamping. Default: global `autoFlip`. |
394
+ | `contrast` | Contrast floor (WCAG or APCA). Without `base`, anchored to the literal seed; with `base`, solved per scheme. |
358
395
  | `base` | `GlazeColorToken` or raw `GlazeColorValue`. See [Pairing colors](#pairing-colors). |
359
396
  | `opacity` | Fixed alpha 0–1. Combining with `contrast` is not recommended — `console.warn` is emitted. |
360
397
  | `name` | Debug label only — surfaces in warnings/errors. Does not change output keys. |
@@ -366,10 +403,10 @@ Named CSS colors (`'red'`, `'blueviolet'`) are not supported.
366
403
  Every input form defaults to `mode: 'auto'` so the resolved token adapts between light and dark like an ordinary theme color. The config snapshot taken at create time differs by input form:
367
404
 
368
405
  - **Value-shorthand** (bare strings, value objects, and `{ from, ...overrides }`):
369
- - Light variant preserves the input lightness exactly (`lightLightness: false`).
370
- - All other config fields (`darkLightness`, `darkDesaturation`, `darkCurve`, `autoFlip`) snapshot from `globalConfig` at create time.
371
- - **Structured input** (`{ hue, saturation, lightness, ... }`):
372
- - Both lightness windows snapshot from `globalConfig` at create time (same as a theme color).
406
+ - Light variant preserves the input tone exactly (`lightTone: false`).
407
+ - All other config fields (`darkTone`, `darkDesaturation`, `autoFlip`) snapshot from `globalConfig` at create time.
408
+ - **Structured input** (`{ hue, saturation, tone, ... }`):
409
+ - Both tone windows snapshot from `globalConfig` at create time (same as a theme color).
373
410
  - All fields are **snapshotted at color-creation time** — later `glaze.configure()` calls don't retroactively change existing tokens.
374
411
 
375
412
  ```ts
@@ -379,11 +416,14 @@ glaze.color('#26fcb2')
379
416
  // Value-object — same behavior
380
417
  glaze.color({ h: 152, s: 0.95, l: 0.74 })
381
418
 
419
+ // OKHST value-object — tone axis
420
+ glaze.color({ h: 152, s: 0.95, t: 0.70 })
421
+
382
422
  // From form — value + color overrides
383
423
  glaze.color({ from: '#1a1a2e', hue: '+20', contrast: 'AA' })
384
424
 
385
- // Structured form — explicit hue/saturation/lightness (0–100)
386
- glaze.color({ hue: 152, saturation: 95, lightness: 74 })
425
+ // Structured form — explicit hue/saturation/tone (0–100)
426
+ glaze.color({ hue: 152, saturation: 95, tone: 74 })
387
427
  ```
388
428
 
389
429
  ### Token methods
@@ -401,36 +441,35 @@ A `GlazeColorToken` exposes:
401
441
 
402
442
  ### Per-instance config override
403
443
 
404
- The optional `config` second argument (`GlazeConfigOverride`) overrides the resolve-relevant global config fields for a single token or theme. Fields that are omitted fall through to the live global config at create time (and are snapshotted). Pass `false` for a lightness window to disable clamping entirely equivalent to `[0, 100]`.
444
+ The optional `config` second argument (`GlazeConfigOverride`) overrides the resolve-relevant global config fields for a single token or theme. Fields that are omitted fall through to the live global config at create time (and are snapshotted). A tone window can be `[lo, hi]`, `{ lo, hi, eps }`, or `false` (disable clamping).
405
445
 
406
446
  `GlazeConfigOverride`:
407
447
 
408
448
  | Field | Default (from global) | Description |
409
449
  |---|---|---|
410
- | `lightLightness` | `[10, 100]` | Light window `[lo, hi]` or `false` (disable clamping = `[0, 100]`). |
411
- | `darkLightness` | `[15, 95]` | Dark window `[lo, hi]` or `false` (disable clamping). |
450
+ | `lightTone` | `[10, 100]` | Light tone window: `[lo, hi]`, `{ lo, hi, eps }`, or `false` (disable clamping). |
451
+ | `darkTone` | `[15, 95]` | Dark tone window: `[lo, hi]`, `{ lo, hi, eps }`, or `false` (disable clamping). |
412
452
  | `darkDesaturation` | `0.1` | Saturation reduction in dark scheme (0–1). |
413
- | `darkCurve` | `0.5` | Möbius beta for dark `auto`-inversion (0–1). Accepts `[normal, hc]` pair. |
414
- | `autoFlip` | `true` | When solving `contrast`, allow the solver to switch lightness direction if the requested side can't meet the target. |
453
+ | `autoFlip` | `true` | Default for each color's `flip`: when solving `contrast` (or applying a relative `tone` that overshoots), allow crossing to the opposite side instead of clamping. |
415
454
  | `shadowTuning` | `undefined` | Default shadow tuning (meaningful for themes; harmless on color tokens). |
416
455
 
417
456
  Config overrides apply to both `glaze.color()` tokens and `glaze()` themes:
418
457
 
419
458
  ```ts
420
- // Standalone color — preserve raw lightness in both schemes
421
- glaze.color('#26fcb2', { darkLightness: false })
459
+ // Standalone color — preserve raw tone in both schemes
460
+ glaze.color('#26fcb2', { darkTone: false })
422
461
 
423
462
  // Restore the #000 → white dark flip (full dark range)
424
463
  glaze.color('#000000', {
425
- lightLightness: false,
426
- darkLightness: [15, 100],
464
+ lightTone: false,
465
+ darkTone: [15, 100],
427
466
  })
428
467
 
429
468
  // Structured form with config override
430
- glaze.color({ hue: 152, saturation: 95, lightness: 74 }, { darkLightness: false })
469
+ glaze.color({ hue: 152, saturation: 95, tone: 74 }, { darkTone: false })
431
470
 
432
471
  // Theme with config override
433
- const rawTheme = glaze(280, 80, { lightLightness: false })
472
+ const rawTheme = glaze(280, 80, { lightTone: false })
434
473
  ```
435
474
 
436
475
  The override is **snapshotted at create time** so later `glaze.configure()` calls don't change already-created tokens or themes (for non-overridden fields, the snapshot captured the global value at creation time; for themes, non-overridden fields are re-read from the live global at resolve time — see [Theme config override](#theme-config-override)).
@@ -443,17 +482,18 @@ When a theme is created with a `GlazeConfigOverride`, the override is **merged o
443
482
  - Fields you didn't override still react to later `glaze.configure()` calls.
444
483
 
445
484
  ```ts
446
- const t = glaze(280, 80, { lightLightness: [0, 50] });
447
- t.colors({ text: { lightness: 50, saturation: 1 } });
448
- // text.light.l 0.25 — always, regardless of global lightLightness changes.
485
+ const t = glaze(280, 80, { lightTone: [0, 50] });
486
+ t.colors({ text: { tone: 50, saturation: 1 } });
487
+ // text.light lands inside the [0, 50] window — always, regardless of
488
+ // global lightTone changes.
449
489
  // text.dark.s reacts to glaze.configure({ darkDesaturation }) since it's not overridden.
450
490
  ```
451
491
 
452
492
  `extend` inherits the parent's override and shallow-merges the child's:
453
493
 
454
494
  ```ts
455
- const child = t.extend({ config: { darkLightness: false } });
456
- // child: lightLightness: [0, 50] (inherited) + darkLightness: false (added)
495
+ const child = t.extend({ config: { darkTone: false } });
496
+ // child: lightTone { lo: 0, hi: 50 } (inherited) + darkTone: false (added)
457
497
  ```
458
498
 
459
499
  `theme.export()` includes `config`; `glaze.from(data)` restores it.
@@ -473,7 +513,7 @@ Both value-form and structured-form tokens round-trip.
473
513
 
474
514
  ### Pairing colors
475
515
 
476
- Set `base` to anchor a standalone color to another standalone color or raw value. The WCAG contrast solver and relative `lightness` offsets switch their anchor from the literal seed to the base's resolved variant per scheme — so the same text color automatically lands at AA against its background in light, dark, and high-contrast modes.
516
+ Set `base` to anchor a standalone color to another standalone color or raw value. The contrast solver and relative `tone` offsets switch their anchor from the literal seed to the base's resolved variant per scheme — so the same text color automatically lands at AA against its background in light, dark, and high-contrast modes.
477
517
 
478
518
  ```ts
479
519
  const bg = glaze.color('#1a1a2e');
@@ -481,10 +521,10 @@ const bg = glaze.color('#1a1a2e');
481
521
  // Text guaranteed AA against `bg` in every scheme.
482
522
  const text = glaze.color({ from: '#ffffff', base: bg, contrast: 'AA' });
483
523
 
484
- // Border 8 lightness units lighter than `bg` in each scheme.
524
+ // Border 8 tone units lighter than `bg` in each scheme.
485
525
  const border = glaze.color({ from: '#000000',
486
526
  base: bg,
487
- lightness: '+8',
527
+ tone: '+8',
488
528
  mode: 'fixed',
489
529
  });
490
530
 
@@ -495,11 +535,11 @@ const text2 = glaze.color({ from: '#ffffff', base: '#1a1a2e', contrast: 'AA' });
495
535
  Behavior with `base`:
496
536
 
497
537
  - `contrast` is solved per scheme against `base`'s resolved variant (light / dark / lightContrast / darkContrast).
498
- - Relative `lightness: '+N'` / `'-N'` is anchored to `base`'s lightness per scheme (matches theme behavior).
538
+ - Relative `tone: '+N'` / `'-N'` is anchored to `base`'s tone per scheme (matches theme behavior).
499
539
  - Relative `hue: '+N'` / `'-N'` still anchors to the **seed** (the value passed to `glaze.color()`), not the base.
500
540
  - `mode` works as a per-pair knob.
501
541
  - The base token's `.resolve()` is called lazily on the first resolve of the dependent and the result is captured by reference; later mutations to the base don't apply.
502
- - **Structured bases are resolved at full range for linking math**: when a value/`from` color links to a base created via the structured form, the contrast/lightness anchor uses the raw input lightness (not the windowed output). This ensures the anchor matches what you intended, not what the light window remapped it to. The base's own `.resolve()` output is unaffected.
542
+ - **Structured bases are resolved at full range for linking math**: when a value/`from` color links to a base created via the structured form, the contrast/tone anchor uses the raw input tone (not the windowed output). This ensures the anchor matches what you intended, not what the light window remapped it to. The base's own `.resolve()` output is unaffected.
503
543
  - When the contrast target is physically unreachable, `glaze` emits a single `console.warn` per `(name, scheme, target)` triple and returns the closest passing variant. Use the `name` override to make the warning identifiable.
504
544
 
505
545
  Chains compose:
@@ -522,8 +562,8 @@ The `name` override appears in `console.warn` / Error messages but **does not**
522
562
 
523
563
  ```ts
524
564
  theme.colors({
525
- surface: { lightness: 95 },
526
- text: { base: 'surface', lightness: '-52', contrast: 'AAA' },
565
+ surface: { tone: 95 },
566
+ text: { base: 'surface', tone: '-52', contrast: 'AAA' },
527
567
 
528
568
  'shadow-sm': { type: 'shadow', bg: 'surface', fg: 'text', intensity: 5 },
529
569
  'shadow-md': { type: 'shadow', bg: 'surface', fg: 'text', intensity: 10 },
@@ -616,7 +656,7 @@ For a simple fixed-alpha color (no shadow algorithm), use `opacity` on a regular
616
656
 
617
657
  ```ts
618
658
  theme.colors({
619
- overlay: { lightness: 0, opacity: 0.5 },
659
+ overlay: { tone: 0, opacity: 0.5 },
620
660
  });
621
661
  // → 'oklch(0 0 0 / 0.5)'
622
662
  ```
@@ -631,8 +671,8 @@ Produces a solid color by interpolating between `base` and `target`:
631
671
 
632
672
  ```ts
633
673
  theme.colors({
634
- surface: { lightness: 95 },
635
- accent: { lightness: 30 },
674
+ surface: { tone: 95 },
675
+ accent: { tone: 30 },
636
676
  tint: { type: 'mix', base: 'surface', target: 'accent', value: 30 },
637
677
  });
638
678
  ```
@@ -647,8 +687,8 @@ Produces the target color with controlled opacity — useful for hover overlays:
647
687
 
648
688
  ```ts
649
689
  theme.colors({
650
- surface: { lightness: 95 },
651
- black: { lightness: 0, saturation: 0 },
690
+ surface: { tone: 95 },
691
+ black: { tone: 0, saturation: 0 },
652
692
  hover: {
653
693
  type: 'mix', base: 'surface', target: 'black',
654
694
  value: 8, blend: 'transparent',
@@ -695,8 +735,8 @@ Mix colors can reference other mix colors:
695
735
 
696
736
  ```ts
697
737
  theme.colors({
698
- white: { lightness: 100, saturation: 0 },
699
- black: { lightness: 0, saturation: 0 },
738
+ white: { tone: 100, saturation: 0 },
739
+ black: { tone: 0, saturation: 0 },
700
740
  gray: { type: 'mix', base: 'white', target: 'black', value: 50, space: 'srgb' },
701
741
  lightGray: { type: 'mix', base: 'white', target: 'gray', value: 50, space: 'srgb' },
702
742
  });
@@ -877,78 +917,66 @@ The `format` option works on every export: `theme.tokens()`, `theme.tasty()`, `t
877
917
 
878
918
  | Mode | Behavior |
879
919
  |---|---|
880
- | `'auto'` (default) | Full adaptation. Light dark inversion via the Möbius curve. High-contrast boost. |
881
- | `'fixed'` | Color stays recognizable. Lightness is *mapped* (not inverted) into the dark window. Use for brand buttons, CTAs, status banners. |
882
- | `'static'` | No adaptation. Same value in every scheme. |
920
+ | `'auto'` (default) | Full adaptation. Dark is a single tone inversion (`100 − t`) remapped into the dark window. High-contrast uses the full range. |
921
+ | `'fixed'` | Color stays recognizable. Tone is *mapped* (not inverted) into the dark window. Use for brand buttons, CTAs, status banners. |
922
+ | `'static'` | No adaptation. Same tone in every scheme. |
883
923
 
884
- ### How relative lightness adapts
924
+ ### How relative tone adapts
885
925
 
886
- **`auto`** — relative lightness sign flips in dark scheme:
926
+ **`auto`** — the offset is anchored to the base's per-scheme tone:
887
927
 
888
928
  ```
889
- Light: surface L=97, text lightness='-52' → L=45 (dark text on light bg)
890
- Dark: surface inverts to L≈20 (Möbius), sign flips L=20+52=72
891
- contrast solver may push further (light text on dark bg)
929
+ Light: surface tone=97, text tone='-52' → tone 45 (dark text on light bg)
930
+ Dark: surface inverts to a low tone; the '-52' offset re-anchors to the
931
+ base's light tone and maps into the dark window (light text on dark bg)
892
932
  ```
893
933
 
894
- **`fixed`** — lightness is mapped (not inverted), relative sign preserved:
934
+ **`fixed`** — tone is mapped (not inverted), relative sign preserved:
895
935
 
896
936
  ```
897
- Light: accent-fill L=52, accent-text lightness='+48' → L=100 (white on brand)
898
- Dark: accent-fill maps to L≈51.6, sign preserved → L≈99.6
937
+ Light: accent-fill tone=52, accent-text tone='+20' → lighter than the fill
938
+ Dark: accent-fill maps into the dark window, sign preserved
899
939
  ```
900
940
 
901
- **`static`** no adaptation, same value in every scheme.
941
+ Offsets that would push past `[0, 100]` clamp to the boundary, or — with `flip` (default on) — mirror to the other side of the base. Set `flip: false` to keep the authored side and clamp instead.
942
+
943
+ **`static`** — no adaptation, same tone in every scheme.
902
944
 
903
945
  ---
904
946
 
905
947
  ## Light / dark scheme mapping
906
948
 
907
- ### Light scheme lightness
949
+ The mapping is now a single tone pipeline; there is no Möbius curve. See [`docs/okhst.md`](okhst.md) for the full math and the calibrated default constants.
908
950
 
909
- Absolute lightness values (root colors and dependent colors with absolute lightness) are mapped linearly within the configured `lightLightness` window:
951
+ ### Light scheme
910
952
 
911
- ```ts
912
- const [lo, hi] = lightLightness; // default: [10, 100]
913
- const mappedL = (lightness * (hi - lo)) / 100 + lo;
914
- ```
915
-
916
- Both `auto` and `fixed` modes use the same linear formula. `static` mode and HC variants bypass the mapping (identity: `mappedL = l`).
953
+ An authored tone (0–100) is remapped into the `lightTone` window. The window's `lo`/`hi` are OKHSL-lightness endpoints (0–100); the tone is positioned within the window's tone interval and converted to a final OKHSL lightness. `static` mode and HC variants use the full range.
917
954
 
918
- | Color | Raw L | Mapped L (default `[10, 100]`) |
919
- |---|---|---|
920
- | surface (L=97) | 97 | 97.3 |
921
- | accent-fill (L=52) | 52 | 56.8 |
922
- | near-black (L=0) | 0 | 10 |
955
+ ```
956
+ window = lightTone // default [10, 100]
957
+ finalTone = remap(authorTone, window)
958
+ finalL = fromTone(finalTone) // OKHSL lightness
959
+ ```
923
960
 
924
- ### Dark scheme — lightness
961
+ ### Dark scheme
925
962
 
926
- **`auto`** — inverted with a Möbius transformation within the configured window:
963
+ **`auto`** — invert the tone, then remap into the dark window:
927
964
 
928
- ```ts
929
- const [lo, hi] = darkLightness; // default: [15, 95]
930
- const t = (100 - lightness) / 100;
931
- const invertedL = lo + (hi - lo) * t / (t + darkCurve * (1 - t));
932
- // darkCurve default: 0.5
965
+ ```
966
+ window = darkTone // default [15, 95]
967
+ inverted = 100 - authorTone
968
+ finalTone = remap(inverted, window)
933
969
  ```
934
970
 
935
- The `darkCurve` parameter (default `0.5`, range 0–1) controls how much the dark-mode inversion expands lightness deltas. Lower values produce stronger expansion; `1` gives linear (legacy) behavior. Accepts `[normal, highContrast]` pairs (e.g. `darkCurve: [0.5, 0.3]`).
936
-
937
- Unlike a power curve, the Möbius transformation provides **proportional expansion** — small and large deltas are scaled by similar ratios, preserving the visual hierarchy of the light theme.
971
+ Because tone is contrast-uniform, the inversion preserves contrast steps without a fitted curve the asymmetry between light and dark lives entirely in the two windows' `(lo, hi, eps)` scalars (`eps` defaults to the reference `0.05`).
938
972
 
939
- **`fixed`** — mapped without inversion (not affected by `darkCurve`):
973
+ **`fixed`** — remap into the dark window without inversion:
940
974
 
941
- ```ts
942
- const mappedL = (lightness * (hi - lo)) / 100 + lo;
975
+ ```
976
+ finalTone = remap(authorTone, darkTone)
943
977
  ```
944
978
 
945
- | Color | Light L | Auto (curve=0.5) | Auto (curve=1, linear) | Fixed (mapped) |
946
- |---|---|---|---|---|
947
- | surface (L=97) | 97 | 19.7 | 17.4 | 92.6 |
948
- | accent-fill (L=52) | 52 | 66.9 | 53.4 | 56.6 |
949
- | accent-text (L=100) | 100 | 15 | 15 | 95 |
950
-
951
- In high-contrast variants the `darkLightness` window is bypassed — `auto` uses the Möbius curve over the full `[0, 100]` range, `fixed` uses identity.
979
+ In high-contrast variants both windows are bypassed (forced to the full `[0, 100]` range): `auto` still inverts, `fixed`/`static` do not.
952
980
 
953
981
  ### Dark scheme — saturation
954
982
 
@@ -966,10 +994,9 @@ S_dark = S_light * (1 - darkDesaturation) // default: 0.1
966
994
 
967
995
  ```ts
968
996
  glaze.configure({
969
- lightLightness: [10, 100], // or false to disable clamping
970
- darkLightness: [15, 95], // or false to disable clamping
997
+ lightTone: [10, 100], // [lo, hi]; or { lo, hi, eps } / false to disable clamping
998
+ darkTone: [15, 95], // [lo, hi]; or { lo, hi, eps } / false to disable clamping
971
999
  darkDesaturation: 0.1,
972
- darkCurve: 0.5, // or [normal, hc] pair
973
1000
  states: {
974
1001
  dark: '@dark',
975
1002
  highContrast: '@high-contrast',
@@ -985,20 +1012,21 @@ glaze.configure({
985
1012
  });
986
1013
  ```
987
1014
 
1015
+ A `ToneWindow` is `[lo, hi]` (OKHSL-lightness endpoints, reference eps — the common form), `{ lo, hi, eps }` (advanced: explicit per-mode render eps), or `false` to disable clamping (full range `[0, 100]` at the reference eps). `false` removes the *boundaries*, not the contrast-uniform tone curve.
1016
+
988
1017
  `GlazeConfig`:
989
1018
 
990
1019
  | Field | Default | Description |
991
1020
  |---|---|---|
992
- | `lightLightness` | `[10, 100]` | Light scheme lightness window `[lo, hi]`, or `false` to disable clamping (equivalent to `[0, 100]`). Bypassed in HC. |
993
- | `darkLightness` | `[15, 95]` | Dark scheme lightness window, or `false` to disable clamping. Bypassed in HC. |
1021
+ | `lightTone` | `[10, 100]` | Light scheme tone window: `[lo, hi]`, `{ lo, hi, eps }`, or `false` to disable clamping. Bypassed in HC. |
1022
+ | `darkTone` | `[15, 95]` | Dark scheme tone window: `[lo, hi]`, `{ lo, hi, eps }`, or `false` to disable clamping. Bypassed in HC. |
994
1023
  | `darkDesaturation` | `0.1` | Saturation reduction in dark scheme (0–1). |
995
- | `darkCurve` | `0.5` | Möbius beta for dark `auto`-inversion (0–1). Accepts `[normal, hc]` pair. |
996
1024
  | `states.dark` | `'@dark'` | State alias for dark mode tokens (Tasty export). |
997
1025
  | `states.highContrast` | `'@high-contrast'` | State alias for HC tokens. |
998
1026
  | `modes.dark` | `true` | Include dark variants in exports. |
999
1027
  | `modes.highContrast` | `false` | Include HC variants. |
1000
1028
  | `shadowTuning` | `undefined` | Default tuning for all shadow colors. Per-color tuning merges field-by-field. |
1001
- | `autoFlip` | `true` | When solving `contrast`, allow the solver to switch away from the requested lightness direction if that side can't meet the target. With `false`, only the requested direction is considered; unmet contrasts pin the lightness to that direction's extreme (and emit a warning). |
1029
+ | `autoFlip` | `true` | Default for each color's `flip`. When solving `contrast` (or applying a relative `tone` that overshoots `[0, 100]`), allow crossing to the opposite side instead of clamping. With `false`, only the requested direction is considered; unmet contrasts pin the tone to that direction's extreme (and emit a warning) and overshooting offsets clamp to the boundary. Override per color via [`flip`](#flip). |
1002
1030
 
1003
1031
  | Method | Description |
1004
1032
  |---|---|
@@ -1039,10 +1067,12 @@ Resolution priority (highest first):
1039
1067
  | Condition | Behavior |
1040
1068
  |---|---|
1041
1069
  | `contrast` without `base` in a **theme** color | Validation error |
1042
- | Relative `lightness` without `base` in a **theme** color | Validation error |
1070
+ | Relative `tone` without `base` in a **theme** color | Validation error |
1043
1071
  | `contrast` without `base` in `glaze.color()` | Anchors against the literal seed (no error) |
1044
- | Relative `lightness` without `base` in `glaze.color()` | Anchors against the literal seed (no error) |
1045
- | `lightness` resolves outside 0–100 | Clamp silently |
1072
+ | Relative `tone` without `base` in `glaze.color()` | Anchors against the literal seed (no error) |
1073
+ | Relative `tone` overshoots `[0, 100]` | Mirror to the other side of the base (`flip` on, default), or clamp to the boundary (`flip` off) |
1074
+ | `tone` resolves outside 0–100 | Clamp silently |
1075
+ | `'max'` / `'min'` without `base` | Allowed — resolves to the scheme's tone extreme (root color) |
1046
1076
  | `saturation` outside 0–1 | Clamp silently |
1047
1077
  | Circular `base` references | Validation error |
1048
1078
  | `base` references non-existent name | Validation error |
@@ -1112,35 +1142,66 @@ formatOklch(280, 60, 95); // 'oklch(0.95 ... 280)'
1112
1142
 
1113
1143
  To attach an alpha component, use `glaze.format(variant, format)` on a `ResolvedColorVariant` (which carries the `alpha` channel) instead of these raw writers.
1114
1144
 
1145
+ ### OKHST tone utilities
1146
+
1147
+ ```ts
1148
+ import {
1149
+ toTone,
1150
+ fromTone,
1151
+ toneFromY,
1152
+ yFromTone,
1153
+ okhstToOkhsl,
1154
+ okhslToOkhst,
1155
+ variantToOkhsl,
1156
+ REF_EPS,
1157
+ } from '@tenphi/glaze';
1158
+ ```
1159
+
1160
+ | Function | Description |
1161
+ |---|---|
1162
+ | `toTone(l, eps?)` | OKHSL lightness (0–1) → tone (0–100). Defaults to `REF_EPS`. |
1163
+ | `fromTone(t, eps?)` | Tone (0–100) → OKHSL lightness (0–1). Inverse of `toTone`. |
1164
+ | `toneFromY(y, eps?)` / `yFromTone(t, eps?)` | Same transfer in luminance space (0–1). |
1165
+ | `okhstToOkhsl({ h, s, t })` | OKHST → OKHSL (`{ h, s, l }`). |
1166
+ | `okhslToOkhst({ h, s, l })` | OKHSL → OKHST (`{ h, s, t }`). |
1167
+ | `variantToOkhsl(variant)` | `ResolvedColorVariant` (stores `t`) → `{ h, s, l, alpha }` for rendering. |
1168
+ | `REF_EPS` | Reference epsilon (`0.05`) for the canonical tone axis. |
1169
+
1170
+ `ResolvedColorVariant` now stores `{ h, s, t, alpha }` (tone, not lightness). Use `variantToOkhsl(variant).l` to recover OKHSL lightness. See [`docs/okhst.md`](okhst.md) for the full model.
1171
+
1115
1172
  ### Contrast solver
1116
1173
 
1117
1174
  ```ts
1118
1175
  import {
1119
- findLightnessForContrast,
1176
+ findToneForContrast,
1120
1177
  findValueForMixContrast,
1178
+ resolveContrastForMode,
1121
1179
  resolveMinContrast,
1180
+ apcaContrast,
1122
1181
  } from '@tenphi/glaze';
1123
1182
  ```
1124
1183
 
1125
1184
  | Function | Description |
1126
1185
  |---|---|
1127
- | `findLightnessForContrast(opts)` | Binary-search for the OKHSL lightness that meets a WCAG contrast floor against a base color. Returns `{ lightness, contrast, met, branch }`. |
1186
+ | `findToneForContrast(opts)` | Binary-search for the tone (0–1) that meets a contrast floor (WCAG or APCA) against a base color. Returns `{ tone, contrast, met, branch, flipped? }`. |
1128
1187
  | `findValueForMixContrast(opts)` | Same, but searches for a mix `value` (0–1) that meets a contrast floor between a base and a target. |
1129
- | `resolveMinContrast(value)` | Resolves a `MinContrast` (preset or number) to a numeric ratio. |
1188
+ | `resolveContrastForMode(spec, isHC)` | Resolves a `ContrastSpec` to `{ metric: 'wcag' \| 'apca', target }` for the requested mode (picks the normal or HC entry of any pair). |
1189
+ | `resolveMinContrast(value)` | Resolves a `MinContrast` (WCAG preset or number) to a numeric ratio. |
1190
+ | `apcaContrast(yText, yBg)` | APCA Lc magnitude (0–106) for two relative luminances. |
1130
1191
 
1131
- `findLightnessForContrast` options:
1192
+ `findToneForContrast` options:
1132
1193
 
1133
1194
  | Option | Default | Description |
1134
1195
  |---|---|---|
1135
1196
  | `hue` | — | Candidate hue (0–360). |
1136
1197
  | `saturation` | — | Candidate saturation (0–1). |
1137
- | `preferredLightness` | — | Preferred candidate lightness (0–1). Kept if it already meets the target. |
1198
+ | `preferredTone` | — | Preferred candidate tone (0–1). Kept if it already meets the target. |
1138
1199
  | `baseLinearRgb` | — | Base color as linear sRGB tuple. |
1139
- | `contrast` | — | WCAG floor (`MinContrast`). |
1140
- | `lightnessRange` | `[0, 1]` | Search bounds. |
1200
+ | `contrast` | — | `ResolvedContrast` (`{ metric, target }`). |
1201
+ | `toneRange` | `[0, 1]` | Search bounds in tone. |
1141
1202
  | `epsilon` | `1e-4` | Convergence threshold. |
1142
- | `maxIterations` | `14` | Max binary-search iterations per branch. |
1143
- | `initialDirection` | higher-contrast side | Direction to search first (`'lighter'` or `'darker'`). Theme resolution sets this from the requested lightness relative to the base color. |
1203
+ | `maxIterations` | `18` | Max binary-search iterations per branch. |
1204
+ | `initialDirection` | higher-contrast side | Direction to search first (`'lighter'` or `'darker'`). |
1144
1205
  | `flip` | `false` | When `true`, try the opposite direction if the initial one doesn't meet the target. When `false`, only the initial direction is searched — unmet contrasts pin the result to that direction's extreme. |
1145
1206
 
1146
- Result: `{ lightness, contrast, met, branch: 'lighter' | 'darker' | 'preferred', flipped? }`. `flipped: true` indicates the initial direction failed and the opposite direction satisfied the target.
1207
+ Result: `{ tone, contrast, met, branch: 'lighter' | 'darker' | 'preferred', flipped? }`. `flipped: true` indicates the initial direction failed and the opposite direction satisfied the target.