diff --git a/.changeset/quiet-pugs-remember.md b/.changeset/quiet-pugs-remember.md new file mode 100644 index 0000000..19d1c50 --- /dev/null +++ b/.changeset/quiet-pugs-remember.md @@ -0,0 +1,29 @@ +--- +'@tenphi/glaze': minor +--- + +A manual `contrastLevel` no longer suppresses high-contrast output. + +The level now does one thing: it positions the **normal** `light` / `dark` +variants on the 0–100 slider. The high-contrast tier stays the true +high-contrast resolution — bit-identical to what `'auto'` resolves — at every +level, and `modes.highContrast` alone decides whether it is emitted. The two +compose: a slider raises the baseline while a `prefers-contrast: more` block +still escalates on top of it. + +Most visible in `css()`, which has no `modes` option and always returns four +strings: at a mid level its `lightContrast` / `darkContrast` blocks now carry +genuinely escalated values where they previously repeated the normal +declarations. + +Two consequences of the corrected model: + +- `contrastLevel: 0` now reproduces `'auto'` output exactly, high-contrast tier + included. It no longer implies "no high-contrast tier" — that is + `modes.highContrast: false`, still the default. +- At a global `contrastLevel: 100` the normal variants already *are* the + high-contrast ones, so a separate tier would duplicate them: a single + light/dark set is emitted, even against an explicit `modes.highContrast: true`. + +Also fixes the side-stability probe leaking into the high-contrast passes, which +made a mid-level high-contrast variant diverge from its `'auto'` counterpart. diff --git a/AGENTS.md b/AGENTS.md index e163f4e..149ab67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,11 +37,11 @@ glaze/ | [src/color-token.ts](src/color-token.ts) | Standalone `glaze.color()` tokens. Parses values through `color-value.ts`; owns the structured-input validator, the two factory paths, sparse local config + live resolve, and the JSON-safe export / `glaze.colorFrom` rehydrate round-trip. | | [src/color-value.ts](src/color-value.ts) | `GlazeColorValue` parsing + validation: hex (3/6/8), the `rgb()` / `hsl()` / `okhsl()` / `okhst()` / `oklch()` functions, and the four value-object shapes, all normalized to OKHSL by `extractOkhslFromValue`. Split out of `color-token.ts` so the resolver can reach it for a theme color's `from` without closing an import cycle. Leaf module — depends only on the color math. | | [src/serialize.ts](src/serialize.ts) | Authoring-export helpers: `GLAZE_EXPORT_VERSION`, `assertExportKind` / `assertExportVersion`, and `isThemeExport` / `isColorTokenExport` / `isPaletteExport` type guards. | -| [src/resolver.ts](src/resolver.ts) | Four-pass solver (light → light-HC → dark → dark-HC), or two-pass (light → dark) under a manual `contrastLevel`, where the HC slots mirror the normal ones, `passTone` / `passNumber` / `resolveContrastSpec` feed interpolated inputs into the ordinary pass, and a probe solve at the nearer endpoint pins which side of its base a contrast-solved color sits on. Stores canonical tone (`t`) in variants; per-scheme branches for regular, shadow, and mix defs; integrates the contrast solver, and the OKHST tone helpers. `resolveChannels` owns per-scheme hue/saturation (incl. the `darkHue` / `darkSaturation` seed + def overrides, the `darkDesaturation` bypass, and the absolute hue/saturation a `from` color carries in place of the seed factor) and feeds both the emitted variant and the contrast solver. `configForColor` gives a `from` color a local `lightTone: false` so its light variant reproduces the authored value. Converts to/from OKHSL lightness only at the mix/shadow edges. Pre-seeds externally-resolved bases for `glaze.color({ base })`. | +| [src/resolver.ts](src/resolver.ts) | Four-pass solver (light → light-HC → dark → dark-HC), dropping to two-pass (light → dark) with mirrored HC slots only at `contrastLevel: 100`, where the normal passes already are the HC ones (`hcMirrorsNormal`). Under any other manual level `passTone` / `passNumber` / `resolveContrastSpec` feed interpolated inputs into the two *normal* passes and a probe solve at the nearer endpoint pins which side of its base a contrast-solved color sits on; the HC passes bypass the level and the probe entirely, so the HC tier is bit-identical to `'auto'`. Stores canonical tone (`t`) in variants; per-scheme branches for regular, shadow, and mix defs; integrates the contrast solver, and the OKHST tone helpers. `resolveChannels` owns per-scheme hue/saturation (incl. the `darkHue` / `darkSaturation` seed + def overrides, the `darkDesaturation` bypass, and the absolute hue/saturation a `from` color carries in place of the seed factor) and feeds both the emitted variant and the contrast solver. `configForColor` gives a `from` color a local `lightTone: false` so its light variant reproduces the authored value. Converts to/from OKHSL lightness only at the mix/shadow edges. Pre-seeds externally-resolved bases for `glaze.color({ base })`. | | [src/okhst.ts](src/okhst.ts) | The OKHST tone layer. `REF_EPS`, tone↔lightness transfers (`toTone`/`fromTone`, `toneFromY`/`yFromTone`), OKHST↔OKHSL conversions, `variantToOkhsl` (tone→lightness at render), `normalizeToneWindow` (`[lo,hi]` / `{lo,hi,eps}` / `false` → `{lo,hi,eps}`), `mapToneForScheme` (scheme inversion + window remap, HC bypass), `mapSaturationDark` (the `darkDesaturation` reducer the resolver skips when a dark saturation is authored), and `schemeToneRange` for the solver. `activeWindow` owns the HC window bypass and its continuous form under `contrastLevel`. Only tone adapts here — hue/saturation are the resolver's business. | -| [src/contrast-solver.ts](src/contrast-solver.ts) | Tone-based binary-search solver for WCAG **and** APCA. Public API: `findToneForContrast` (incl. the `preferInitial` tie-break the manual level uses to keep a color's side stable), `findValueForMixContrast`, `resolveContrastForMode`, `resolveContrastForLevel` (both ends resolved, target interpolated for a manual `contrastLevel`; throws on a metric switch), `contrastMetricOf`, `resolveMinContrast`, `apcaContrast`. Closed-form WCAG seed + tone search. | +| [src/contrast-solver.ts](src/contrast-solver.ts) | Tone-based binary-search solver for WCAG **and** APCA. Public API: `findToneForContrast` (incl. the `preferInitial` tie-break the manual level uses on its normal passes to keep a color's side stable), `findValueForMixContrast`, `resolveContrastForMode`, `resolveContrastForLevel` (both ends resolved, target interpolated for a manual `contrastLevel`; throws on a metric switch), `contrastMetricOf`, `resolveMinContrast`, `apcaContrast`. Closed-form WCAG seed + tone search. | | [src/shadow.ts](src/shadow.ts) | Shadow + mix def predicates (`isShadowDef`, `isMixDef`), default `ShadowTuning`, tuning merge, the actual `computeShadow` math (hue blend, saturation cap, lightness clamp, `tanh` alpha curve) operating on OKHSL lightness at the edge, and `circularLerp` for hue. | -| [src/hc-pair.ts](src/hc-pair.ts) | Tiny shared helpers: HC-pair selection (`pairNormal` / `pairHC`), HC-pair interpolation for the manual contrast level (`contrastFraction`, `levelFraction`, `numberAt`, `parseToneValueAt`, `PAIR_SWITCH`), tone-value parsing (`parseToneValue` for absolute / relative / `'max'`/`'min'` extremes, `isExtremeTone`), root-tone discrimination (`isAbsoluteTone`, now incl. extremes), `clamp` / `lerp`, hue resolution (`resolveEffectiveHue`), relative-value parsing. Leaf module — types only, so every other module can import it. | +| [src/hc-pair.ts](src/hc-pair.ts) | Tiny shared helpers: HC-pair selection (`pairNormal` / `pairHC`), HC-pair interpolation for the manual contrast level (`contrastFraction`, `hcMirrorsNormal` — the level-100 "HC slots are duplicates" predicate, `levelFraction`, `numberAt`, `parseToneValueAt`, `PAIR_SWITCH`), tone-value parsing (`parseToneValue` for absolute / relative / `'max'`/`'min'` extremes, `isExtremeTone`), root-tone discrimination (`isAbsoluteTone`, now incl. extremes), `clamp` / `lerp`, hue resolution (`resolveEffectiveHue`), relative-value parsing. Leaf module — types only, so every other module can import it. | | [src/formatters.ts](src/formatters.ts) | Variant→string dispatch (`okhsl` / `rgb` / `hsl` / `oklch`) and the four token-map shapes Glaze emits: `buildTokenMap` ([Tasty](https://tasty.style) `#name` keys + state aliases), `buildFlatTokenMap` (per-variant maps), `buildJsonMap` (per-color JSON), `buildCssMap` (CSS custom-property declarations). For `splitHue` it emits the light hue declarations plus, via `darkHueDeclarations` in `channels.ts`, the whole dark set when a `darkHue` makes it differ. Also the `resolveModes` helper used everywhere. | | [src/okhsl-color-math.ts](src/okhsl-color-math.ts) | OKHSL ↔ linear-sRGB ↔ gamma-sRGB ↔ OKLab conversions, hex parsing (3/6/8 digits), gamut clamping, and the `formatOkhsl` / `formatRgb` / `formatHsl` / `formatOklch` writers. The only file with the actual color science. | | [src/config.ts](src/config.ts) | Global config singleton. `defaultConfig()` is the one source of truth for defaults; `configure()` mutates the live object and bumps a monotonic `configVersion` so theme caches invalidate. `snapshotConfig()` powers `glaze.getConfig()`. `freezeConfigForExport()` freezes resolve-relevant fields for authoring export (`pastel` is instance-only; `contrastLevel` is frozen only when instance-authored, since a global level is a live preference). `normalizeContrastLevel()` validates the manual contrast level. | diff --git a/README.md b/README.md index e5c2703..38b651b 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ contrast floor. - **Mix colors** — blend two colors with OKHSL or sRGB interpolation, opaque or transparent, with optional contrast solving - **Shadow colors** — OKHSL-native shadow computation with automatic alpha, fg/bg tinting, and per-scheme adaptation - **Light + Dark + High-Contrast** — all schemes from one definition -- **Manual contrast level** — swap the high-contrast tier for a `0–100` slider; `0` is your normal palette, `100` the high-contrast one, and every level in between is solved, not approximated +- **Manual contrast level** — put your normal palette on a `0–100` slider between itself and the high-contrast one; every level in between is solved, not approximated, and the high-contrast tier stays available on top - **Per-color hue override** — absolute or relative hue shifts within a theme - **Multi-format output** — native `rgb`, `hsl`, and `oklch`, plus [Tasty](https://tasty.style)-compatible `okhsl` and `okhst` diff --git a/docs/api.md b/docs/api.md index 71c5f87..0f3d990 100644 --- a/docs/api.md +++ b/docs/api.md @@ -411,9 +411,9 @@ promotion for that color. ## Manual contrast level -`contrastLevel` turns contrast from a two-tier switch into a `0–100` slider. -It is a config field, so it works globally, per theme, per token, and through -`extend()`: +`contrastLevel` puts the normal end of the two-tier contrast model on a `0–100` +slider. It is a config field, so it works globally, per theme, per token, and +through `extend()`: ```ts glaze.configure({ contrastLevel: 60 }); @@ -421,12 +421,17 @@ const theme = glaze(280, 80, { contrastLevel: 60 }); glaze.color('#26fcb2', { contrastLevel: 60 }); ``` -| Value | Meaning | -| ---------------- | -------------------------------------------------------------------- | -| `'auto'` | Default. The two-tier model: normal variants plus a high-contrast tier. | -| `0` | Normal contrast, with **no** high-contrast tier. | -| `100` | The high-contrast scheme as the only scheme. | -| anything between | Resolved *at* that level. | +The level moves the **normal** variants — `light` and `dark` — along that slider. +It says nothing about high contrast: `lightContrast` / `darkContrast` stay the +true high-contrast resolution at every level, and +[`modes.highContrast`](#output-modes) alone decides whether they are emitted. + +| Value | Meaning | +| ---------------- | -------------------------------------------------------------------------- | +| `'auto'` | Default. The normal variants take the authored normal entries as-is. | +| `0` | The same thing, reached through the slider — output-identical to `'auto'`. | +| `100` | The normal variants *are* the high-contrast ones, so the tier is dropped. | +| anything between | The normal variants are resolved *at* that level. | Levels `0` and `100` reproduce the classic `light` / `dark` and `lightContrast` / `darkContrast` output **bit for bit**. @@ -446,6 +451,9 @@ The interpolated values are then fed through the ordinary resolve, so a contrast floor at any level is **solved**, not approximated, and `autoFlip` decides once per level. +These feed the two **normal** passes only. The high-contrast passes bypass the +level entirely, so the tier resolves exactly as it does in `'auto'` mode. + Everything that does not vary by high contrast today — hue, saturation, `darkDesaturation`, `opacity`, `pastel` — is unaffected by the level. @@ -495,24 +503,35 @@ directly. ### High-contrast output -A manual level already carries the contrast preference, so there is no second -tier to emit: - -- `resolve()` still returns four variants, but `lightContrast` / `darkContrast` - **mirror** `light` / `dark`. -- A **global** level turns high-contrast output off outright, so - `tokens()` / `tasty()` / `json()` / `dtcg()` / `dtcgResolver()` / `tailwind()` - emit no high-contrast tier. `modes.highContrast` goes inert — it reads as - "emit a separate high-contrast set *when* contrast is automatic" — so leaving - `highContrast: true` set in a build config while a user switches their - preference from auto to manual is fine, and silent. +The level and the high-contrast tier are independent, and they compose: the +slider raises the baseline, and a `prefers-contrast: more` block still escalates +on top of whatever baseline the user has chosen. + +- `resolve()` returns four variants at every level. `lightContrast` / + `darkContrast` are the **true** high-contrast resolution — bit-identical to + what `'auto'` resolves for those slots, at any level. +- [`modes.highContrast`](#output-modes) alone decides whether the tier is + emitted, exactly as in `'auto'` mode. A level does not turn it off. - `css()` always returns four strings and ignores `modes`, so its - `lightContrast` / `darkContrast` strings repeat the normal declarations — - an existing `@media (prefers-contrast: more)` block keeps matching the base - block as the level changes, with nothing to rewire. + `lightContrast` / `darkContrast` strings now carry genuinely escalated values + at a mid level, where they previously repeated the normal declarations. An + existing `@media (prefers-contrast: more)` block starts doing real work. - A level on a **single theme or token** does not change which modes are - emitted. Sibling themes in a palette keep their real high-contrast tier, and - the manual one reports its own resolved values there. + emitted — `modes` is global-only, and a palette must not have one sibling + collapse the shared token structure. + +| `contrastLevel` | `modes.highContrast: false` (default) | `modes.highContrast: true` | +| ----------------- | ------------------------------------- | ---------------------------------------- | +| `'auto'` / `0` | `light`, `dark` | + `lightContrast`, `darkContrast` | +| `1`–`99` | `light`, `dark` at the level | + the tier, unchanged by the level | +| `100` (global) | `light`, `dark` = the HC values | same — the tier is dropped as a duplicate | + +That last row is the one exception. At level 100 the normal variants *are* the +high-contrast ones, so a separate tier would be an exact duplicate: a global +level of 100 emits a single light/dark set, and even an explicit +`modes: { highContrast: true }` on the call is suppressed, because it has nothing +different left to ask for. A level of 100 on a single theme or token still +reports into the tier, with values equal to its own normal ones. ### Driving it at runtime @@ -526,8 +545,9 @@ slider.oninput = () => { }; ``` -Because a manual level skips the two high-contrast passes, a manual resolve is -also half the work of an `'auto'` one. +A manual resolve runs the same four passes as an `'auto'` one. Only level 100 +skips the two high-contrast passes, because the normal ones already produced +those values. ### Clearing it, exporting it, base links @@ -540,7 +560,9 @@ also half the work of an `'auto'` one. matches how `modes` and `states` are treated. - **Set the level globally, or on both sides of a base link.** A per-token level on a dependent but not on its base anchors the two at different levels — the - same caveat as a per-instance `lightTone` override. + same caveat as a per-instance `lightTone` override. It is visible in the + high-contrast tier too, since a dependent's high-contrast variant reads its + base's high-contrast slots (and at level 100, the base's mirrored normal ones). `resolveContrastForLevel(spec, level, polarity?)` is exported for advanced use; see [Contrast solver](#contrast-solver). @@ -1767,11 +1789,11 @@ boundaries, not the tone transfer. | `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. | | `states.highContrast` | `'@media(prefers-contrast: more)'` | State alias for HC tokens ([Tasty](https://tasty.style) export). | | `modes.dark` | `true` | Include dark variants in exports. | -| `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". | +| `modes.highContrast` | `false` | Include HC variants. Independent of [`contrastLevel`](#manual-contrast-level) — the level positions the normal variants while these stay the true HC resolution. A global level of `100` is the exception: the tier would duplicate the normal set, so it is dropped. | | `shadowTuning` | `undefined` | Default tuning for all shadow colors. Per-color tuning merges field-by-field. | | `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). | | `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). | -| `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). | +| `contrastLevel` | `'auto'` | Manual contrast level, `0`–`100`, putting the **normal** variants on a slider between themselves and the high-contrast ones. `0` reproduces the normal output and `100` the high-contrast output, bit for bit. The HC tier is unaffected. See [Manual contrast level](#manual-contrast-level). | | Method | Description | | ------------------------- | ----------------------------------------------------------------------------------- | @@ -1779,7 +1801,7 @@ boundaries, not the tone transfer. | `glaze.getConfig()` | Snapshot the current resolved config (shallow copy). | | `glaze.resetConfig()` | Reset to defaults (also bumps the version counter). | -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)). +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](#clearing-it-exporting-it-base-links)). --- @@ -1801,9 +1823,10 @@ palette.tokens({ modes: { dark: true, highContrast: true } }); Resolution priority (highest first): -1. A global [`contrastLevel`](#manual-contrast-level) — pins - `highContrast: false`, since a manual level leaves no separate high-contrast - tier to emit. `highContrast` is inert while it is set. +1. A global [`contrastLevel`](#manual-contrast-level) of `100` — pins + `highContrast: false`, since the normal variants already *are* the + high-contrast ones and the tier would duplicate them. Any other level leaves + `highContrast` alone. 2. Per-call `modes` option on `tokens` / `tasty` / `json`. 3. `glaze.configure({ modes })` — global config. 4. Built-in default: `{ dark: true, highContrast: false }`. @@ -1964,6 +1987,6 @@ Exported constants: `APCA_PRESETS`, `APCA_HC_ENHANCEMENT` (`15`, the Enhanced Le | `maxIterations` | `18` | Max binary-search iterations per branch. | | `initialDirection` | higher-contrast side | Direction to search first (`'lighter'` or `'darker'`). | | `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. | -| `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. | +| `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 (on the normal passes only; the high-contrast ones never probe). The flip fallback is unaffected. | Result: `{ tone, contrast, met, branch: 'lighter' | 'darker' | 'preferred', flipped? }`. `flipped: true` indicates the initial direction failed and the opposite direction satisfied the target. diff --git a/docs/methodology.md b/docs/methodology.md index eb2da24..75e3c90 100644 --- a/docs/methodology.md +++ b/docs/methodology.md @@ -642,24 +642,26 @@ In HC variants, Glaze bypasses the normal tone window and uses the full `[0, 100]` range. Edge tones can reach the edge; contrast floors have more room to solve. -### A slider instead of a tier +### A slider through the tier The two-tier model assumes contrast is a binary OS preference. When contrast is instead a setting *your users* control, set -[`contrastLevel`](api.md#manual-contrast-level) to a number and the HC tier -becomes a `0–100` ramp: level `0` is your normal palette, `100` is the HC -palette you already authored, and everything between is resolved at that level. +[`contrastLevel`](api.md#manual-contrast-level) to a number and your **normal** +palette slides along a `0–100` ramp: level `0` is the palette you authored, `100` +is the HC palette you authored, and everything between is resolved at that level. The authoring work is the same — the HC pairs above are what the slider ramps -toward, so a palette tuned for high contrast needs no new fields. Two -consequences worth planning for: - -- The separate HC tier stops being emitted, so wire the level itself into your - build or runtime rather than a `prefers-contrast: more` block. Existing - media-query wiring keeps working; it just receives the same values as the base - block. -- `contrastLevel: 0` is a useful state in its own right: normal contrast with no - HC tier at all, for products that ship the slider but default it off. +toward, so a palette tuned for high contrast needs no new fields. Two things +worth planning for: + +- The slider and the tier compose. The HC tier keeps resolving at full contrast + regardless of the level, so wire the level into your build or runtime *and* + keep your `prefers-contrast: more` block: a user who has raised the slider and + asked their OS for more contrast still gets the escalation on top. The one + exception is level `100`, where the two coincide and the tier is dropped as a + duplicate. +- `contrastLevel: 0` is the natural rest position for a slider you ship but + default off — it reproduces the `'auto'` output bit for bit. ## Checklist diff --git a/docs/okhst.md b/docs/okhst.md index 5594840..2b53d3c 100644 --- a/docs/okhst.md +++ b/docs/okhst.md @@ -98,6 +98,10 @@ hi(f) = hi + (100 - hi) * f `[15, 95] → [0, 100]`. The render `eps` is not interpolated, and a `false` window is already the full range and therefore level-invariant. +This continuous form applies to the normal variants only. High-contrast variants +are already at the full range, so the level does not widen them further — the +bypass above is what makes the high-contrast tier level-independent. + ## Scheme adaptation Each regular color has an adaptation `mode`: diff --git a/src/formatters.ts b/src/formatters.ts index 499848e..6c7c2c5 100644 --- a/src/formatters.ts +++ b/src/formatters.ts @@ -30,7 +30,7 @@ import { } from './okhsl-color-math'; import { variantToOkhsl } from './okhst'; import { getConfig } from './config'; -import { contrastFraction } from './hc-pair'; +import { hcMirrorsNormal } from './hc-pair'; import type { DtcgColorSpace, DtcgColorToken, @@ -130,28 +130,30 @@ function formatColorValue( /** * Resolve the effective output modes for an export. * - * A global manual `contrastLevel` turns off high-contrast output outright: the - * level *is* the contrast preference, so a separate high-contrast tier has no - * meaning alongside it. `modes.highContrast` therefore reads as "emit a separate - * high-contrast set **when** contrast is automatic" — it goes inert under a - * manual level rather than fighting it, and silently, since switching a - * preference from auto to manual is normal use and not a mistake to report. + * `modes.highContrast` and `contrastLevel` are independent: the level positions + * the normal variants, the high-contrast tier stays the true high-contrast + * resolution, and the two compose — a slider raises the baseline while a + * `prefers-contrast: more` block still escalates on top of it. + * + * The one exception is a **global** level of 100, where the normal variants + * already *are* the high-contrast ones. The tier would be an exact duplicate, so + * it is dropped and a single light/dark set is emitted — including against an + * explicit `highContrast: true` override, which has nothing different left to + * ask for. * * A level set on a single theme or token does not change which modes are - * emitted: sibling themes in a palette may still have a real high-contrast - * tier, and the manual one correctly reports its own resolved values there — - * the alternative would drop its colors from that tier entirely. + * emitted: `modes` is global-only, and a palette must not have one sibling + * collapse the shared token structure. A per-instance level of 100 therefore + * still reports into the tier, with values equal to its own normal ones. */ export function resolveModes( override?: GlazeOutputModes, ): Required { const cfg = getConfig(); + const highContrast = override?.highContrast ?? cfg.modes.highContrast; return { dark: override?.dark ?? cfg.modes.dark, - highContrast: - contrastFraction(cfg) !== undefined - ? false - : (override?.highContrast ?? cfg.modes.highContrast), + highContrast: highContrast && !hcMirrorsNormal(cfg), }; } diff --git a/src/glaze.test.ts b/src/glaze.test.ts index 27416b1..577bb3d 100644 --- a/src/glaze.test.ts +++ b/src/glaze.test.ts @@ -14,7 +14,9 @@ import type { ContrastSpec, DtcgColorToken, GlazeColorTokenExport, + HCPair, ResolvedColorVariant, + ToneValue, } from './types'; /** OKHSL lightness (0–1) of a resolved variant (stored as tone). */ @@ -3745,66 +3747,163 @@ describe('glaze', () => { }); describe('output', () => { - it('mirrors the high-contrast slots onto the normal ones', () => { - glaze.configure({ contrastLevel: 60 }); - const r = fixture().resolve(); - for (const color of r.values()) { - expect(color.lightContrast).toBe(color.light); - expect(color.darkContrast).toBe(color.dark); + it('resolves a level-independent high-contrast tier at every level', () => { + // The headline invariant: the level positions the normal variants only. + // `css()` ignores `modes` and always emits all four blocks, so it reads + // the tier directly. Levels 1 / 99 are the only witnesses for the probe + // gate (it fires on 0 < f < 1); 49 / 50 straddle PAIR_SWITCH; 100 passes + // through the mirror, whose normal values are the 'auto' HC ones. + const theme = fixture(); + const auto = theme.css(); + for (const level of [0, 1, 20, 49, 50, 60, 99, 100]) { + glaze.configure({ contrastLevel: level }); + const css = theme.css(); + expect(css.lightContrast).toBe(auto.lightContrast); + expect(css.darkContrast).toBe(auto.darkContrast); + } + }); + + it('keeps the high-contrast tier off the side-stability probe', () => { + // Guards the `isHighContrast` gate on the probe solve. Below level 50 the + // probe resolves the *normal* target, so an ungated probe pins a side + // from it and then solves the HC target with `preferInitial` — a + // different search from the plain nearest-wins one `'auto'` runs, and it + // converges elsewhere. (At or above 50 the probe resolves the HC target + // itself, so it is a no-op there.) + // + // Every case below was verified to diverge without the gate — the whole + // `fixture()` theme does not, so a broad export-level comparison is not + // enough to hold this line. The divergence needs a base near the tone + // window's edge, which is what pins these to low base tones. + const cases: { + saturation: number; + base: number; + tone: ToneValue; + contrast: HCPair; + }[] = [ + { saturation: 0, base: 15, tone: '+5', contrast: 'AA' }, + { saturation: 90, base: 15, tone: '-5', contrast: 'AA' }, + { + saturation: 90, + base: 15, + tone: '-5', + contrast: { wcag: [4.5, 7] }, + }, + { saturation: 90, base: 30, tone: '+10', contrast: { apca: 30 } }, + { saturation: 90, base: 15, tone: '-5', contrast: { apca: 75 } }, + { + saturation: 90, + base: 15, + tone: '-5', + contrast: { apca: [45, 90] }, + }, + // Decreasing pairs: the HC end asks for *less* than the normal one. + { + saturation: 90, + base: 30, + tone: '+5', + contrast: { apca: [90, 45] }, + }, + { + saturation: 90, + base: 15, + tone: '-5', + contrast: [{ apca: 60 }, { apca: 30 }], + }, + ]; + + function build(c: (typeof cases)[number]) { + const theme = glaze(0, c.saturation); + theme.colors({ + bg: { tone: c.base }, + chip: { + base: 'bg', + tone: c.tone, + contrast: c.contrast, + role: 'text', + }, + }); + return theme.resolve().get('chip')!; + } + + for (const c of cases) { + glaze.resetConfig(); + const anchor = structuredClone(build(c)); + for (const level of [1, 15, 25, 40, 49, 60, 99]) { + glaze.configure({ contrastLevel: level }); + const chip = build(c); + expect(chip.lightContrast).toEqual(anchor.lightContrast); + expect(chip.darkContrast).toEqual(anchor.darkContrast); + } } }); - it('drops the high-contrast tier from every exporter', () => { + it('emits the high-contrast tier from every exporter at a mid level', () => { glaze.configure({ modes: { highContrast: true }, contrastLevel: 60 }); const theme = fixture(); - expect(theme.tokens().lightContrast).toBeUndefined(); - expect(theme.tokens().darkContrast).toBeUndefined(); - expect(theme.json().surface.lightContrast).toBeUndefined(); - expect(theme.dtcg().lightContrast).toBeUndefined(); + expect(theme.tokens().lightContrast).toBeDefined(); + expect(theme.tokens().darkContrast).toBeDefined(); + expect(theme.json().surface.lightContrast).toBeDefined(); + expect(theme.dtcg().lightContrast).toBeDefined(); expect( theme.dtcgResolver().modifiers.scheme.contexts.lightContrast, - ).toBeUndefined(); - expect(theme.tailwind()).not.toContain('.high-contrast'); + ).toBeDefined(); + expect(theme.tailwind()).toContain('.high-contrast'); expect( theme.tasty()['#surface']['@media(prefers-contrast: more)'], - ).toBeUndefined(); + ).toBeDefined(); + // And it carries the escalated values, not copies of the normal ones. + expect(theme.tokens().lightContrast.text).not.toBe( + theme.tokens().light.text, + ); }); - it('ignores modes.highContrast entirely, and says nothing about it', () => { - // Flipping a contrast preference from auto to manual is normal use, so a - // still-set `highContrast: true` goes quietly inert rather than winning - // or warning. It means "emit a separate HC set when contrast is auto". - const warn = vi - .spyOn(console, 'warn') - .mockImplementation(() => undefined); - try { - glaze.configure({ contrastLevel: 60 }); + it('honors a per-call modes.highContrast under a manual level', () => { + glaze.configure({ contrastLevel: 60 }); + const theme = fixture(); + expect( + theme.tokens({ modes: { highContrast: true } }).lightContrast, + ).toBeDefined(); + expect( + theme.dtcg({ modes: { highContrast: true } }).lightContrast, + ).toBeDefined(); + expect(theme.tailwind({ modes: { highContrast: true } })).toContain( + '.high-contrast', + ); + expect( + theme.tasty({ modes: { highContrast: true } })['#surface'][ + '@media(prefers-contrast: more)' + ], + ).toBeDefined(); + }); + + it('emits nothing extra while modes.highContrast is off', () => { + for (const contrastLevel of ['auto', 0, 60, 100] as const) { + glaze.configure({ contrastLevel }); const theme = fixture(); - expect( - theme.tokens({ modes: { highContrast: true } }).lightContrast, - ).toBeUndefined(); - expect( - theme.dtcg({ modes: { highContrast: true } }).lightContrast, - ).toBeUndefined(); - expect( - theme.tailwind({ modes: { highContrast: true } }), - ).not.toContain('.high-contrast'); - expect( - theme.tasty({ modes: { highContrast: true } })['#surface'][ - '@media(prefers-contrast: more)' - ], - ).toBeUndefined(); - expect(warn).not.toHaveBeenCalled(); - } finally { - warn.mockRestore(); + expect(theme.tokens().lightContrast).toBeUndefined(); + expect(theme.tokens().darkContrast).toBeUndefined(); + expect(theme.tailwind()).not.toContain('.high-contrast'); } }); - it('mirrors the high-contrast CSS blocks', () => { - glaze.configure({ contrastLevel: 60 }); - const css = fixture().css(); - expect(css.lightContrast).toBe(css.light); - expect(css.darkContrast).toBe(css.dark); + it('reproduces auto output at level 0, tier included', () => { + glaze.configure({ modes: { highContrast: true } }); + const theme = fixture(); + const auto = { + tokens: theme.tokens(), + json: theme.json(), + tasty: theme.tasty(), + dtcg: theme.dtcg(), + tailwind: theme.tailwind(), + }; + // `configure` merges, so `modes` survives this call. + glaze.configure({ contrastLevel: 0 }); + expect(theme.tokens()).toEqual(auto.tokens); + expect(theme.json()).toEqual(auto.json); + expect(theme.tasty()).toEqual(auto.tasty); + expect(theme.dtcg()).toEqual(auto.dtcg); + expect(theme.tailwind()).toBe(auto.tailwind); }); it('leaves default output untouched at level 0', () => { @@ -3814,7 +3913,66 @@ describe('glaze', () => { expect(theme.tokens()).toEqual(auto); }); - it('keeps a sibling theme’s high-contrast tier in a palette', () => { + it('mirrors the high-contrast slots onto the normal ones at level 100', () => { + glaze.configure({ contrastLevel: 100 }); + const r = fixture().resolve(); + for (const color of r.values()) { + expect(color.lightContrast).toBe(color.light); + expect(color.darkContrast).toBe(color.dark); + } + }); + + it('mirrors the high-contrast CSS blocks at level 100', () => { + glaze.configure({ contrastLevel: 100 }); + const css = fixture().css(); + expect(css.lightContrast).toBe(css.light); + expect(css.darkContrast).toBe(css.dark); + }); + + it('collapses the redundant tier at level 100', () => { + glaze.configure({ modes: { highContrast: true }, contrastLevel: 100 }); + const theme = fixture(); + // Key sets, so an empty-but-present tier is caught too. + expect(Object.keys(theme.tokens())).toEqual(['light', 'dark']); + expect(Object.keys(theme.tasty()['#surface'])).toEqual([ + '', + '@media(prefers-color-scheme: dark)', + ]); + expect( + Object.keys(theme.dtcgResolver().modifiers.scheme.contexts), + ).toEqual(['light', 'dark']); + expect(Object.keys(theme.json().surface)).toEqual(['light', 'dark']); + expect(theme.dtcg().lightContrast).toBeUndefined(); + expect(theme.dtcg().darkContrast).toBeUndefined(); + expect(theme.tailwind()).not.toContain('.high-contrast'); + // An explicit per-call request has nothing different left to emit. + expect( + theme.tokens({ modes: { highContrast: true } }).lightContrast, + ).toBeUndefined(); + }); + + it('gives a mid-level theme a real tier beside an auto sibling', () => { + glaze.configure({ modes: { highContrast: true } }); + const manual = glaze(280, 80, { contrastLevel: 60 }); + const auto = glaze(280, 80); + for (const theme of [manual, auto]) { + theme.colors({ + surface: { tone: 97 }, + text: { base: 'surface', tone: 25, contrast: 'AA', role: 'text' }, + }); + } + const tokens = glaze + .palette({ manual, auto }) + .tokens({ modes: { highContrast: true } }); + // Same seed and defs, so the tier — level-independent — matches... + expect(tokens.lightContrast['manual-text']).toBe( + tokens.lightContrast['auto-text'], + ); + // ...while the normal variants sit at level 60 rather than 0. + expect(tokens.light['manual-text']).not.toBe(tokens.light['auto-text']); + }); + + it('keeps a level-100 theme’s mirrored tier beside a sibling’s real one', () => { glaze.configure({ modes: { highContrast: true } }); const manual = glaze(280, 80, { contrastLevel: 100 }); manual.colors({ surface: { tone: 97 } }); @@ -3827,11 +3985,29 @@ describe('glaze', () => { expect(tokens.lightContrast['auto-surface']).not.toBe( tokens.light['auto-surface'], ); - // ...while the manual theme reports its own resolved value. + // ...while the manual theme reports its own resolved value. A + // per-instance level never collapses the shared token structure. expect(tokens.lightContrast['manual-surface']).toBe( tokens.light['manual-surface'], ); }); + + it('keeps the tier at least as contrasty as the normal variants', () => { + for (const contrastLevel of [0, 25, 50, 75, 100] as const) { + glaze.configure({ contrastLevel }); + const r = fixture().resolve(); + const surface = r.get('surface')!; + const text = r.get('text')!; + expectMeetsWcag( + variantContrast(text.lightContrast, surface.lightContrast), + variantContrast(text.light, surface.light), + ); + expectMeetsWcag( + variantContrast(text.darkContrast, surface.darkContrast), + variantContrast(text.dark, surface.dark), + ); + } + }); }); describe('config plumbing', () => { diff --git a/src/hc-pair.test.ts b/src/hc-pair.test.ts index b459615..3198bad 100644 --- a/src/hc-pair.test.ts +++ b/src/hc-pair.test.ts @@ -1,5 +1,6 @@ import { contrastFraction, + hcMirrorsNormal, levelFraction, numberAt, pairHC, @@ -33,9 +34,11 @@ describe('hc-pair', () => { expect(contrastFraction({ contrastLevel: 100 })).toBe(1); }); - it("distinguishes level 0 from 'auto'", () => { - // Level 0 pins the output to normal contrast AND drops the HC tier; - // 'auto' keeps the tier. They must not collapse into each other. + it("keeps level 0 distinct from 'auto' in the config", () => { + // The two take different branches, and an authored 0 is a slider position + // that `.export()` freezes where an absent level is not. They are not an + // output distinction: at fraction 0 every interpolation returns its normal + // entry verbatim, so level 0 reproduces 'auto' bit for bit. expect(contrastFraction({ contrastLevel: 0 })).not.toBeUndefined(); }); @@ -47,6 +50,22 @@ describe('hc-pair', () => { }); }); + describe('hcMirrorsNormal', () => { + it('is true only at the top of the slider', () => { + expect(hcMirrorsNormal({ contrastLevel: 100 })).toBe(true); + // Clamped, so anything above 100 counts too. + expect(hcMirrorsNormal({ contrastLevel: 150 })).toBe(true); + }); + + it('is false at every other level, and in auto mode', () => { + expect(hcMirrorsNormal({ contrastLevel: 99.9 })).toBe(false); + expect(hcMirrorsNormal({ contrastLevel: 50 })).toBe(false); + expect(hcMirrorsNormal({ contrastLevel: 0 })).toBe(false); + expect(hcMirrorsNormal({ contrastLevel: 'auto' })).toBe(false); + expect(hcMirrorsNormal({})).toBe(false); + }); + }); + describe('numberAt', () => { it('interpolates a numeric pair', () => { expect(numberAt([30, 60], 0.5)).toBe(45); diff --git a/src/hc-pair.ts b/src/hc-pair.ts index e59389a..b255de6 100644 --- a/src/hc-pair.ts +++ b/src/hc-pair.ts @@ -2,7 +2,7 @@ * Small shared helpers used across the resolver pipeline: * - HC-pair selection (`pairNormal` / `pairHC`) * - HC-pair interpolation for the manual contrast level (`contrastFraction`, - * `numberAt`, `parseToneValueAt`) + * `hcMirrorsNormal`, `numberAt`, `parseToneValueAt`) * - Absolute / relative / extreme tone discrimination * - Generic numeric helpers (`clamp`, `lerp`, hue resolution, relative-value * parsing) @@ -43,10 +43,14 @@ export function levelFraction(level: number): number { /** * Manual-contrast blend fraction (0–1) for a resolved config, or `undefined` - * in `'auto'` mode (the two-tier normal + high-contrast model). + * in `'auto'` mode, where the normal variants take the authored normal entries + * as-is. * - * Note `contrastLevel: 0` yields `0`, not `undefined`: "pinned to normal - * contrast with no high-contrast tier" is a distinct state from `'auto'`. + * Note `contrastLevel: 0` yields `0`, not `undefined`. The two select different + * branches, and an authored `0` is a slider position that `.export()` freezes + * where an absent level is not — but they are not an *output* distinction: at + * fraction 0 every interpolation returns its normal entry verbatim, so level 0 + * reproduces `'auto'` bit for bit. */ export function contrastFraction(config: { contrastLevel?: number | 'auto'; @@ -56,6 +60,23 @@ export function contrastFraction(config: { return levelFraction(level); } +/** + * Whether the high-contrast variants are exact duplicates of the normal ones — + * true only at `contrastLevel: 100`, where the normal passes already resolve at + * full contrast. + * + * The level otherwise says nothing about high contrast: it positions the normal + * variants, while `lightContrast` / `darkContrast` stay the true high-contrast + * resolution at every level. This predicate marks the one level where that tier + * carries no new information, so callers can skip the two high-contrast passes, + * their drift checks, and the redundant output tier. + */ +export function hcMirrorsNormal(config: { + contrastLevel?: number | 'auto'; +}): boolean { + return contrastFraction(config) === 1; +} + /** * A numeric HC pair at a blend fraction (shadow `intensity`, mix `value`). * diff --git a/src/resolver.ts b/src/resolver.ts index 9d6fa69..168631f 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -2,12 +2,14 @@ * Color resolution engine. * * Runs the four-pass solver (light → light-HC → dark → dark-HC) that - * turns a `ColorMap` into a fully resolved `ResolvedColor` per name — or, - * under a manual `contrastLevel`, a two-pass solver (light → dark) whose - * authored HC pairs, tone window, and contrast targets are interpolated at - * that level and whose high-contrast slots mirror the normal ones. - * Owns the per-scheme resolve helpers for regular, shadow, and mix - * color defs. + * turns a `ColorMap` into a fully resolved `ResolvedColor` per name. A manual + * `contrastLevel` interpolates the authored HC pairs, tone window, and contrast + * targets at that level for the two *normal* passes only; the high-contrast + * passes bypass the level entirely, so the high-contrast slots are the same + * values `'auto'` resolves. At level 100 the normal passes already are the + * high-contrast ones, so the solver drops to two passes (light → dark) and + * mirrors the slots. Owns the per-scheme resolve helpers for regular, shadow, + * and mix color defs. * * Variants are stored in OKHST: `h` / `s` are OKHSL hue/saturation and * `t` is the canonical contrast-uniform tone (0–1, reference eps). The @@ -36,6 +38,7 @@ import { PAIR_SWITCH, clamp, contrastFraction, + hcMirrorsNormal, numberAt, pairHC, pairNormal, @@ -568,7 +571,9 @@ function resolveDependentColor( }; // Under a manual contrast level, pin which side of the base the color sits - // on so a slider can't send it leaping across its own base. + // on so a slider can't send it leaping across its own base. The normal + // passes only: a high-contrast pass has no ramp to be stable along, and it + // must reproduce what `'auto'` resolves, which never probes. // // `autoFlip`'s tie-break is unstable along a ramp: when both sides meet the // floor it takes whichever lands nearer the anchor, and which side that is @@ -584,7 +589,7 @@ function resolveDependentColor( // A color whose two endpoints genuinely disagree therefore changes side at // most once, at level 50 — the same place every other un-interpolable // decision switches. - const level = contrastFraction(ctx.config); + const level = isHighContrast ? undefined : contrastFraction(config); let preferInitial = false; if (level !== undefined && level > 0 && level < 1) { const probe = findToneForContrast({ @@ -912,10 +917,11 @@ function seedField( * resolved with a `base` + `contrast` may land slightly under the contrast * its tone implies because chromatic luminance drifts from the gray tone. * - * Under a manual `contrastLevel` only the two emitted variants are checked - * (the high-contrast slots are mirrors), and the spec is resolved at the level - * so the check measures the output against the target it was actually solved - * for. + * Under a manual `contrastLevel` the spec is resolved at the level, so the check + * measures each normal variant against the target it was actually solved for. At + * level 100 the high-contrast slots are mirrors and are skipped: the normal + * schemes already carry that check, and repeating it would emit a byte-identical + * advisory under a different scheme label, which dedupes as a separate key. */ function verifyContrastDrift( order: string[], @@ -946,11 +952,11 @@ function verifyContrastDrift( { isDark: true, isHighContrast: false, field: 'dark' }, { isDark: true, isHighContrast: true, field: 'darkContrast' }, ]; - const manual = contrastFraction(config) !== undefined; + const mirrored = hcMirrorsNormal(config); for (const s of schemes) { - // Manual mode mirrors the high-contrast slots and never emits them. - if (manual && s.isHighContrast) continue; + // At level 100 the high-contrast slots are mirrors of the normal ones. + if (mirrored && s.isHighContrast) continue; const spec = resolveContrastSpec( regDef.contrast, s.isHighContrast, @@ -1006,20 +1012,22 @@ export function resolveAllColors( } } - // Under a manual contrast level the normal passes already resolve *at* that - // level, so the two high-contrast passes are skipped and their slots mirror - // the normal ones. Level 100 stays bit-identical to the high-contrast passes: - // within a pass a dependent reads its base's same slot (topo order), and the - // only cross-scheme reads — the relative-tone dark branch and - // `extremeDarkTone` — read `light`, which equals `lightContrast` at 100. - const manual = contrastFraction(config) !== undefined; + // A manual contrast level positions the *normal* passes; the high-contrast + // passes bypass it, so they run as usual and produce the same values `'auto'` + // does. Level 100 is the one exception: there the normal passes already *are* + // the high-contrast passes bit for bit, so the two are skipped and their slots + // mirror the normal ones. That holds because within a pass a dependent reads + // its base's same slot (topo order), and the only cross-scheme reads — the + // relative-tone dark branch and `extremeDarkTone` — read `light`, which equals + // `lightContrast` at 100. + const mirrored = hcMirrorsNormal(config); // Pass 1: Light (normal, or at the level). const lightMap = runPass(order, defs, ctx, false, false, 'light'); // Pass 2: Light high-contrast. let lightHCMap = lightMap; - if (!manual) { + if (!mirrored) { seedField(order, ctx, 'lightContrast', lightMap); lightHCMap = runPass(order, defs, ctx, false, true, 'lightContrast'); } @@ -1031,7 +1039,7 @@ export function resolveAllColors( // Pass 4: Dark high-contrast. let darkHCMap = darkMap; - if (!manual) { + if (!mirrored) { seedField(order, ctx, 'darkContrast', darkMap); darkHCMap = runPass(order, defs, ctx, true, true, 'darkContrast'); } diff --git a/src/types.ts b/src/types.ts index af94ac5..73f5e6b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -104,9 +104,10 @@ export interface GlazeOutputModes { /** * Include high-contrast variants (both light-HC and dark-HC). Default: false. * - * Inert while a global `contrastLevel` is set: a manual level has no separate - * high-contrast tier, so this reads as "emit high-contrast variants when - * contrast is automatic". + * Independent of `contrastLevel` — the level positions the normal variants + * while these stay the true high-contrast resolution, so the two compose. The + * exception is a global level of 100, where the normal variants already *are* + * the high-contrast ones: the tier would duplicate them and is dropped. * @see GlazeConfig.contrastLevel */ highContrast?: boolean; @@ -480,15 +481,15 @@ export interface GlazeConfig { shadowTuning?: ShadowTuning; /** * Manual contrast level — a 0–100 slider from normal contrast (`0`) to high - * contrast (`100`), or `'auto'` (the default) to keep the two-tier - * normal + high-contrast model. + * contrast (`100`), or `'auto'` (the default) to take the authored normal + * entries as-is. * - * With a number, Glaze resolves the `light` / `dark` variants *at* that - * level and stops emitting a separate high-contrast tier: - * `lightContrast` / `darkContrast` mirror their normal counterparts and - * `modes.highContrast` defaults to `false`. Level `0` is therefore "normal - * contrast, no high-contrast tier"; `100` is "the high-contrast scheme as - * the only scheme". + * With a number, Glaze resolves the `light` / `dark` variants *at* that level. + * That is all it does: `lightContrast` / `darkContrast` stay the true + * high-contrast resolution at every level, and `modes.highContrast` alone + * decides whether they are emitted. Level `0` therefore reproduces `'auto'` + * bit for bit. At `100` the normal variants *are* the high-contrast ones, so a + * separate tier would duplicate them and a global level of 100 drops it. * * The level interpolates all three things that make high contrast differ: * authored `[normal, highContrast]` pairs, the tone-window widening, and @@ -539,8 +540,8 @@ export interface GlazeConfigResolved { shadowTuning?: ShadowTuning; autoFlip: boolean; /** - * Manual contrast level (0–100), or `'auto'` for the two-tier - * normal + high-contrast model. + * Manual contrast level (0–100) for the normal variants, or `'auto'` to take + * the authored normal entries as-is. * @see GlazeConfig.contrastLevel */ contrastLevel: number | 'auto'; @@ -571,8 +572,10 @@ export interface GlazeConfigOverride { /** Whether to auto-flip tone when contrast can't be met. */ autoFlip?: boolean; /** - * Manual contrast level (0–100) for this instance, or `'auto'` to opt out - * of a global level and keep the two-tier normal + high-contrast model. + * Manual contrast level (0–100) for this instance, or `'auto'` to opt out of a + * global level and take the authored normal entries as-is. Either way the + * instance keeps its high-contrast tier; a per-instance level never changes + * which modes are emitted. * * Only an instance-authored level is frozen into `.export()` snapshots — a * level inherited from the global config is treated as a live preference and