@tenphi/glaze 1.2.0 → 1.3.1

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
@@ -10,6 +10,7 @@ Full reference for every public method, option, and type exported by `@tenphi/gl
10
10
  - [DTCG Resolver-Module](#themedtcgresolveroptions)
11
11
  - [Tailwind CSS](#themetailwindoptions)
12
12
  - [High-contrast pairs](#high-contrast-pairs)
13
+ - [Manual contrast level](#manual-contrast-level)
13
14
  - [Color definitions](#color-definitions)
14
15
  - [Dark seed](#dark-seed-darkhue--darksaturation)
15
16
  - [Standalone color tokens](#standalone-color-tokens)
@@ -408,6 +409,144 @@ promotion for that color.
408
409
 
409
410
  ---
410
411
 
412
+ ## Manual contrast level
413
+
414
+ `contrastLevel` turns contrast from a two-tier switch into a `0–100` slider.
415
+ It is a config field, so it works globally, per theme, per token, and through
416
+ `extend()`:
417
+
418
+ ```ts
419
+ glaze.configure({ contrastLevel: 60 });
420
+ const theme = glaze(280, 80, { contrastLevel: 60 });
421
+ glaze.color('#26fcb2', { contrastLevel: 60 });
422
+ ```
423
+
424
+ | Value | Meaning |
425
+ | ---------------- | -------------------------------------------------------------------- |
426
+ | `'auto'` | Default. The two-tier model: normal variants plus a high-contrast tier. |
427
+ | `0` | Normal contrast, with **no** high-contrast tier. |
428
+ | `100` | The high-contrast scheme as the only scheme. |
429
+ | anything between | Resolved *at* that level. |
430
+
431
+ Levels `0` and `100` reproduce the classic `light` / `dark` and
432
+ `lightContrast` / `darkContrast` output **bit for bit**.
433
+
434
+ ### What the level interpolates
435
+
436
+ Exactly the three things that make a high-contrast variant differ from its
437
+ normal counterpart:
438
+
439
+ | Mechanism | At level `L` |
440
+ | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
441
+ | Authored `HCPair`s — `tone`, `contrast`, shadow `intensity`, mix `value` | the two ends are interpolated |
442
+ | The tone-window bypass | each endpoint moves toward the full range: light `10→0`, dark `15→0 / 95→100` |
443
+ | Contrast escalation — `AA → AAA`, APCA `+15 Lc` | the two numeric targets are interpolated (`AA` at level 50 solves for 5.75) |
444
+
445
+ The interpolated values are then fed through the ordinary resolve, so a
446
+ contrast floor at any level is **solved**, not approximated, and `autoFlip`
447
+ decides once per level.
448
+
449
+ Everything that does not vary by high contrast today — hue, saturation,
450
+ `darkDesaturation`, `opacity`, `pastel` — is unaffected by the level.
451
+
452
+ ### Un-interpolable tone pairs switch at 50
453
+
454
+ Two ends of different kinds have no midpoint, because blending them would
455
+ change *which* resolution rule applies partway up the ramp. These switch from
456
+ the normal entry to the high-contrast one at level 50:
457
+
458
+ ```ts
459
+ tone: [50, 'max']; // absolute vs. extreme
460
+ tone: [50, '+20']; // absolute vs. relative
461
+ tone: ['max', 'min']; // opposite extremes
462
+ ```
463
+
464
+ Same-kind pairs (`[30, 20]`, `['-6', '-12']`, `{ wcag: [4.5, 7] }`,
465
+ `[{ apca: 60 }, { apca: 90 }]`) interpolate smoothly.
466
+
467
+ A `contrast` pair may **not** switch metric — `[4.5, { apca: 75 }]` throws. A
468
+ WCAG ratio and an APCA Lc are different scales, so no target exists between
469
+ them, and even in `'auto'` mode the two variants would be incomparable. Pair
470
+ values inside one metric instead: `{ wcag: [4.5, 7] }` or `{ apca: [60, 90] }`.
471
+
472
+ ### A color never swaps sides mid-slider
473
+
474
+ [`autoFlip`](#autoflip) decides per solve, and its tie-break — when both
475
+ directions meet the floor, take the one nearer the authored tone — depends on
476
+ the target. Along a ramp of targets that criterion shifts, so a naive
477
+ implementation lets a color leap across its own base as the slider moves.
478
+
479
+ Under a manual level the side is instead decided once, from the nearer
480
+ endpoint's target, and preferred at every level in that half of the ramp.
481
+ Consequences:
482
+
483
+ - A color whose two ends land on the same side of its base **never** changes
484
+ side, at any level.
485
+ - A color whose ends genuinely disagree — no single side can satisfy both its
486
+ normal and its high-contrast floor — changes side exactly once, at level 50,
487
+ the same place un-interpolable tone pairs switch.
488
+ - Flipping is not disabled, only re-ordered, so a side that physically cannot
489
+ reach the requested contrast still falls back to the opposite one. The floor
490
+ is met at every level.
491
+
492
+ The underlying solver option is `preferInitial` on
493
+ [`findToneForContrast`](#contrast-solver), should you need the same behavior
494
+ directly.
495
+
496
+ ### High-contrast output
497
+
498
+ A manual level already carries the contrast preference, so there is no second
499
+ tier to emit:
500
+
501
+ - `resolve()` still returns four variants, but `lightContrast` / `darkContrast`
502
+ **mirror** `light` / `dark`.
503
+ - A **global** level turns high-contrast output off outright, so
504
+ `tokens()` / `tasty()` / `json()` / `dtcg()` / `dtcgResolver()` / `tailwind()`
505
+ emit no high-contrast tier. `modes.highContrast` goes inert — it reads as
506
+ "emit a separate high-contrast set *when* contrast is automatic" — so leaving
507
+ `highContrast: true` set in a build config while a user switches their
508
+ preference from auto to manual is fine, and silent.
509
+ - `css()` always returns four strings and ignores `modes`, so its
510
+ `lightContrast` / `darkContrast` strings repeat the normal declarations —
511
+ an existing `@media (prefers-contrast: more)` block keeps matching the base
512
+ block as the level changes, with nothing to rewire.
513
+ - A level on a **single theme or token** does not change which modes are
514
+ emitted. Sibling themes in a palette keep their real high-contrast tier, and
515
+ the manual one reports its own resolved values there.
516
+
517
+ ### Driving it at runtime
518
+
519
+ `configure()` bumps the config version, which invalidates every theme and token
520
+ cache, so a slider only needs to re-export:
521
+
522
+ ```ts
523
+ slider.oninput = () => {
524
+ glaze.configure({ contrastLevel: slider.valueAsNumber });
525
+ apply(theme.css());
526
+ };
527
+ ```
528
+
529
+ Because a manual level skips the two high-contrast passes, a manual resolve is
530
+ also half the work of an `'auto'` one.
531
+
532
+ ### Clearing it, exporting it, base links
533
+
534
+ - **`configure()` never clears a field by omission.** Pass `'auto'` explicitly
535
+ to leave manual mode — globally, or on one theme of a palette.
536
+ - **`.export()` freezes only an *authored* level.** A level set on the instance
537
+ (or passed to `.export()`) is authored intent and is written to the snapshot;
538
+ a level merely inherited from the global config is a live user preference and
539
+ is left out, so a restored theme still follows the current slider. This
540
+ matches how `modes` and `states` are treated.
541
+ - **Set the level globally, or on both sides of a base link.** A per-token level
542
+ on a dependent but not on its base anchors the two at different levels — the
543
+ same caveat as a per-instance `lightTone` override.
544
+
545
+ `resolveContrastForLevel(spec, level, polarity?)` is exported for advanced use;
546
+ see [Contrast solver](#contrast-solver).
547
+
548
+ ---
549
+
411
550
  ## Color definitions
412
551
 
413
552
  `ColorDef` is a discriminated union:
@@ -460,7 +599,7 @@ delta is kept and clamped on the authored side.
460
599
 
461
600
  **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. Without a `base`, 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.
462
601
 
463
- **Extreme tone with a `base`** keeps the pair's contrast across schemes. The tone windows are asymmetric by default (`lightTone: [10, 100]`, `darkTone: [15, 95]`), so re-mapping the extreme through the dark window would squeeze the base-to-extreme span and cost contrast. Instead Glaze measures the tone shift the light scheme applied between the base and the extreme, then replays it against the base's resolved dark tone — mirrored under `mode: 'auto'` (both ends invert), same-signed under `'fixed'`. The result is clamped to `[0, 100]` only, so it may cross the `darkTone` window edge; that is intentional, since the author asked for the extreme. When the shift does not fit above or below the dark base, the color pins at tone 100 / 0. `mode: 'static'` is unaffected, and high-contrast variants already use the full range.
602
+ **Extreme tone with a `base`** keeps the pair's contrast across schemes. The tone windows are asymmetric by default (`lightTone: [10, 100]`, `darkTone: [15, 95]`), so re-mapping the extreme through the dark window would squeeze the base-to-extreme span and cost contrast. Instead Glaze measures the tone shift the light scheme applied between the base and the extreme, then replays it against the base's resolved dark tone — mirrored under `mode: 'auto'` (both ends invert), same-signed under `'fixed'`. The result is clamped to `[0, 100]` only, so it may cross the `darkTone` window edge; that is intentional, since the author asked for the extreme. When the shift does not fit above or below the dark base, the color pins at tone 100 / 0. `mode: 'static'` is unaffected. High-contrast variants follow the same rule, but since their window is already the full range the replay reproduces the plain mapping unless the base itself sits asymmetrically across schemes.
464
603
 
465
604
  ```ts
466
605
  theme.colors({
@@ -490,7 +629,7 @@ type ContrastSpec =
490
629
  | number // bare WCAG ratio
491
630
  | ContrastPreset // named WCAG preset
492
631
  | { wcag: HCPair<number | ContrastPreset> }
493
- | { apca: HCPair<number> }; // APCA Lc target
632
+ | { apca: HCPair<number | ApcaPreset> }; // APCA Lc target
494
633
  ```
495
634
 
496
635
  | Preset | WCAG ratio |
@@ -1553,6 +1692,7 @@ glaze.configure({
1553
1692
  alphaMax: 0.6,
1554
1693
  bgHueBlend: 0.2,
1555
1694
  },
1695
+ contrastLevel: 'auto', // or 0–100 for a manual contrast slider
1556
1696
  });
1557
1697
  ```
1558
1698
 
@@ -1565,16 +1705,17 @@ boundaries, not the tone transfer.
1565
1705
 
1566
1706
  | Field | Default | Description |
1567
1707
  | --------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1568
- | `lightTone` | `[10, 100]` | Light scheme tone window: `[lo, hi]`, `{ lo, hi, eps }`, or `false` to disable clamping. Bypassed in HC. |
1569
- | `darkTone` | `[15, 95]` | Dark scheme tone window: `[lo, hi]`, `{ lo, hi, eps }`, or `false` to disable clamping. Bypassed in HC. |
1708
+ | `lightTone` | `[10, 100]` | Light scheme tone window: `[lo, hi]`, `{ lo, hi, eps }`, or `false` to disable clamping. Bypassed in HC; widened continuously by [`contrastLevel`](#manual-contrast-level). |
1709
+ | `darkTone` | `[15, 95]` | Dark scheme tone window: `[lo, hi]`, `{ lo, hi, eps }`, or `false` to disable clamping. Bypassed in HC; widened continuously by [`contrastLevel`](#manual-contrast-level). |
1570
1710
  | `darkDesaturation` | `0.1` | Saturation reduction in dark scheme (0–1). |
1571
1711
  | `states.dark` | `'@media(prefers-color-scheme: dark)'` | State alias for dark mode tokens ([Tasty](https://tasty.style) export). Defaults to a media query so tokens react to the OS preference without registering custom states. |
1572
1712
  | `states.highContrast` | `'@media(prefers-contrast: more)'` | State alias for HC tokens ([Tasty](https://tasty.style) export). |
1573
1713
  | `modes.dark` | `true` | Include dark variants in exports. |
1574
- | `modes.highContrast` | `false` | Include HC variants. |
1714
+ | `modes.highContrast` | `false` | Include HC variants. Inert while a global [`contrastLevel`](#manual-contrast-level) is set — a manual level has no separate HC tier, so this reads as "emit HC variants when contrast is automatic". |
1575
1715
  | `shadowTuning` | `undefined` | Default tuning for all shadow colors. Per-color tuning merges field-by-field. |
1576
1716
  | `autoFlip` | `true` | Default for each color's `autoFlip`. 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 [`autoFlip`](#autoflip). |
1577
1717
  | `inferRole` | `true` | Infer each color's [`role`](#roles) from its name when no explicit `role` is set. Set to `false` to opt out of name-based inference (the base-opposite and foreground-default fallbacks still apply). |
1718
+ | `contrastLevel` | `'auto'` | Manual contrast level, `0`–`100`, replacing the two-tier high-contrast model with a slider. `0` reproduces the normal output and `100` the high-contrast output, bit for bit. See [Manual contrast level](#manual-contrast-level). |
1578
1719
 
1579
1720
  | Method | Description |
1580
1721
  | ------------------------- | ----------------------------------------------------------------------------------- |
@@ -1582,7 +1723,7 @@ boundaries, not the tone transfer.
1582
1723
  | `glaze.getConfig()` | Snapshot the current resolved config (shallow copy). |
1583
1724
  | `glaze.resetConfig()` | Reset to defaults (also bumps the version counter). |
1584
1725
 
1585
- Themes and standalone color tokens keep a sparse local `GlazeConfigOverride` and merge the live global at resolve time for omitted fields. Authoring `.export(override?)` freezes the effective merge at call time; restored instances pin that freeze. `pastel` is instance-only (theme/token override or per-color) — not set via `configure()`.
1726
+ Themes and standalone color tokens keep a sparse local `GlazeConfigOverride` and merge the live global at resolve time for omitted fields. Authoring `.export(override?)` freezes the effective merge at call time; restored instances pin that freeze. `pastel` is instance-only (theme/token override or per-color) — not set via `configure()`. `contrastLevel` is the one field the freeze treats as a live preference: only an instance-authored level is written to the snapshot ([why](#two-rules-worth-knowing)).
1586
1727
 
1587
1728
  ---
1588
1729
 
@@ -1604,9 +1745,12 @@ palette.tokens({ modes: { dark: true, highContrast: true } });
1604
1745
 
1605
1746
  Resolution priority (highest first):
1606
1747
 
1607
- 1. Per-call `modes` option on `tokens` / `tasty` / `json`.
1608
- 2. `glaze.configure({ modes })` — global config.
1609
- 3. Built-in default: `{ dark: true, highContrast: false }`.
1748
+ 1. A global [`contrastLevel`](#manual-contrast-level) — pins
1749
+ `highContrast: false`, since a manual level leaves no separate high-contrast
1750
+ tier to emit. `highContrast` is inert while it is set.
1751
+ 2. Per-call `modes` option on `tokens` / `tasty` / `json`.
1752
+ 3. `glaze.configure({ modes })` — global config.
1753
+ 4. Built-in default: `{ dark: true, highContrast: false }`.
1610
1754
 
1611
1755
  ---
1612
1756
 
@@ -1621,6 +1765,7 @@ available result.
1621
1765
  | Condition | Behavior |
1622
1766
  | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
1623
1767
  | `contrast` without `base` in a **theme** color | Validation error |
1768
+ | `contrast` pair switching metric (`[4.5, { apca: 75 }]`) | Validation error — a WCAG ratio and an APCA Lc are different scales |
1624
1769
  | Relative `tone` without `base` in a **theme** color | Validation error |
1625
1770
  | `contrast` without `base` in `glaze.color()` | Anchors against the literal seed (no error) |
1626
1771
  | Relative `tone` without `base` in `glaze.color()` | Anchors against the literal seed (no error) |
@@ -1732,6 +1877,7 @@ import {
1732
1877
  findToneForContrast,
1733
1878
  findValueForMixContrast,
1734
1879
  resolveContrastForMode,
1880
+ resolveContrastForLevel,
1735
1881
  resolveMinContrast,
1736
1882
  apcaContrast,
1737
1883
  } from '@tenphi/glaze';
@@ -1742,6 +1888,7 @@ import {
1742
1888
  | `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? }`. |
1743
1889
  | `findValueForMixContrast(opts)` | Same, but searches for a mix `value` (0–1) that meets a contrast floor between a base and a target. |
1744
1890
  | `resolveContrastForMode(spec, isHC, polarity?, outerExplicitHC?)` | Resolves a `ContrastSpec` to `{ metric: 'wcag' \| 'apca', target }` for the requested mode (picks the normal or HC entry of any pair). In HC, applies the metric's auto-enhancement unless `outerExplicitHC` is set or the inner metric pair carries an explicit HC value: APCA +15 Lc (clamped to 106); WCAG AA → AAA / AA-large → AAA-large (AAA-family and bare numbers unchanged). |
1891
+ | `resolveContrastForLevel(spec, level, polarity?)` | Resolves a `ContrastSpec` — including its outer `[normal, highContrast]` pair — at a [manual contrast level](#manual-contrast-level) (0–100), interpolating the two numeric targets. Levels 0 / 100 delegate verbatim to `resolveContrastForMode`; a WCAG-vs-APCA metric switch across the pair has no midpoint and switches at 50. |
1745
1892
  | `resolveMinContrast(value)` | Resolves a `MinContrast` (WCAG preset or number) to a numeric ratio. |
1746
1893
  | `apcaContrast(yText, yBg)` | APCA Lc magnitude (0–106) for two relative luminances. |
1747
1894
 
@@ -1761,5 +1908,6 @@ Exported constants: `APCA_PRESETS`, `APCA_HC_ENHANCEMENT` (`15`, the Enhanced Le
1761
1908
  | `maxIterations` | `18` | Max binary-search iterations per branch. |
1762
1909
  | `initialDirection` | higher-contrast side | Direction to search first (`'lighter'` or `'darker'`). |
1763
1910
  | `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. |
1911
+ | `preferInitial` | `false` | With `flip` on and **both** directions meeting the target, keep `initialDirection` instead of taking whichever result lands nearer `preferredTone`. Makes the chosen side independent of the target — what [`contrastLevel`](#manual-contrast-level) uses to keep a color on one side of its base across the ramp. The flip fallback is unaffected. |
1764
1912
 
1765
1913
  Result: `{ tone, contrast, met, branch: 'lighter' | 'darker' | 'preferred', flipped? }`. `flipped: true` indicates the initial direction failed and the opposite direction satisfied the target.
@@ -642,6 +642,25 @@ In HC variants, Glaze bypasses the normal tone window and uses the full
642
642
  `[0, 100]` range. Edge tones can reach the edge; contrast floors have more room
643
643
  to solve.
644
644
 
645
+ ### A slider instead of a tier
646
+
647
+ The two-tier model assumes contrast is a binary OS preference. When contrast is
648
+ instead a setting *your users* control, set
649
+ [`contrastLevel`](api.md#manual-contrast-level) to a number and the HC tier
650
+ becomes a `0–100` ramp: level `0` is your normal palette, `100` is the HC
651
+ palette you already authored, and everything between is resolved at that level.
652
+
653
+ The authoring work is the same — the HC pairs above are what the slider ramps
654
+ toward, so a palette tuned for high contrast needs no new fields. Two
655
+ consequences worth planning for:
656
+
657
+ - The separate HC tier stops being emitted, so wire the level itself into your
658
+ build or runtime rather than a `prefers-contrast: more` block. Existing
659
+ media-query wiring keeps working; it just receives the same values as the base
660
+ block.
661
+ - `contrastLevel: 0` is a useful state in its own right: normal contrast with no
662
+ HC tier at all, for products that ship the slider but default it off.
663
+
645
664
  ## Checklist
646
665
 
647
666
  Before shipping a palette, verify:
@@ -654,7 +673,8 @@ Before shipping a palette, verify:
654
673
  - Low-stakes visual relationships use tone deltas instead of fake contrast
655
674
  floors.
656
675
  - `inherit: false` is set on default-only tokens so status themes stay focused.
657
- - HC pairs exist where high contrast should visibly tighten.
676
+ - HC pairs exist where high contrast should visibly tighten (they double as the
677
+ ramp for a manual `contrastLevel`).
658
678
  - `glaze.configure({ states, modes })` matches the states registered in the app.
659
679
  - Every emitted scheme (`light`, `dark`, `lightContrast`, `darkContrast`) has
660
680
  been reviewed on complete screens, not only in a token grid.
package/docs/okhst.md CHANGED
@@ -85,6 +85,19 @@ lightTone: { lo: 10, hi: 100, eps: 0.05 }
85
85
  Pass `false` to use the full range. High-contrast variants always bypass the
86
86
  ordinary boundaries and use the full range.
87
87
 
88
+ A [manual contrast level](api.md#manual-contrast-level) generalizes that bypass
89
+ continuously: with `contrastLevel: L` (`f = L / 100`), each boundary moves toward
90
+ the full range instead of jumping to it —
91
+
92
+ ```text
93
+ lo(f) = lo + (0 - lo) * f
94
+ hi(f) = hi + (100 - hi) * f
95
+ ```
96
+
97
+ — so the light default walks `[10, 100] → [0, 100]` and the dark default
98
+ `[15, 95] → [0, 100]`. The render `eps` is not interpolated, and a `false` window
99
+ is already the full range and therefore level-invariant.
100
+
88
101
  ## Scheme adaptation
89
102
 
90
103
  Each regular color has an adaptation `mode`:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenphi/glaze",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "OKHST-based color theme generator with WCAG contrast solving for light, dark, and high-contrast schemes",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",