diff --git a/.changeset/manual-contrast-level.md b/.changeset/manual-contrast-level.md new file mode 100644 index 0000000..0cd3d05 --- /dev/null +++ b/.changeset/manual-contrast-level.md @@ -0,0 +1,59 @@ +--- +'@tenphi/glaze': minor +--- + +Add `contrastLevel` — a manual contrast level that turns the two-tier +high-contrast model into a `0–100` slider. Set it globally via +`glaze.configure()`, or per theme / per token / through `extend()`: + +```ts +glaze.configure({ contrastLevel: 60 }); +const theme = glaze(280, 80, { contrastLevel: 60 }); +glaze.color('#26fcb2', { contrastLevel: 60 }); +``` + +Level `0` reproduces the normal `light` / `dark` output and level `100` the +`lightContrast` / `darkContrast` output, **bit for bit**. Levels in between are +resolved at that level, not interpolated after the fact: Glaze interpolates the +three things that make high contrast differ — authored `[normal, highContrast]` +pairs, the tone-window widening (light `[10,100] → [0,100]`, dark +`[15,95] → [0,100]`), and the `AA → AAA` / APCA `+15 Lc` escalation — and feeds +those through the ordinary resolve. A contrast floor is therefore genuinely +solved at every level (`contrast: 'AA'` at level 50 solves for 5.75), and the +floor is met at every level. + +While a level is set there is no separate high-contrast tier: `lightContrast` / +`darkContrast` mirror their normal counterparts, and a global level turns +high-contrast output off outright so no exporter emits the tier. +`modes.highContrast` goes inert rather than fighting it — it reads as "emit a +separate high-contrast set *when* contrast is automatic" — so a build config that +leaves `highContrast: true` set keeps working, silently, when a user switches +their preference from auto to manual. `css()` keeps its four-string shape with +the high-contrast strings repeating the normal declarations, so existing +`@media (prefers-contrast: more)` wiring keeps working untouched. A level set on +one theme of a palette leaves its siblings' high-contrast tier alone. + +**A color never swaps sides of its base mid-slider.** `autoFlip`'s tie-break — +when both directions meet the floor, take the one nearer the authored tone — +depends on the target, so along a ramp it would let a color leap across its +base. The side is now decided once from the nearer endpoint and preferred +throughout that half of the ramp: a color whose two ends agree never changes +side, and one whose ends genuinely disagree changes exactly once, at level 50. +Flipping is only re-ordered, never disabled, so a side that cannot physically +reach the target still falls back. This is exposed as a new `preferInitial` +option on `findToneForContrast`. + +Un-interpolable tone pairs (`[50, 'max']`, `[50, '+20']`, `['max', 'min']`) +switch at level 50 rather than blending across kinds. + +Also exports `resolveContrastForLevel(spec, level, polarity?)`. + +**A `contrast` pair may no longer switch metric.** `[4.5, { apca: 75 }]` now +throws a validation error: a WCAG ratio and an APCA Lc are different scales, so +no target exists between them and the two variants are incomparable even without +a manual level. Pair values inside one metric instead — `{ wcag: [4.5, 7] }` or +`{ apca: [60, 90] }`. Previously such a pair resolved silently. + +Nothing else changes by default: `contrastLevel` defaults to `'auto'`, which is +today's behavior exactly, and no existing type, signature, or output shape moved. +As a bonus, a manual resolve runs two passes instead of four. diff --git a/AGENTS.md b/AGENTS.md index 7c2db81..45e055d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,19 +36,20 @@ glaze/ | [src/palette.ts](src/palette.ts) | Multi-theme composition (`createPalette` / `createPaletteFromExport`). Shared per-theme driver `buildPaletteOutput` handles prefix resolution, primary duplication, collision filtering. Authoring: `export(override?)`, `theme` / `themes` / `list` / `primary`. | | [src/color-token.ts](src/color-token.ts) | Standalone `glaze.color()` tokens. Owns the value-shorthand parser (hex 3/6/8, `rgb()` / `hsl()` / `okhsl()` / `oklch()`, `{ r, g, b }`, `{ h, s, l }`, `{ l, c, h }`), the structured-input validator, the two factory paths, sparse local config + live resolve, and the JSON-safe export / `glaze.colorFrom` rehydrate round-trip. | | [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). 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 and the `darkDesaturation` bypass) and feeds both the emitted variant and the contrast solver. 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. 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`, `findValueForMixContrast`, `resolveContrastForMode`, `resolveMinContrast`, `apcaContrast`. Closed-form WCAG seed + tone search. | +| [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 and the `darkDesaturation` bypass) and feeds both the emitted variant and the contrast solver. 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/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`), tone-value parsing (`parseToneValue` for absolute / relative / `'max'`/`'min'` extremes, `isExtremeTone`), root-tone discrimination (`isAbsoluteTone`, now incl. extremes), `clamp`, hue resolution (`resolveEffectiveHue`), relative-value parsing. | +| [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/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). | -| [src/validation.ts](src/validation.ts) | `validateColorDefs` (missing references, shadow-bg-cannot-be-shadow, mix-cannot-target-shadow, contrast-without-base, relative-without-base, etc.) + `topoSort` so the resolver processes each color after its dependencies. | +| [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. | +| [src/validation.ts](src/validation.ts) | `validateColorDefs` (missing references, shadow-bg-cannot-be-shadow, mix-cannot-target-shadow, contrast-without-base, relative-without-base, WCAG-vs-APCA metric switch in a `contrast` pair, etc.) + `topoSort` so the resolver processes each color after its dependencies. | | [src/warnings.ts](src/warnings.ts) | Deduped contrast-unmet warnings. Caps cache at 256 entries to keep dev-server output bounded. | -| [src/types.ts](src/types.ts) | All public TypeScript types: `HCPair`, `MinContrast`, `ContrastSpec`, `RelativeValue`, `ExtremeValue` (`'max'`/`'min'`), `ToneValue`, `AdaptationMode`, `OkhstColor`, `ToneWindow` (`[lo,hi]` \| `{lo,hi,eps}` \| `false`), `ColorDef` discriminated union (with `tone` + `flip` + `darkHue` / `darkSaturation`), `GlazeConfig` (with `lightTone` / `darkTone` / `autoFlip`), `GlazeThemeSeed` (`hue` / `saturation` + optional dark pair), `GlazeTheme`, `GlazePalette`, `GlazeColorToken`, the `*Export` shapes, etc. `ResolvedColorVariant` stores `{ h, s, t, alpha }`. | -| `src/glaze.test.ts` | Main test suite — covers the factory surface, resolver behavior, palette composition, shadow/mix algorithms, and the standalone color token round-trip. | +| [src/types.ts](src/types.ts) | All public TypeScript types: `HCPair`, `MinContrast`, `ContrastSpec`, `RelativeValue`, `ExtremeValue` (`'max'`/`'min'`), `ToneValue`, `AdaptationMode`, `OkhstColor`, `ToneWindow` (`[lo,hi]` \| `{lo,hi,eps}` \| `false`), `ColorDef` discriminated union (with `tone` + `flip` + `darkHue` / `darkSaturation`), `GlazeConfig` (with `lightTone` / `darkTone` / `autoFlip` / `contrastLevel`), `GlazeThemeSeed` (`hue` / `saturation` + optional dark pair), `GlazeTheme`, `GlazePalette`, `GlazeColorToken`, the `*Export` shapes, etc. `ResolvedColorVariant` stores `{ h, s, t, alpha }`. | +| `src/glaze.test.ts` | Main test suite — covers the factory surface, resolver behavior, palette composition, shadow/mix algorithms, the manual contrast level, and the standalone color token round-trip. | | `src/contrast-solver.test.ts` | Tests for the binary-search solver in isolation. | +| `src/hc-pair.test.ts` | Tests for the pure HC-pair selection and interpolation helpers. | ### `docs/` files diff --git a/README.md b/README.md index a9a1079..e5c2703 100644 --- a/README.md +++ b/README.md @@ -32,6 +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 - **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 72bc61b..d5eeb91 100644 --- a/docs/api.md +++ b/docs/api.md @@ -10,6 +10,7 @@ Full reference for every public method, option, and type exported by `@tenphi/gl - [DTCG Resolver-Module](#themedtcgresolveroptions) - [Tailwind CSS](#themetailwindoptions) - [High-contrast pairs](#high-contrast-pairs) +- [Manual contrast level](#manual-contrast-level) - [Color definitions](#color-definitions) - [Dark seed](#dark-seed-darkhue--darksaturation) - [Standalone color tokens](#standalone-color-tokens) @@ -408,6 +409,144 @@ 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()`: + +```ts +glaze.configure({ contrastLevel: 60 }); +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. | + +Levels `0` and `100` reproduce the classic `light` / `dark` and +`lightContrast` / `darkContrast` output **bit for bit**. + +### What the level interpolates + +Exactly the three things that make a high-contrast variant differ from its +normal counterpart: + +| Mechanism | At level `L` | +| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| Authored `HCPair`s — `tone`, `contrast`, shadow `intensity`, mix `value` | the two ends are interpolated | +| The tone-window bypass | each endpoint moves toward the full range: light `10→0`, dark `15→0 / 95→100` | +| Contrast escalation — `AA → AAA`, APCA `+15 Lc` | the two numeric targets are interpolated (`AA` at level 50 solves for 5.75) | + +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. + +Everything that does not vary by high contrast today — hue, saturation, +`darkDesaturation`, `opacity`, `pastel` — is unaffected by the level. + +### Un-interpolable tone pairs switch at 50 + +Two ends of different kinds have no midpoint, because blending them would +change *which* resolution rule applies partway up the ramp. These switch from +the normal entry to the high-contrast one at level 50: + +```ts +tone: [50, 'max']; // absolute vs. extreme +tone: [50, '+20']; // absolute vs. relative +tone: ['max', 'min']; // opposite extremes +``` + +Same-kind pairs (`[30, 20]`, `['-6', '-12']`, `{ wcag: [4.5, 7] }`, +`[{ apca: 60 }, { apca: 90 }]`) interpolate smoothly. + +A `contrast` pair may **not** switch metric — `[4.5, { apca: 75 }]` throws. A +WCAG ratio and an APCA Lc are different scales, so no target exists between +them, and even in `'auto'` mode the two variants would be incomparable. Pair +values inside one metric instead: `{ wcag: [4.5, 7] }` or `{ apca: [60, 90] }`. + +### A color never swaps sides mid-slider + +[`autoFlip`](#autoflip) decides per solve, and its tie-break — when both +directions meet the floor, take the one nearer the authored tone — depends on +the target. Along a ramp of targets that criterion shifts, so a naive +implementation lets a color leap across its own base as the slider moves. + +Under a manual level the side is instead decided once, from the nearer +endpoint's target, and preferred at every level in that half of the ramp. +Consequences: + +- A color whose two ends land on the same side of its base **never** changes + side, at any level. +- A color whose ends genuinely disagree — no single side can satisfy both its + normal and its high-contrast floor — changes side exactly once, at level 50, + the same place un-interpolable tone pairs switch. +- Flipping is not disabled, only re-ordered, so a side that physically cannot + reach the requested contrast still falls back to the opposite one. The floor + is met at every level. + +The underlying solver option is `preferInitial` on +[`findToneForContrast`](#contrast-solver), should you need the same behavior +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. +- `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. +- 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. + +### Driving it at runtime + +`configure()` bumps the config version, which invalidates every theme and token +cache, so a slider only needs to re-export: + +```ts +slider.oninput = () => { + glaze.configure({ contrastLevel: slider.valueAsNumber }); + apply(theme.css()); +}; +``` + +Because a manual level skips the two high-contrast passes, a manual resolve is +also half the work of an `'auto'` one. + +### Clearing it, exporting it, base links + +- **`configure()` never clears a field by omission.** Pass `'auto'` explicitly + to leave manual mode — globally, or on one theme of a palette. +- **`.export()` freezes only an *authored* level.** A level set on the instance + (or passed to `.export()`) is authored intent and is written to the snapshot; + a level merely inherited from the global config is a live user preference and + is left out, so a restored theme still follows the current slider. This + 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. + +`resolveContrastForLevel(spec, level, polarity?)` is exported for advanced use; +see [Contrast solver](#contrast-solver). + +--- + ## Color definitions `ColorDef` is a discriminated union: @@ -490,7 +629,7 @@ type ContrastSpec = | number // bare WCAG ratio | ContrastPreset // named WCAG preset | { wcag: HCPair } - | { apca: HCPair }; // APCA Lc target + | { apca: HCPair }; // APCA Lc target ``` | Preset | WCAG ratio | @@ -1553,6 +1692,7 @@ glaze.configure({ alphaMax: 0.6, bgHueBlend: 0.2, }, + contrastLevel: 'auto', // or 0–100 for a manual contrast slider }); ``` @@ -1565,16 +1705,17 @@ boundaries, not the tone transfer. | Field | Default | Description | | --------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `lightTone` | `[10, 100]` | Light scheme tone window: `[lo, hi]`, `{ lo, hi, eps }`, or `false` to disable clamping. Bypassed in HC. | -| `darkTone` | `[15, 95]` | Dark scheme tone window: `[lo, hi]`, `{ lo, hi, eps }`, or `false` to disable clamping. Bypassed in HC. | +| `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). | +| `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). | | `darkDesaturation` | `0.1` | Saturation reduction in dark scheme (0–1). | | `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. | +| `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". | | `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). | | Method | Description | | ------------------------- | ----------------------------------------------------------------------------------- | @@ -1582,7 +1723,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()`. +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)). --- @@ -1604,9 +1745,12 @@ palette.tokens({ modes: { dark: true, highContrast: true } }); Resolution priority (highest first): -1. Per-call `modes` option on `tokens` / `tasty` / `json`. -2. `glaze.configure({ modes })` — global config. -3. Built-in default: `{ dark: true, highContrast: false }`. +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. +2. Per-call `modes` option on `tokens` / `tasty` / `json`. +3. `glaze.configure({ modes })` — global config. +4. Built-in default: `{ dark: true, highContrast: false }`. --- @@ -1621,6 +1765,7 @@ available result. | Condition | Behavior | | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | `contrast` without `base` in a **theme** color | Validation error | +| `contrast` pair switching metric (`[4.5, { apca: 75 }]`) | Validation error — a WCAG ratio and an APCA Lc are different scales | | Relative `tone` without `base` in a **theme** color | Validation error | | `contrast` without `base` in `glaze.color()` | Anchors against the literal seed (no error) | | Relative `tone` without `base` in `glaze.color()` | Anchors against the literal seed (no error) | @@ -1732,6 +1877,7 @@ import { findToneForContrast, findValueForMixContrast, resolveContrastForMode, + resolveContrastForLevel, resolveMinContrast, apcaContrast, } from '@tenphi/glaze'; @@ -1742,6 +1888,7 @@ import { | `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? }`. | | `findValueForMixContrast(opts)` | Same, but searches for a mix `value` (0–1) that meets a contrast floor between a base and a target. | | `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). | +| `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. | | `resolveMinContrast(value)` | Resolves a `MinContrast` (WCAG preset or number) to a numeric ratio. | | `apcaContrast(yText, yBg)` | APCA Lc magnitude (0–106) for two relative luminances. | @@ -1761,5 +1908,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. | 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 6300ce2..eb2da24 100644 --- a/docs/methodology.md +++ b/docs/methodology.md @@ -642,6 +642,25 @@ 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 + +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. + +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. + ## Checklist Before shipping a palette, verify: @@ -654,7 +673,8 @@ Before shipping a palette, verify: - Low-stakes visual relationships use tone deltas instead of fake contrast floors. - `inherit: false` is set on default-only tokens so status themes stay focused. -- HC pairs exist where high contrast should visibly tighten. +- HC pairs exist where high contrast should visibly tighten (they double as the + ramp for a manual `contrastLevel`). - `glaze.configure({ states, modes })` matches the states registered in the app. - Every emitted scheme (`light`, `dark`, `lightContrast`, `darkContrast`) has been reviewed on complete screens, not only in a token grid. diff --git a/docs/okhst.md b/docs/okhst.md index 2d73549..5594840 100644 --- a/docs/okhst.md +++ b/docs/okhst.md @@ -85,6 +85,19 @@ lightTone: { lo: 10, hi: 100, eps: 0.05 } Pass `false` to use the full range. High-contrast variants always bypass the ordinary boundaries and use the full range. +A [manual contrast level](api.md#manual-contrast-level) generalizes that bypass +continuously: with `contrastLevel: L` (`f = L / 100`), each boundary moves toward +the full range instead of jumping to it — + +```text +lo(f) = lo + (0 - lo) * f +hi(f) = hi + (100 - hi) * f +``` + +— so the light default walks `[10, 100] → [0, 100]` and the dark default +`[15, 95] → [0, 100]`. The render `eps` is not interpolated, and a `false` window +is already the full range and therefore level-invariant. + ## Scheme adaptation Each regular color has an adaptation `mode`: diff --git a/src/config.ts b/src/config.ts index ec50119..19a9153 100644 --- a/src/config.ts +++ b/src/config.ts @@ -39,9 +39,28 @@ export function defaultConfig(): GlazeConfigResolved { autoFlip: true, pastel: false, inferRole: true, + contrastLevel: 'auto', }; } +/** + * Validate an authored `contrastLevel`. Numbers clamp to 0–100 so a slider + * can't produce an out-of-range level; a non-finite number throws rather than + * silently reading as `'auto'`, which would turn a `NaN` slider value into an + * invisible no-op. + */ +export function normalizeContrastLevel( + value: number | 'auto' | undefined, +): number | 'auto' { + if (value === undefined || value === 'auto') return 'auto'; + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error( + `glaze: contrastLevel must be a finite number 0–100 or 'auto' (got ${String(value)}).`, + ); + } + return Math.max(0, Math.min(100, value)); +} + let globalConfig: GlazeConfigResolved = defaultConfig(); /** @@ -89,6 +108,9 @@ export function configure(config: GlazeConfig): void { // Instance-only; never configurable globally. pastel: false, inferRole: config.inferRole ?? globalConfig.inferRole, + contrastLevel: normalizeContrastLevel( + config.contrastLevel ?? globalConfig.contrastLevel, + ), }; } @@ -125,6 +147,9 @@ export function mergeConfig( autoFlip: override.autoFlip ?? base.autoFlip, pastel: override.pastel ?? false, inferRole: override.inferRole ?? base.inferRole, + contrastLevel: normalizeContrastLevel( + override.contrastLevel ?? base.contrastLevel, + ), }; } @@ -154,5 +179,16 @@ export function freezeConfigForExport( if (effective.shadowTuning !== undefined) { out.shadowTuning = effective.shadowTuning; } + // `contrastLevel` is deliberately NOT taken from the effective merge. A + // global level is a live user preference (an accessibility slider), not + // authored theme data — freezing it would pin a transient slider position + // into the snapshot and make restored themes ignore the slider forever. + // Only an instance-local or explicitly-passed level is authored intent. + // Same reasoning excludes `modes` and `states` from the freeze. + const authoredLevel = + exportArg?.contrastLevel ?? instanceLocal?.contrastLevel; + if (authoredLevel !== undefined) { + out.contrastLevel = normalizeContrastLevel(authoredLevel); + } return structuredClone(out); } diff --git a/src/contrast-solver.test.ts b/src/contrast-solver.test.ts index e4f020a..57ed9a8 100644 --- a/src/contrast-solver.test.ts +++ b/src/contrast-solver.test.ts @@ -1,10 +1,12 @@ import { resolveMinContrast, + resolveContrastForLevel, resolveContrastForMode, findToneForContrast, apcaContrast, } from './contrast-solver'; import type { ResolvedContrast } from './contrast-solver'; +import type { ContrastSpec, HCPair } from './types'; import { okhslToLinearSrgb, okhslToSrgb, @@ -179,6 +181,145 @@ describe('contrast-solver', () => { }); }); + describe('resolveContrastForLevel', () => { + it('delegates verbatim at the endpoints', () => { + expect(resolveContrastForLevel('AA', 0)).toEqual( + resolveContrastForMode('AA', false), + ); + expect(resolveContrastForLevel('AA', 100)).toEqual( + resolveContrastForMode('AA', true), + ); + }); + + it('interpolates the WCAG auto-promotion', () => { + expect(resolveContrastForLevel('AA', 0).target).toBe(4.5); + expect(resolveContrastForLevel('AA', 50).target).toBe(5.75); + expect(resolveContrastForLevel('AA', 100).target).toBe(7); + }); + + it('interpolates the APCA Enhanced Level boost', () => { + expect(resolveContrastForLevel({ apca: 60 }, 0).target).toBe(60); + expect(resolveContrastForLevel({ apca: 60 }, 50).target).toBe(67.5); + expect(resolveContrastForLevel({ apca: 60 }, 100).target).toBe(75); + }); + + it('interpolates an inner explicit pair without promoting either end', () => { + expect(resolveContrastForLevel({ wcag: [4.5, 7] }, 0).target).toBe(4.5); + expect(resolveContrastForLevel({ wcag: [4.5, 7] }, 50).target).toBe(5.75); + expect(resolveContrastForLevel({ wcag: [4.5, 7] }, 100).target).toBe(7); + // 'AA' on both ends stays 4.5 — explicit HC cancels the promotion. + expect(resolveContrastForLevel({ wcag: ['AA', 'AA'] }, 50).target).toBe( + 4.5, + ); + }); + + it('interpolates an outer explicit pair without enhancing either end', () => { + const spec: HCPair = [{ apca: 60 }, { apca: 90 }]; + expect(resolveContrastForLevel(spec, 0).target).toBe(60); + expect(resolveContrastForLevel(spec, 50).target).toBe(75); + expect(resolveContrastForLevel(spec, 100).target).toBe(90); + }); + + it('respects the APCA_MAX_LC clamp at the high-contrast end', () => { + expect(resolveContrastForLevel({ apca: 100 }, 0).target).toBe(100); + expect(resolveContrastForLevel({ apca: 100 }, 50).target).toBe(103); + expect(resolveContrastForLevel({ apca: 100 }, 100).target).toBe(106); + }); + + it('leaves a target with no escalation flat across the ramp', () => { + for (const level of [0, 25, 50, 75, 100]) { + expect(resolveContrastForLevel('AAA', level).target).toBe(7); + expect(resolveContrastForLevel(5.5, level).target).toBe(5.5); + } + }); + + it('rejects a WCAG-vs-APCA metric pair mid-ramp', () => { + const spec: HCPair = [4.5, { apca: 75 }]; + // No target exists between a WCAG ratio and an APCA Lc. + expect(() => resolveContrastForLevel(spec, 50)).toThrow( + /cannot switch metric/, + ); + // The endpoints still resolve — each end is a single, coherent metric. + expect(resolveContrastForLevel(spec, 0)).toEqual({ + metric: 'wcag', + target: 4.5, + }); + expect(resolveContrastForLevel(spec, 100)).toEqual({ + metric: 'apca', + target: 75, + polarity: 'fg', + }); + }); + + it('preserves polarity presence per metric mid-ramp', () => { + // WCAG is symmetric, so it never carries polarity... + expect(resolveContrastForLevel('AA', 50, 'bg')).toEqual({ + metric: 'wcag', + target: 5.75, + }); + // ...while APCA does. + expect(resolveContrastForLevel({ apca: 60 }, 50, 'bg')).toEqual({ + metric: 'apca', + target: 67.5, + polarity: 'bg', + }); + }); + + it('clamps out-of-range levels', () => { + expect(resolveContrastForLevel('AA', 150).target).toBe(7); + expect(resolveContrastForLevel('AA', -50).target).toBe(4.5); + }); + }); + + describe('preferInitial', () => { + // Both directions can reach a ratio of 3 from a mid-tone base, and the + // darker result lands nearer the preferred tone — so the default tie-break + // flips away from an explicit 'lighter'. That tie-break is what makes the + // side unstable along a ramp of targets. + const shared = { + hue: 0, + saturation: 0, + preferredTone: 0.45, + baseLinearRgb: okhslToLinearSrgb(0, 0, fromTone(50, REF_EPS)), + contrast: wcag(3), + initialDirection: 'lighter' as const, + flip: true, + }; + + it('takes the nearest side by default', () => { + const result = findToneForContrast(shared); + expect(result.met).toBe(true); + expect(result.flipped).toBe(true); + expect(result.tone).toBeLessThan(0.45); + }); + + it('keeps the initial side when both meet', () => { + const result = findToneForContrast({ ...shared, preferInitial: true }); + expect(result.met).toBe(true); + expect(result.flipped).toBeUndefined(); + expect(result.tone).toBeGreaterThan(0.45); + }); + + it('still falls back when the initial side cannot reach the target', () => { + // Against a tone-70 base, a ratio of 4.6 needs ~0.50 tone of separation: + // reachable going darker (0.20), impossible going lighter (1.20). The + // fallback must still fire even though 'lighter' is preferred. + const result = findToneForContrast({ + hue: 0, + saturation: 0, + preferredTone: 0.75, + baseLinearRgb: okhslToLinearSrgb(0, 0, fromTone(70, REF_EPS)), + contrast: wcag(4.6), + initialDirection: 'lighter', + flip: true, + preferInitial: true, + }); + expect(result.met).toBe(true); + expect(result.flipped).toBe(true); + expect(result.tone).toBeLessThan(0.7); + }); + }); + describe('apcaContrast', () => { it('is ~0 for identical colors', () => { expect(Math.abs(apcaContrast(0.5, 0.5))).toBeLessThan(1); diff --git a/src/contrast-solver.ts b/src/contrast-solver.ts index 7e32eb4..8256319 100644 --- a/src/contrast-solver.ts +++ b/src/contrast-solver.ts @@ -7,7 +7,8 @@ * converges quickly. * * Public API: `findToneForContrast`, `findValueForMixContrast`, - * `resolveMinContrast`, `resolveContrastForMode`, `apcaContrast`. + * `resolveMinContrast`, `resolveContrastForMode`, `resolveContrastForLevel`, + * `apcaContrast`. */ import { @@ -17,7 +18,7 @@ import { apcaLuminanceFromLinearRgb, } from './okhsl-color-math'; import { REF_EPS, fromTone, toneFromY } from './okhst'; -import { clamp } from './hc-pair'; +import { clamp, lerp, levelFraction, pairHC, pairNormal } from './hc-pair'; import type { ContrastSpec, HCPair } from './types'; export type LinearRgb = [number, number, number]; @@ -163,6 +164,14 @@ function pickPair(p: HCPair, isHighContrast: boolean): T { return Array.isArray(p) ? (isHighContrast ? p[1] : p[0]) : p; } +/** + * The metric a single `ContrastSpec` measures in. Bare numbers and WCAG + * presets are `'wcag'`; only an `{ apca }` object is `'apca'`. + */ +export function contrastMetricOf(spec: ContrastSpec): ContrastMetric { + return typeof spec === 'object' && 'apca' in spec ? 'apca' : 'wcag'; +} + /** * Resolve a `ContrastSpec` (already selected from any outer HC pair) for a * given mode into `{ metric, target }`. Handles the inner metric HC pair and @@ -217,6 +226,56 @@ export function resolveContrastForMode( }; } +/** + * Resolve a `contrast` spec — including its outer `[normal, highContrast]` + * pair — at a manual contrast `level` (0–100), by resolving both ends and + * interpolating the numeric target. + * + * Level 0 / 100 delegate verbatim to {@link resolveContrastForMode}, so the + * endpoints are bit-identical to classic normal / high-contrast resolution. + * Resolving each end fully first also means every rule that governs the + * escalation still applies at both ends for free: preset mapping, the + * `APCA_MAX_LC` clamp, and the explicit-HC suppression that cancels the + * `AA → AAA` promotion and the `+15 Lc` enhancement. + * + * Throws when the two ends measure in different metrics — a WCAG ratio and an + * APCA Lc live on different scales, so there is no target between them. Such a + * pair is rejected at authoring time by `validateColorDefs`; this is the guard + * for direct callers. + */ +export function resolveContrastForLevel( + spec: HCPair, + level: number, + polarity?: 'fg' | 'bg', +): ResolvedContrast { + const outerExplicitHC = Array.isArray(spec); + const f = levelFraction(level); + const normal = resolveContrastForMode( + pairNormal(spec), + false, + polarity, + outerExplicitHC, + ); + if (f <= 0) return normal; + const hc = resolveContrastForMode( + pairHC(spec), + true, + polarity, + outerExplicitHC, + ); + if (f >= 1) return hc; + if (normal.metric !== hc.metric) { + throw new Error( + `glaze: a "contrast" pair cannot switch metric between its normal ` + + `(${normal.metric}) and high-contrast (${hc.metric}) entry — the two ` + + `targets are on different scales. Use one metric for both entries.`, + ); + } + // Spread `normal` so the WCAG branch keeps its absent `polarity` and the + // APCA branch its resolved one. + return { ...normal, target: lerp(normal.target, hc.target, f) }; +} + // ============================================================================ // APCA (SAPC / APCA-W3 0.1.9 simplified) // ============================================================================ @@ -362,6 +421,19 @@ export interface FindToneForContrastOptions { initialDirection?: 'lighter' | 'darker'; /** Auto-flip tone direction when contrast can't be met. Default: false. */ flip?: boolean; + /** + * When `flip` is on and *both* directions meet the target, keep + * `initialDirection` instead of taking whichever result lands nearer the + * preferred tone. Default: false (nearest wins). + * + * The nearest-wins tie-break is unstable along a ramp of targets: which side + * is nearer shifts as the target grows, so a color can swap sides between two + * adjacent targets. Set this when a caller needs the side to be a property of + * the color rather than of the target — the manual contrast level does. The + * flip itself is unaffected: an initial direction that cannot reach the + * target still falls back to the opposite side. + */ + preferInitial?: boolean; /** Use the hue-independent "safe" chroma boundary. Default: false. */ pastel?: boolean; } @@ -490,6 +562,8 @@ interface SolveCoreOptions { epsilon: number; maxIterations: number; flip: boolean; + /** Keep the initial branch when both meet, instead of taking the nearest. */ + preferInitial: boolean; /** Force the first branch ('lower' searches `[lo, anchor]`). */ initialIsLower: boolean; /** APCA argument order; ignored for WCAG. Default `'fg'`. */ @@ -519,6 +593,7 @@ function solveNearestContrast(opts: SolveCoreOptions): SolveCoreResult { epsilon, maxIterations, flip, + preferInitial, initialIsLower, polarity, } = opts; @@ -567,6 +642,7 @@ function solveNearestContrast(opts: SolveCoreOptions): SolveCoreResult { if (oppositeResult) oppositeResult.met = oppositeResult.contrast >= target; if (initialResult.met && oppositeResult?.met) { + if (preferInitial) return { ...initialResult, lower: initialIsLower }; const initialDist = Math.abs(initialResult.pos - distanceAnchor); const oppositeDist = Math.abs(oppositeResult.pos - distanceAnchor); return initialDist <= oppositeDist @@ -680,6 +756,7 @@ export function findToneForContrast( epsilon, maxIterations, flip: options.flip ?? false, + preferInitial: options.preferInitial ?? false, initialIsLower: initialIsDarker, polarity, }); @@ -791,6 +868,9 @@ export function findValueForMixContrast( epsilon, maxIterations, flip: options.flip ?? false, + // A mix searches along the base→target segment rather than across a base, + // so the nearest-wins tie-break has no side to swap; left as-is. + preferInitial: false, initialIsLower, polarity, }); diff --git a/src/formatters.ts b/src/formatters.ts index 20f6d19..c40cd7d 100644 --- a/src/formatters.ts +++ b/src/formatters.ts @@ -31,6 +31,7 @@ import { } from './okhsl-color-math'; import { variantToOkhsl } from './okhst'; import { getConfig } from './config'; +import { contrastFraction } from './hc-pair'; import type { DtcgColorSpace, DtcgColorToken, @@ -127,13 +128,31 @@ function formatColorValue( return formatVariant(v, format, pastel); } +/** + * 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. + * + * 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. + */ export function resolveModes( override?: GlazeOutputModes, ): Required { const cfg = getConfig(); return { dark: override?.dark ?? cfg.modes.dark, - highContrast: override?.highContrast ?? cfg.modes.highContrast, + highContrast: + contrastFraction(cfg) !== undefined + ? false + : (override?.highContrast ?? cfg.modes.highContrast), }; } diff --git a/src/glaze.test.ts b/src/glaze.test.ts index e09a187..d34f2c6 100644 --- a/src/glaze.test.ts +++ b/src/glaze.test.ts @@ -50,6 +50,36 @@ function variantApca( ); } +/** + * Slack allowed when re-measuring a solved contrast floor. + * + * The solver measures candidates at a tone rounded to 4 decimals + * (`cachedLuminance` in `contrast-solver.ts`), but the resolver stores and emits + * the unrounded tone. Re-measuring the emitted color therefore lands slightly + * off the value the solver judged `met` against — up to a factor of `21^1e-4` + * (~0.021%) for a WCAG ratio, and up to ~0.023 Lc for APCA. An exact + * `>= target` assertion fails whenever a solve happens to converge near that + * rounding boundary. + * + * These tolerances are ~5x the measured worst case and still far tighter than + * the library's own "effectively a pass" thresholds + * (`CONTRAST_WARN_SLACK_WCAG` = 0.98, `CONTRAST_WARN_SLACK_APCA` = 1.5 Lc), so + * a genuinely wrong target — an unpromoted 4.5 where 7 was expected — still + * fails loudly. + */ +const WCAG_MEASURE_SLACK = 0.999; +const APCA_MEASURE_SLACK = 0.1; + +/** Assert a re-measured WCAG ratio meets `target`. @see WCAG_MEASURE_SLACK */ +function expectMeetsWcag(actual: number, target: number): void { + expect(actual).toBeGreaterThanOrEqual(target * WCAG_MEASURE_SLACK); +} + +/** Assert a re-measured APCA Lc meets `target`. @see WCAG_MEASURE_SLACK */ +function expectMeetsApca(actual: number, target: number): void { + expect(actual).toBeGreaterThanOrEqual(target - APCA_MEASURE_SLACK); +} + describe('glaze', () => { beforeEach(() => { glaze.resetConfig(); @@ -983,9 +1013,7 @@ describe('glaze', () => { const surface = resolved.get('surface')!; expect(text.dark.h).toBe(130); - expect(variantContrast(text.dark, surface.dark)).toBeGreaterThanOrEqual( - 4.5, - ); + expectMeetsWcag(variantContrast(text.dark, surface.dark), 4.5); }); it('shadow and mix colors inherit dark channels from their references', () => { @@ -1089,9 +1117,10 @@ describe('glaze', () => { const r = theme.resolve(); const text = r.get('text')!; const surface = r.get('surface')!; - expect( + expectMeetsWcag( variantContrast(text.lightContrast, surface.lightContrast), - ).toBeGreaterThanOrEqual(7); + 7, + ); }); }); @@ -1103,9 +1132,10 @@ describe('glaze', () => { fg: { base: 'bg', tone: 40, contrast: 4.5 }, }); const r = theme.resolve(); - expect( + expectMeetsWcag( variantContrast(r.get('fg')!.light, r.get('bg')!.light), - ).toBeGreaterThanOrEqual(4.5); + 4.5, + ); }); it('{ wcag } object selects WCAG', () => { @@ -1115,9 +1145,10 @@ describe('glaze', () => { fg: { base: 'bg', tone: 40, contrast: { wcag: 7 } }, }); const r = theme.resolve(); - expect( + expectMeetsWcag( variantContrast(r.get('fg')!.light, r.get('bg')!.light), - ).toBeGreaterThanOrEqual(7); + 7, + ); }); it('{ apca } object pins an APCA Lc floor', () => { @@ -1142,9 +1173,10 @@ describe('glaze', () => { }, }); const r = theme.resolve(); - expect( + expectMeetsWcag( variantContrast(r.get('fg')!.lightContrast, r.get('bg')!.lightContrast), - ).toBeGreaterThanOrEqual(7); + 7, + ); }); it('auto-enhances a bare APCA floor by +15 Lc in high-contrast', () => { @@ -1160,7 +1192,7 @@ describe('glaze', () => { const hcLc = variantApca(fg.lightContrast, base.lightContrast, 'fg'); // HC targets Lc 75 (60 + 15) and so produces strictly more contrast. expect(hcLc).toBeGreaterThan(normalLc); - expect(hcLc).toBeGreaterThanOrEqual(75); + expectMeetsApca(hcLc, 75); }); it('does not auto-enhance when the inner apca pair gives an explicit HC value', () => { @@ -1200,10 +1232,8 @@ describe('glaze', () => { const base = r.get('bg')!; const fg = r.get('fg')!; // Normal targets AA (4.5); HC auto-promotes to AAA (7). - expect(variantContrast(fg.light, base.light)).toBeGreaterThanOrEqual(4.5); - expect( - variantContrast(fg.lightContrast, base.lightContrast), - ).toBeGreaterThanOrEqual(7); + expectMeetsWcag(variantContrast(fg.light, base.light), 4.5); + expectMeetsWcag(variantContrast(fg.lightContrast, base.lightContrast), 7); }); it('does not auto-promote when the inner wcag pair gives an explicit HC value', () => { @@ -1290,8 +1320,8 @@ describe('glaze', () => { const asSurface = r.get('input-bg')!.light; const base = r.get('bg')!.light; // 'button-text' infers text (fg); 'input-bg' infers surface (bg). - expect(variantApca(asText, base, 'fg')).toBeGreaterThanOrEqual(45); - expect(variantApca(asSurface, base, 'bg')).toBeGreaterThanOrEqual(45); + expectMeetsApca(variantApca(asText, base, 'fg'), 45); + expectMeetsApca(variantApca(asSurface, base, 'bg'), 45); expect(Math.abs(llOf(asText) - llOf(asSurface))).toBeGreaterThan(0.01); }); @@ -1310,8 +1340,8 @@ describe('glaze', () => { const base = r.get('bg')!.light; // Polarity flips the APCA argument order, so the two converge differently. expect(Math.abs(llOf(asSurface) - llOf(asText))).toBeGreaterThan(0.01); - expect(variantApca(asSurface, base, 'bg')).toBeGreaterThanOrEqual(45); - expect(variantApca(asText, base, 'fg')).toBeGreaterThanOrEqual(45); + expectMeetsApca(variantApca(asSurface, base, 'bg'), 45); + expectMeetsApca(variantApca(asText, base, 'fg'), 45); }); it("uses the opposite of the base's role when the name does not infer", () => { @@ -1324,7 +1354,7 @@ describe('glaze', () => { // base 'bg' is a surface -> 'accent' defaults to text (fg polarity). const base = r.get('bg')!.light; const accent = r.get('accent')!.light; - expect(variantApca(accent, base, 'fg')).toBeGreaterThanOrEqual(45); + expectMeetsApca(variantApca(accent, base, 'fg'), 45); }); it('inferRole: false skips name inference and falls back to the base opposite', () => { @@ -1350,13 +1380,9 @@ describe('glaze', () => { const r = theme.resolve(); const base = r.get('bg')!.light; // 'content' -> Lc 60 - expect( - variantApca(r.get('body')!.light, base, 'fg'), - ).toBeGreaterThanOrEqual(60); + expectMeetsApca(variantApca(r.get('body')!.light, base, 'fg'), 60); // 'min' -> Lc 15 (border role -> bg-ordered? border is fg polarity) - expect( - variantApca(r.get('divider')!.light, base, 'fg'), - ).toBeGreaterThanOrEqual(15); + expectMeetsApca(variantApca(r.get('divider')!.light, base, 'fg'), 15); }); it('back-compat: a dependent with no role defaults to foreground (fg)', () => { @@ -1368,9 +1394,7 @@ describe('glaze', () => { const r = theme.resolve(); // 'accent' name does not infer; base 'bg' infers surface -> accent is fg. const base = r.get('bg')!.light; - expect( - variantApca(r.get('accent')!.light, base, 'fg'), - ).toBeGreaterThanOrEqual(60); + expectMeetsApca(variantApca(r.get('accent')!.light, base, 'fg'), 60); }); }); @@ -2714,7 +2738,7 @@ describe('glaze', () => { 'lightContrast', 'darkContrast', ] as const) { - expect(variantContrast(textR[s], bgR[s])).toBeGreaterThanOrEqual(4.5); + expectMeetsWcag(variantContrast(textR[s], bgR[s]), 4.5); } }); @@ -3009,9 +3033,10 @@ describe('glaze', () => { }, }); const r = theme.resolve(); - expect( + expectMeetsWcag( variantContrast(r.get('m')!.light, r.get('bg')!.light), - ).toBeGreaterThanOrEqual(4.5); + 4.5, + ); }); it('srgb blend space stays in gamut', () => { @@ -3054,4 +3079,661 @@ describe('glaze', () => { } }); }); + + describe('manual contrast level', () => { + /** + * A theme exercising all three mechanisms the level interpolates — + * authored HC pairs, the tone window, and the contrast escalation — + * across every def kind. + */ + function fixture(config?: Parameters[2]) { + const theme = glaze( + { hue: 280, saturation: 80, darkHue: 265, darkSaturation: 60 }, + undefined, + config, + ); + theme.colors({ + surface: { tone: 97 }, + surfaceAlt: { base: 'surface', tone: '-6' }, + deltaPair: { base: 'surface', tone: ['-6', '-12'] }, + tonePair: { base: 'surface', tone: [30, 20] }, + text: { base: 'surface', tone: 25, contrast: 'AA', role: 'text' }, + apcaText: { + base: 'surface', + tone: 25, + contrast: { apca: 60 }, + role: 'text', + }, + innerPair: { base: 'surface', tone: 30, contrast: { wcag: [4.5, 7] } }, + outerPair: { + base: 'surface', + tone: 30, + contrast: [{ apca: 60 }, { apca: 90 }], + }, + knockout: { base: 'surface', tone: 'max' }, + pinned: { tone: 50, mode: 'static' }, + fixedTone: { tone: 40, mode: 'fixed' }, + faded: { base: 'surface', tone: 60, opacity: 0.5 }, + ghost: { + type: 'mix', + base: 'surface', + target: 'text', + value: [20, 40], + }, + shade: { + type: 'shadow', + bg: 'surface', + fg: 'text', + intensity: [30, 60], + }, + }); + return theme; + } + + describe('bit-exact endpoints', () => { + it('level 0 reproduces the normal variants exactly', () => { + const anchors = structuredClone([...fixture().resolve()]); + glaze.configure({ contrastLevel: 0 }); + const manual = fixture().resolve(); + for (const [name, anchor] of anchors) { + expect(manual.get(name)!.light).toEqual(anchor.light); + expect(manual.get(name)!.dark).toEqual(anchor.dark); + } + }); + + it('level 100 reproduces the high-contrast variants exactly', () => { + const anchors = structuredClone([...fixture().resolve()]); + glaze.configure({ contrastLevel: 100 }); + const manual = fixture().resolve(); + for (const [name, anchor] of anchors) { + expect(manual.get(name)!.light).toEqual(anchor.lightContrast); + expect(manual.get(name)!.dark).toEqual(anchor.darkContrast); + } + }); + + it('is exact through a per-theme override too', () => { + const anchors = structuredClone([...fixture().resolve()]); + const lo = fixture({ contrastLevel: 0 }).resolve(); + const hi = fixture({ contrastLevel: 100 }).resolve(); + for (const [name, anchor] of anchors) { + expect(lo.get(name)!.light).toEqual(anchor.light); + expect(hi.get(name)!.light).toEqual(anchor.lightContrast); + expect(hi.get(name)!.dark).toEqual(anchor.darkContrast); + } + }); + + it('emits byte-identical CSS at the endpoints', () => { + const theme = fixture(); + const auto = theme.css(); + + glaze.configure({ contrastLevel: 0 }); + expect(theme.css().light).toBe(auto.light); + expect(theme.css().dark).toBe(auto.dark); + + glaze.configure({ contrastLevel: 100 }); + expect(theme.css().light).toBe(auto.lightContrast); + expect(theme.css().dark).toBe(auto.darkContrast); + }); + }); + + describe('the ramp', () => { + const levels = [0, 20, 40, 60, 80, 100]; + + /** + * Monotonicity holds for interpolable fields — the fixture below avoids + * mixed-kind pairs, which switch at level 50 by design. + */ + it('never lowers measured WCAG contrast as the level rises', () => { + let previousLight = 0; + let previousDark = 0; + for (const level of levels) { + glaze.resetConfig(); + glaze.configure({ contrastLevel: level }); + const r = fixture().resolve(); + const light = variantContrast( + r.get('text')!.light, + r.get('surface')!.light, + ); + const dark = variantContrast( + r.get('text')!.dark, + r.get('surface')!.dark, + ); + expect(light).toBeGreaterThanOrEqual(previousLight - 1e-9); + expect(dark).toBeGreaterThanOrEqual(previousDark - 1e-9); + previousLight = light; + previousDark = dark; + } + }); + + it('never lowers measured APCA contrast as the level rises', () => { + let previous = 0; + for (const level of levels) { + glaze.resetConfig(); + glaze.configure({ contrastLevel: level }); + const r = fixture().resolve(); + const lc = variantApca( + r.get('apcaText')!.light, + r.get('surface')!.light, + 'fg', + ); + expect(lc).toBeGreaterThanOrEqual(previous - 1e-9); + previous = lc; + } + }); + + it('lands each intermediate tone between its neighbours', () => { + const tones = levels.map((level) => { + glaze.resetConfig(); + glaze.configure({ contrastLevel: level }); + return fixture().resolve().get('tonePair')!.light.t; + }); + for (let i = 1; i < tones.length - 1; i++) { + const [lo, hi] = [tones[i - 1], tones[i + 1]].sort((a, b) => a - b); + expect(tones[i]).toBeGreaterThanOrEqual(lo); + expect(tones[i]).toBeLessThanOrEqual(hi); + } + }); + }); + + describe('mechanism: tone window widening', () => { + // Asserted on colors with no contrast floor: for solved colors the + // window only clamps the solver's seed, so it is a weak probe there. + it('walks the light window floor toward 0', () => { + const at = (level: number | 'auto') => { + glaze.resetConfig(); + glaze.configure({ contrastLevel: level }); + const theme = glaze(0, 0); + theme.colors({ floor: { tone: 0 } }); + return llOf(theme.resolve().get('floor')!.light); + }; + expect(at(0)).toBeCloseTo(0.1, 4); + expect(at(50)).toBeCloseTo(0.05, 4); + expect(at(100)).toBeCloseTo(0, 4); + }); + + it('walks the dark window floor toward 0', () => { + const at = (level: number) => { + glaze.resetConfig(); + glaze.configure({ contrastLevel: level }); + const theme = glaze(0, 0); + theme.colors({ ceil: { tone: 100 } }); + // `tone: 100` inverts to 0 in dark under mode 'auto', landing on the + // dark window's `lo` (15 → 7.5 at level 50 → 0 at 100). + return llOf(theme.resolve().get('ceil')!.dark); + }; + expect(at(0)).toBeCloseTo(0.15, 4); + expect(at(50)).toBeCloseTo(0.075, 4); + expect(at(100)).toBeCloseTo(0, 4); + }); + + it('leaves a disabled window level-invariant', () => { + const at = (level: number) => { + glaze.resetConfig(); + glaze.configure({ contrastLevel: level }); + const theme = glaze(0, 0, { lightTone: false }); + theme.colors({ floor: { tone: 20 } }); + return theme.resolve().get('floor')!.light.t; + }; + expect(at(0)).toBeCloseTo(at(100), 10); + }); + }); + + describe('mechanism: authored pairs', () => { + it('resolves an absolute tone pair as its midpoint at level 50', () => { + glaze.configure({ contrastLevel: 50 }); + const theme = glaze(0, 0, { lightTone: false }); + theme.colors({ + surface: { tone: 97 }, + text: { base: 'surface', tone: [30, 10] }, + authored: { base: 'surface', tone: 20 }, + }); + const r = theme.resolve(); + expect(r.get('text')!.light.t).toBeCloseTo( + r.get('authored')!.light.t, + 6, + ); + }); + + it('interpolates shadow intensity and mix value', () => { + const alphaAt = (level: number) => { + glaze.resetConfig(); + glaze.configure({ contrastLevel: level }); + const r = fixture().resolve(); + return { + shadow: r.get('shade')!.light.alpha, + mix: r.get('ghost')!.light.t, + }; + }; + const lo = alphaAt(0); + const mid = alphaAt(50); + const hi = alphaAt(100); + expect(mid.shadow).toBeGreaterThan(lo.shadow); + expect(mid.shadow).toBeLessThan(hi.shadow); + expect(mid.mix).toBeGreaterThan(hi.mix); + expect(mid.mix).toBeLessThan(lo.mix); + }); + + it('rejects a contrast pair that switches metric', () => { + const theme = glaze(0, 0); + theme.colors({ + bg: { tone: 97 }, + fg: { base: 'bg', tone: 40, contrast: [4.5, { apca: 75 }] }, + }); + expect(() => theme.resolve()).toThrow(/switches metric/); + }); + + it('switches a mixed-kind tone pair at level 50', () => { + // `lightTone: false` pins the window so this isolates the pair switch + // from the window widening, which keeps ramping either side of 50. + const toneAt = (level: number) => { + glaze.resetConfig(); + glaze.configure({ contrastLevel: level }); + const theme = glaze(0, 0, { lightTone: false }); + theme.colors({ + surface: { tone: 60 }, + chip: { base: 'surface', tone: [50, 'max'] }, + }); + return theme.resolve().get('chip')!.light.t; + }; + expect(toneAt(49)).toBeCloseTo(toneAt(0), 10); + expect(toneAt(49)).toBeCloseTo(0.5, 6); + expect(toneAt(50)).toBeCloseTo(toneAt(100), 10); + expect(toneAt(50)).toBeCloseTo(1, 6); + }); + }); + + describe('mechanism: contrast escalation', () => { + it('meets an interpolated WCAG floor at mid-level', () => { + glaze.configure({ contrastLevel: 50 }); + const theme = glaze(0, 0); + theme.colors({ + surface: { tone: 97 }, + text: { base: 'surface', tone: 60, contrast: 'AA', role: 'text' }, + }); + const r = theme.resolve(); + // AA (4.5) → AAA (7) interpolates to 5.75 at level 50. + expectMeetsWcag( + variantContrast(r.get('text')!.light, r.get('surface')!.light), + 5.75, + ); + }); + + it('keeps an explicit HC pair from escalating across the ramp', () => { + const at = (level: number) => { + glaze.resetConfig(); + glaze.configure({ contrastLevel: level }); + const theme = glaze(0, 0); + theme.colors({ + surface: { tone: 97 }, + text: { + base: 'surface', + tone: 60, + contrast: { wcag: ['AA', 'AA'] }, + role: 'text', + }, + }); + const r = theme.resolve(); + return variantContrast(r.get('text')!.light, r.get('surface')!.light); + }; + // The target stays 4.5 at every level (an explicit HC entry cancels the + // AA→AAA promotion), so the measured contrast never climbs toward 7. + // It is not bit-flat: the solver's overshoot allowance and the widening + // seed clamp both move the solved tone by a hair. + for (const level of [0, 50, 100]) { + expectMeetsWcag(at(level), 4.5); + expect(at(level)).toBeLessThan(4.6); + } + }); + }); + + describe('direction stability', () => { + const LEVELS = [0, 10, 20, 30, 40, 49, 50, 51, 60, 80, 90, 100]; + + /** Signed tone delta of a solved color against its base, per level. */ + function deltasAcrossRamp(baseTone: number, tone: string): number[] { + return LEVELS.map((level) => { + glaze.resetConfig(); + glaze.configure({ contrastLevel: level }); + const theme = glaze(0, 0); + theme.colors({ + bg: { tone: baseTone }, + chip: { base: 'bg', tone, contrast: 'AA', role: 'text' }, + }); + const r = theme.resolve(); + return r.get('chip')!.light.t - r.get('bg')!.light.t; + }); + } + + /** Levels at which the solved side differs from the previous level's. */ + function switchLevels(deltas: number[]): number[] { + const out: number[] = []; + for (let i = 1; i < deltas.length; i++) { + if (Math.sign(deltas[i]) !== Math.sign(deltas[i - 1])) { + out.push(LEVELS[i]); + } + } + return out; + } + + // Both fixtures wobble when the solver re-decides per level: `autoFlip` + // takes whichever side lands nearer the anchor when both meet the floor, + // and that shifts with the target. Their two endpoints genuinely disagree + // (no side satisfies both a 4.5 and a 7 floor here), so a single switch is + // unavoidable — but it now lands at 50 instead of wherever reachability + // happened to tip. + it('switches side at most once, at level 50', () => { + expect(switchLevels(deltasAcrossRamp(42, '-20'))).toEqual([50]); + expect(switchLevels(deltasAcrossRamp(50, '+10'))).toEqual([50]); + }); + + it('never switches side when the two endpoints agree', () => { + for (const [baseTone, tone] of [ + [97, '-20'], + [30, '-10'], + [88, '+8'], + [55, '-25'], + ] as const) { + const deltas = deltasAcrossRamp(baseTone, tone); + expect(switchLevels(deltas)).toEqual([]); + } + }); + + it('meets the interpolated floor at every level', () => { + // A reachable fixture: the pin only reorders the tie-break, so `flip` + // still falls back when a side cannot physically reach the target. + for (const level of LEVELS) { + glaze.resetConfig(); + glaze.configure({ contrastLevel: level }); + const theme = glaze(0, 0); + theme.colors({ + bg: { tone: 97 }, + chip: { base: 'bg', tone: '-20', contrast: 'AA', role: 'text' }, + }); + const r = theme.resolve(); + // AA (4.5) ramps to AAA (7). + expectMeetsWcag( + variantContrast(r.get('chip')!.light, r.get('bg')!.light), + 4.5 + (7 - 4.5) * (level / 100), + ); + } + }); + + it('never emits less contrast than the weaker endpoint', () => { + // For a color whose floor is physically unreachable, both endpoints + // clamp to an extreme. Pinning must not make any level worse than the + // weaker of the two anchors it ramps between. + const anchors = (() => { + const theme = glaze(0, 0); + theme.colors({ + bg: { tone: 42 }, + chip: { base: 'bg', tone: '-20', contrast: 'AA', role: 'text' }, + }); + const r = theme.resolve(); + return [ + variantContrast(r.get('chip')!.light, r.get('bg')!.light), + variantContrast( + r.get('chip')!.lightContrast, + r.get('bg')!.lightContrast, + ), + ]; + })(); + const weakest = Math.min(...anchors); + for (const level of LEVELS) { + glaze.resetConfig(); + glaze.configure({ contrastLevel: level }); + const theme = glaze(0, 0); + theme.colors({ + bg: { tone: 42 }, + chip: { base: 'bg', tone: '-20', contrast: 'AA', role: 'text' }, + }); + const r = theme.resolve(); + expect( + variantContrast(r.get('chip')!.light, r.get('bg')!.light), + ).toBeGreaterThanOrEqual(weakest * WCAG_MEASURE_SLACK); + } + }); + }); + + describe('invariants', () => { + it("leaves mode: 'static' colors untouched at every level", () => { + const at = (level: number) => { + glaze.resetConfig(); + glaze.configure({ contrastLevel: level }); + return fixture().resolve().get('pinned')!; + }; + expect(at(100).light.t).toBeCloseTo(at(0).light.t, 10); + expect(at(100).dark.t).toBeCloseTo(at(0).dark.t, 10); + }); + + it('keeps root/dependent classification level-independent', () => { + // `isAbsoluteTone` inspects only the normal entry, so a pair whose HC + // entry is relative still resolves as a root at every level. + const at = (level: number) => { + glaze.resetConfig(); + glaze.configure({ contrastLevel: level }); + const theme = glaze(0, 0); + theme.colors({ odd: { tone: [50, '+20'] } }); + return theme.resolve().get('odd')!.light.t; + }; + expect(at(0)).toBeGreaterThan(0); + expect(at(100)).toBeGreaterThan(0); + }); + }); + + 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('drops the high-contrast tier from every exporter', () => { + 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.dtcgResolver().modifiers.scheme.contexts.lightContrast, + ).toBeUndefined(); + expect(theme.tailwind()).not.toContain('.high-contrast'); + expect( + theme.tasty()['#surface']['@media(prefers-contrast: more)'], + ).toBeUndefined(); + }); + + 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 }); + 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(); + } + }); + + 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('leaves default output untouched at level 0', () => { + const theme = fixture(); + const auto = theme.tokens(); + glaze.configure({ contrastLevel: 0 }); + expect(theme.tokens()).toEqual(auto); + }); + + it('keeps a sibling theme’s high-contrast tier in a palette', () => { + glaze.configure({ modes: { highContrast: true } }); + const manual = glaze(280, 80, { contrastLevel: 100 }); + manual.colors({ surface: { tone: 97 } }); + const auto = glaze(120, 80); + auto.colors({ surface: { tone: 97 } }); + const tokens = glaze + .palette({ manual, auto }) + .tokens({ modes: { highContrast: true } }); + // The auto theme still escalates... + expect(tokens.lightContrast['auto-surface']).not.toBe( + tokens.light['auto-surface'], + ); + // ...while the manual theme reports its own resolved value. + expect(tokens.lightContrast['manual-surface']).toBe( + tokens.light['manual-surface'], + ); + }); + }); + + describe('config plumbing', () => { + it("defaults to 'auto'", () => { + expect(glaze.getConfig().contrastLevel).toBe('auto'); + }); + + it('round-trips through configure and back to auto', () => { + glaze.configure({ contrastLevel: 60 }); + expect(glaze.getConfig().contrastLevel).toBe(60); + glaze.configure({ contrastLevel: 'auto' }); + expect(glaze.getConfig().contrastLevel).toBe('auto'); + }); + + it('clamps out-of-range levels and rejects non-finite ones', () => { + glaze.configure({ contrastLevel: 150 }); + expect(glaze.getConfig().contrastLevel).toBe(100); + glaze.configure({ contrastLevel: -20 }); + expect(glaze.getConfig().contrastLevel).toBe(0); + expect(() => glaze.configure({ contrastLevel: NaN })).toThrow( + /contrastLevel/, + ); + }); + + it('lets an instance override or opt out of the global level', () => { + glaze.configure({ contrastLevel: 100 }); + expect( + glaze(0, 0, { contrastLevel: 40 }).getConfig().contrastLevel, + ).toBe(40); + expect( + glaze(0, 0, { contrastLevel: 'auto' }).getConfig().contrastLevel, + ).toBe('auto'); + }); + + it('inherits and overrides through extend()', () => { + const parent = glaze(0, 0, { contrastLevel: 60 }); + expect(parent.extend({}).getConfig().contrastLevel).toBe(60); + expect( + parent.extend({ config: { contrastLevel: 20 } }).getConfig() + .contrastLevel, + ).toBe(20); + }); + + it('invalidates the resolve cache when the level changes', () => { + const theme = fixture(); + const before = theme.resolve().get('text')!.light.t; + glaze.configure({ contrastLevel: 100 }); + expect(theme.resolve().get('text')!.light.t).not.toBe(before); + }); + + it('applies an opted-out instance under a global level', () => { + const anchors = structuredClone([...fixture().resolve()]); + glaze.configure({ contrastLevel: 100 }); + const optedOut = fixture({ contrastLevel: 'auto' }).resolve(); + for (const [name, anchor] of anchors) { + expect(optedOut.get(name)!.light).toEqual(anchor.light); + expect(optedOut.get(name)!.lightContrast).toEqual( + anchor.lightContrast, + ); + } + }); + }); + + describe('authoring export', () => { + it('freezes an instance-authored level', () => { + const data = glaze(280, 80, { contrastLevel: 60 }).export(); + expect(data.config!.contrastLevel).toBe(60); + }); + + it('does not freeze a level inherited from the global config', () => { + glaze.configure({ contrastLevel: 60 }); + // A global level is a live preference, not authored theme data. + expect(glaze(280, 80).export().config!.contrastLevel).toBeUndefined(); + }); + + it('freezes a level passed to export()', () => { + const data = glaze(280, 80).export({ contrastLevel: 30 }); + expect(data.config!.contrastLevel).toBe(30); + }); + + it('restores a frozen level after the global resets', () => { + const source = fixture({ contrastLevel: 100 }); + const expected = source.resolve().get('text')!.light.t; + const data = source.export(); + glaze.resetConfig(); + const restored = glaze.themeFrom(data); + expect(restored.getConfig().contrastLevel).toBe(100); + expect(restored.resolve().get('text')!.light.t).toBeCloseTo( + expected, + 10, + ); + }); + }); + + describe('standalone tokens', () => { + it('resolves a token at the level', () => { + const auto = glaze.color({ hue: 280, saturation: 80, tone: [30, 10] }); + const anchor = structuredClone(auto.resolve()); + glaze.configure({ contrastLevel: 100 }); + const manual = glaze.color({ + hue: 280, + saturation: 80, + tone: [30, 10], + }); + expect(manual.resolve().light).toEqual(anchor.lightContrast); + }); + + it('is exact for a base-linked token under a global level', () => { + const build = () => { + const bg = glaze.color('#ffffff'); + return glaze.color({ + from: '#8080ff', + base: bg, + contrast: 'AA', + role: 'text', + }); + }; + const anchor = structuredClone(build().resolve()); + glaze.configure({ contrastLevel: 100 }); + expect(build().resolve().light).toEqual(anchor.lightContrast); + }); + + it('accepts a per-token level', () => { + const token = glaze.color('#8080ff', { contrastLevel: 100 }); + expect(token.resolve().lightContrast).toBe(token.resolve().light); + }); + }); + }); }); diff --git a/src/hc-pair.test.ts b/src/hc-pair.test.ts new file mode 100644 index 0000000..b459615 --- /dev/null +++ b/src/hc-pair.test.ts @@ -0,0 +1,144 @@ +import { + contrastFraction, + levelFraction, + numberAt, + pairHC, + pairNormal, + parseToneValue, + parseToneValueAt, +} from './hc-pair'; + +describe('hc-pair', () => { + describe('pair selection', () => { + it('reads both ends of a pair', () => { + expect(pairNormal([30, 60])).toBe(30); + expect(pairHC([30, 60])).toBe(60); + }); + + it('treats a bare value as both ends', () => { + expect(pairNormal(42)).toBe(42); + expect(pairHC(42)).toBe(42); + }); + }); + + describe('contrastFraction', () => { + it("is undefined in 'auto' mode", () => { + expect(contrastFraction({})).toBeUndefined(); + expect(contrastFraction({ contrastLevel: 'auto' })).toBeUndefined(); + }); + + it('maps 0–100 onto 0–1', () => { + expect(contrastFraction({ contrastLevel: 0 })).toBe(0); + expect(contrastFraction({ contrastLevel: 50 })).toBe(0.5); + 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. + expect(contrastFraction({ contrastLevel: 0 })).not.toBeUndefined(); + }); + + it('clamps out-of-range levels', () => { + expect(contrastFraction({ contrastLevel: 150 })).toBe(1); + expect(contrastFraction({ contrastLevel: -20 })).toBe(0); + expect(levelFraction(150)).toBe(1); + expect(levelFraction(-20)).toBe(0); + }); + }); + + describe('numberAt', () => { + it('interpolates a numeric pair', () => { + expect(numberAt([30, 60], 0.5)).toBe(45); + expect(numberAt([30, 60], 0.25)).toBe(37.5); + }); + + it('returns the authored ends by identity', () => { + expect(numberAt([0.1, 0.3], 0)).toBe(0.1); + // `0.1 + (0.3 - 0.1) * 1` is not bit-exactly 0.3 in IEEE 754, so the + // endpoints must short-circuit rather than compute. + expect(numberAt([0.1, 0.3], 1)).toBe(0.3); + }); + + it('passes a bare value through at every fraction', () => { + expect(numberAt(42, 0)).toBe(42); + expect(numberAt(42, 0.5)).toBe(42); + expect(numberAt(42, 1)).toBe(42); + }); + }); + + describe('parseToneValueAt', () => { + it('interpolates two absolute tones', () => { + expect(parseToneValueAt([30, 20], 0.5)).toEqual({ + kind: 'absolute', + value: 25, + }); + }); + + it('interpolates two relative deltas without a string round-trip', () => { + expect(parseToneValueAt(['+10', '+20'], 0.5)).toEqual({ + kind: 'relative', + value: 15, + }); + expect(parseToneValueAt(['-6', '-12'], 0.5)).toEqual({ + kind: 'relative', + value: -9, + }); + }); + + it('returns the authored ends verbatim', () => { + expect(parseToneValueAt([30, 20], 0)).toEqual(parseToneValue(30)); + expect(parseToneValueAt([30, 20], 1)).toEqual(parseToneValue(20)); + }); + + it('passes a bare tone through', () => { + expect(parseToneValueAt('max', 0.5)).toEqual({ + kind: 'extreme', + value: 100, + }); + expect(parseToneValueAt('+20', 0.5)).toEqual({ + kind: 'relative', + value: 20, + }); + }); + + it('keeps identical ends stable', () => { + expect(parseToneValueAt(['max', 'max'], 0.5)).toEqual({ + kind: 'extreme', + value: 100, + }); + }); + + it('switches mixed-kind pairs at level 50 instead of interpolating', () => { + // Blending across kinds would change which resolver branch runs + // mid-ramp, so these step instead. + expect(parseToneValueAt([50, 'max'], 0.49)).toEqual({ + kind: 'absolute', + value: 50, + }); + expect(parseToneValueAt([50, 'max'], 0.5)).toEqual({ + kind: 'extreme', + value: 100, + }); + expect(parseToneValueAt([50, '+20'], 0.49)).toEqual({ + kind: 'absolute', + value: 50, + }); + expect(parseToneValueAt([50, '+20'], 0.5)).toEqual({ + kind: 'relative', + value: 20, + }); + }); + + it('switches differing extremes at level 50', () => { + expect(parseToneValueAt(['max', 'min'], 0.49)).toEqual({ + kind: 'extreme', + value: 100, + }); + expect(parseToneValueAt(['max', 'min'], 0.5)).toEqual({ + kind: 'extreme', + value: 0, + }); + }); + }); +}); diff --git a/src/hc-pair.ts b/src/hc-pair.ts index 80f7eef..e59389a 100644 --- a/src/hc-pair.ts +++ b/src/hc-pair.ts @@ -1,8 +1,11 @@ /** * Small shared helpers used across the resolver pipeline: * - HC-pair selection (`pairNormal` / `pairHC`) + * - HC-pair interpolation for the manual contrast level (`contrastFraction`, + * `numberAt`, `parseToneValueAt`) * - Absolute / relative / extreme tone discrimination - * - Generic numeric helpers (`clamp`, hue resolution, relative-value parsing) + * - Generic numeric helpers (`clamp`, `lerp`, hue resolution, relative-value + * parsing) */ import type { ExtremeValue, HCPair, RelativeValue, ToneValue } from './types'; @@ -19,6 +22,54 @@ export function clamp(v: number, min: number, max: number): number { return Math.max(min, Math.min(max, v)); } +export function lerp(a: number, b: number, f: number): number { + return a + (b - a) * f; +} + +// ============================================================================ +// Manual contrast level +// ============================================================================ + +/** + * Blend fraction at/above which an un-interpolable HC pair switches from its + * normal entry to its high-contrast one (level 50). + */ +export const PAIR_SWITCH = 0.5; + +/** Blend fraction (0–1) for an authored 0–100 contrast level. */ +export function levelFraction(level: number): number { + return clamp(level, 0, 100) / 100; +} + +/** + * Manual-contrast blend fraction (0–1) for a resolved config, or `undefined` + * in `'auto'` mode (the two-tier normal + high-contrast model). + * + * Note `contrastLevel: 0` yields `0`, not `undefined`: "pinned to normal + * contrast with no high-contrast tier" is a distinct state from `'auto'`. + */ +export function contrastFraction(config: { + contrastLevel?: number | 'auto'; +}): number | undefined { + const level = config.contrastLevel; + if (typeof level !== 'number' || !Number.isFinite(level)) return undefined; + return levelFraction(level); +} + +/** + * A numeric HC pair at a blend fraction (shadow `intensity`, mix `value`). + * + * The endpoints return the authored entry *by identity* rather than through + * float arithmetic — `a + (b - a) * 1` is not bit-exactly `b` in IEEE 754, and + * levels 0 / 100 must reproduce the classic output exactly. + */ +export function numberAt(p: HCPair, f: number): number { + if (!Array.isArray(p)) return p; + if (f <= 0) return p[0]; + if (f >= 1) return p[1]; + return lerp(p[0], p[1], f); +} + /** Whether a tone value is an extreme keyword (`'max'` / `'min'`). */ export function isExtremeTone(value: ToneValue): value is ExtremeValue { return value === 'max' || value === 'min'; @@ -55,6 +106,37 @@ export function parseToneValue(value: ToneValue): { return { kind: 'relative', value: parseFloat(value) }; } +/** + * Parse an authored tone pair at a manual-contrast blend fraction, returning + * the same normalized shape as {@link parseToneValue}. + * + * Same-kind numeric pairs interpolate their magnitude — absolute tones + * (`[30, 20]` → 25 at level 50) and relative deltas (`['+10', '+20']` → +15). + * Everything else is un-interpolable and switches at {@link PAIR_SWITCH}: + * extremes carry no magnitude, and a mixed-kind pair would change *which* + * resolver branch runs mid-ramp (absolute remap vs. extreme-against-base vs. + * base-anchored delta), putting a discontinuity at level 100 — exactly where + * the output must stay bit-exact. + * + * Returns a parsed struct rather than a `ToneValue` so a blended relative + * delta never round-trips through a `'+15.5'` string, and so `kind` — which + * selects the resolver branch — cannot drift. + */ +export function parseToneValueAt( + p: HCPair, + f: number, +): { kind: 'absolute' | 'relative' | 'extreme'; value: number } { + if (!Array.isArray(p)) return parseToneValue(p); + if (f <= 0) return parseToneValue(p[0]); + if (f >= 1) return parseToneValue(p[1]); + const normal = parseToneValue(p[0]); + const hc = parseToneValue(p[1]); + if (normal.kind === hc.kind && normal.kind !== 'extreme') { + return { kind: normal.kind, value: lerp(normal.value, hc.value, f) }; + } + return f < PAIR_SWITCH ? normal : hc; +} + /** * Compute the effective hue for a color, given the theme seed hue * and an optional per-color hue override. diff --git a/src/index.ts b/src/index.ts index c23785e..fd84611 100644 --- a/src/index.ts +++ b/src/index.ts @@ -99,6 +99,7 @@ export { findValueForMixContrast, resolveMinContrast, resolveContrastForMode, + resolveContrastForLevel, resolveApcaTarget, apcaContrast, APCA_PRESETS, diff --git a/src/okhst.ts b/src/okhst.ts index 81b3a7e..72ba62a 100644 --- a/src/okhst.ts +++ b/src/okhst.ts @@ -11,13 +11,15 @@ * - the resolved-variant edge adapter (`variantToOkhsl`), * - the per-scheme tone mapping that replaced the Möbius dark curve * (`mapToneForScheme`), the dark desaturation reducer, and the solver's scheme - * tone range. + * tone range, + * - the scheme tone window, including the high-contrast bypass and its + * continuous generalization under a manual contrast level. * * See `docs/okhst.md` for the full specification and the calibrated * default constants. */ -import { clamp } from './hc-pair'; +import { clamp, contrastFraction, lerp } from './hc-pair'; import { toe, toeInv } from './okhsl-color-math'; import type { AdaptationMode, GlazeConfigResolved, ToneWindow } from './types'; @@ -140,7 +142,12 @@ export function normalizeToneWindow(win: ToneWindow): { /** * Resolve the active tone window for a scheme as OKHSL-lightness endpoints. * - HC variants always return the full range `[0, 100]` with the mode eps. - * - `false` (= "no clamping") is treated as `[0, 100]` with the reference eps. + * - Under a manual `contrastLevel`, each endpoint is interpolated toward that + * full range: light `10 → 0` / `100 → 100`, dark `15 → 0` / `95 → 100`. The + * endpoints are exact — level 0 returns the configured window and level 100 + * the same literal the HC branch returns. + * - `false` (= "no clamping") is treated as `[0, 100]` with the reference eps, + * and is therefore level-invariant. */ function activeWindow( isHighContrast: boolean, @@ -151,7 +158,12 @@ function activeWindow( kind === 'dark' ? config.darkTone : config.lightTone, ); if (isHighContrast) return { lo: 0, hi: 100, eps: win.eps }; - return win; + const f = contrastFraction(config); + if (f === undefined || f <= 0) return win; + if (f >= 1) return { lo: 0, hi: 100, eps: win.eps }; + // The render eps is not interpolated — HC keeps the configured eps today, so + // the ramp does too. + return { lo: lerp(win.lo, 0, f), hi: lerp(win.hi, 100, f), eps: win.eps }; } /** @@ -181,6 +193,9 @@ function remapToneToLightness( * The window remap uses the mode's render eps to land a final OKHSL * lightness; that lightness is then re-expressed as canonical tone so * relative offsets and contrast stay comparable across schemes. + * + * A manual `contrastLevel` widens the window continuously (see + * `activeWindow`); inversion is unaffected. */ export function mapToneForScheme( authorTone: number, @@ -220,7 +235,8 @@ export function mapSaturationDark( /** * Tone search range (0–1) for the contrast solver in a given scheme. * `static` searches the full range; otherwise the scheme window's tone - * endpoints (HC bypasses to full range). + * endpoints (HC bypasses to full range, a manual `contrastLevel` widens + * toward it — see `activeWindow`). */ export function schemeToneRange( isDark: boolean, diff --git a/src/resolver.ts b/src/resolver.ts index bd963c9..2360859 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -2,7 +2,10 @@ * 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. + * 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. * @@ -25,15 +28,20 @@ import { findToneForContrast, findValueForMixContrast, metricLuminance, + resolveContrastForLevel, resolveContrastForMode, } from './contrast-solver'; import type { LinearRgb, ResolvedContrast } from './contrast-solver'; import { + PAIR_SWITCH, clamp, + contrastFraction, isAbsoluteTone, + numberAt, pairHC, pairNormal, parseToneValue, + parseToneValueAt, resolveEffectiveHue, } from './hc-pair'; import { @@ -74,6 +82,7 @@ import type { ResolvedColorVariant, Role, ShadowColorDef, + ToneValue, } from './types'; export interface ResolveContext extends GlazeThemeSeed { @@ -200,8 +209,17 @@ function resolveRole(name: string, def: ColorDef, ctx: ResolveContext): Role { function resolveContrastSpec( spec: HCPair, isHighContrast: boolean, + config: GlazeConfigResolved, polarity?: 'fg' | 'bg', ): ResolvedContrast { + if (!isHighContrast && contrastFraction(config) !== undefined) { + // A defined fraction proves the level is a finite number, not `'auto'`. + return resolveContrastForLevel( + spec, + config.contrastLevel as number, + polarity, + ); + } const outerExplicitHC = Array.isArray(spec); const outer = isHighContrast ? pairHC(spec) : pairNormal(spec); return resolveContrastForMode( @@ -212,6 +230,35 @@ function resolveContrastSpec( ); } +/** + * The authored tone for this pass: the pair's own entry in `'auto'` mode, or + * the manual-level interpolation. Returns the parsed struct so the `kind` — + * which selects the branch in `resolveDependentColor` — is fixed here once. + */ +function passTone( + tone: HCPair, + isHighContrast: boolean, + config: GlazeConfigResolved, +): { kind: 'absolute' | 'relative' | 'extreme'; value: number } { + const f = contrastFraction(config); + return f === undefined || isHighContrast + ? parseToneValue(isHighContrast ? pairHC(tone) : pairNormal(tone)) + : parseToneValueAt(tone, f); +} + +/** The authored numeric pair for this pass (shadow `intensity`, mix `value`). */ +function passNumber( + p: HCPair, + isHighContrast: boolean, + config: GlazeConfigResolved, +): number { + const f = contrastFraction(config); + if (f === undefined || isHighContrast) { + return isHighContrast ? pairHC(p) : pairNormal(p); + } + return numberAt(p, f); +} + /** * Apply the relative-tone delta against a base, honoring `flip`. * @@ -263,12 +310,11 @@ function extremeDarkTone( function resolveRootColor( def: RegularColorDef, isHighContrast: boolean, + config: GlazeConfigResolved, ): number { - const rawT = def.tone!; - const rawValue = isHighContrast ? pairHC(rawT) : pairNormal(rawT); // Root tone is absolute or extreme ('max' = 100, 'min' = 0); both flow // through mapToneForScheme (and invert in dark under mode 'auto'). - const parsed = parseToneValue(rawValue); + const parsed = passTone(def.tone!, isHighContrast, config); return clamp(parsed.value, 0, 100); } @@ -348,8 +394,7 @@ function resolveDependentColor( if (rawTone === undefined) { preferredTone = baseTone; } else { - const rawValue = isHighContrast ? pairHC(rawTone) : pairNormal(rawTone); - const parsed = parseToneValue(rawValue); + const parsed = passTone(rawTone, isHighContrast, ctx.config); if (parsed.kind === 'relative') { if (isDark && mode === 'auto') { @@ -408,6 +453,7 @@ function resolveDependentColor( const resolvedContrast = resolveContrastSpec( rawContrast, isHighContrast, + ctx.config, polarity, ); @@ -434,16 +480,54 @@ function resolveDependentColor( ? clamp(preferredTone / 100, 0, 1) : clamp(preferredTone / 100, toneRange[0], toneRange[1]); - const result = findToneForContrast({ + const solve = { hue: channels.hue, saturation: channels.saturation, preferredTone: seedTone, baseLinearRgb, - contrast: resolvedContrast, - toneRange: [0, 1], - initialDirection, + toneRange: [0, 1] as [number, number], flip, pastel, + }; + + // 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. + // + // `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 + // shifts as the target grows. So the side is decided once — by a probe solve + // at the *nearer endpoint's* target — and then preferred at every level in + // that half of the ramp via `preferInitial`. + // + // Anchoring to the nearer endpoint is what keeps levels 0 and 100 + // bit-identical to the classic normal / high-contrast output: at an endpoint + // the probe solves the very problem that pass would solve, so it reproduces + // that side. `flip` stays on, so a pinned side that physically cannot reach + // the target still falls back to the opposite one and the floor is met. + // 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); + let preferInitial = false; + if (level !== undefined && level > 0 && level < 1) { + const probe = findToneForContrast({ + ...solve, + contrast: resolveContrastForLevel( + rawContrast, + level < PAIR_SWITCH ? 0 : 100, + polarity, + ), + initialDirection, + }); + initialDirection = probe.tone * 100 < baseTone ? 'darker' : 'lighter'; + preferInitial = true; + } + + const result = findToneForContrast({ + ...solve, + contrast: resolvedContrast, + initialDirection, + preferInitial, }); if (!result.met) { @@ -487,7 +571,7 @@ function resolveColorForScheme( const finalTone = isRoot ? mapToneForScheme( - resolveRootColor(regDef, isHighContrast), + resolveRootColor(regDef, isHighContrast, ctx.config), mode, isDark, isHighContrast, @@ -532,9 +616,7 @@ function resolveShadowForScheme( ); } - const intensity = isHighContrast - ? pairHC(def.intensity) - : pairNormal(def.intensity); + const intensity = passNumber(def.intensity, isHighContrast, ctx.config); const tuning = resolveShadowTuning(def.tuning, ctx.config.shadowTuning); return { @@ -604,7 +686,7 @@ function resolveMixForScheme( getSchemeVariant(targetResolved, isDark, isHighContrast), ); - const rawValue = isHighContrast ? pairHC(def.value) : pairNormal(def.value); + const rawValue = passNumber(def.value, isHighContrast, ctx.config); let t = clamp(rawValue, 0, 100) / 100; const blend = def.blend ?? 'opaque'; @@ -625,6 +707,7 @@ function resolveMixForScheme( const resolvedContrast = resolveContrastSpec( def.contrast, isHighContrast, + ctx.config, polarity, ); const metric = resolvedContrast.metric; @@ -745,9 +828,14 @@ function seedField( } /** - * After the four passes, surface chromatic contrast drift (§10): a color + * After the passes, surface chromatic contrast drift (§10): a color * 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. */ function verifyContrastDrift( order: string[], @@ -778,11 +866,15 @@ function verifyContrastDrift( { isDark: true, isHighContrast: false, field: 'dark' }, { isDark: true, isHighContrast: true, field: 'darkContrast' }, ]; + const manual = contrastFraction(config) !== undefined; for (const s of schemes) { + // Manual mode mirrors the high-contrast slots and never emits them. + if (manual && s.isHighContrast) continue; const spec = resolveContrastSpec( regDef.contrast, s.isHighContrast, + config, polarity, ); const cVariant = color[s.field]; @@ -834,21 +926,35 @@ export function resolveAllColors( } } - // Pass 1: Light normal. + // 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; + + // Pass 1: Light (normal, or at the level). const lightMap = runPass(order, defs, ctx, false, false, 'light'); // Pass 2: Light high-contrast. - seedField(order, ctx, 'lightContrast', lightMap); - const lightHCMap = runPass(order, defs, ctx, false, true, 'lightContrast'); + let lightHCMap = lightMap; + if (!manual) { + seedField(order, ctx, 'lightContrast', lightMap); + lightHCMap = runPass(order, defs, ctx, false, true, 'lightContrast'); + } - // Pass 3: Dark normal. + // Pass 3: Dark (normal, or at the level). seedField(order, ctx, 'dark', lightMap); seedField(order, ctx, 'darkContrast', lightHCMap); const darkMap = runPass(order, defs, ctx, true, false, 'dark'); // Pass 4: Dark high-contrast. - seedField(order, ctx, 'darkContrast', darkMap); - const darkHCMap = runPass(order, defs, ctx, true, true, 'darkContrast'); + let darkHCMap = darkMap; + if (!manual) { + seedField(order, ctx, 'darkContrast', darkMap); + darkHCMap = runPass(order, defs, ctx, true, true, 'darkContrast'); + } const result = new Map(); for (const name of order) { diff --git a/src/types.ts b/src/types.ts index 0304891..6f5df9f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -101,7 +101,14 @@ export type GlazeColorFormat = 'okhsl' | 'okhst' | 'rgb' | 'hsl' | 'oklch'; export interface GlazeOutputModes { /** Include dark scheme variants. Default: true. */ dark?: boolean; - /** Include high-contrast variants (both light-HC and dark-HC). Default: false. */ + /** + * 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". + * @see GlazeConfig.contrastLevel + */ highContrast?: boolean; } @@ -434,10 +441,38 @@ export interface GlazeConfig { dark?: string; highContrast?: string; }; - /** Which scheme variants to include in exports. Default: both true. */ + /** + * Which scheme variants to include in exports. Defaults: `dark: true`, + * `highContrast: false`. + */ modes?: GlazeOutputModes; /** Default tuning for all shadow colors. Per-color tuning merges field-by-field. */ 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. + * + * 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". + * + * The level interpolates all three things that make high contrast differ: + * authored `[normal, highContrast]` pairs, the tone-window widening, and + * the `AA → AAA` / APCA `+15 Lc` escalation. Contrast floors are re-solved + * at the interpolated target, so every level is a solution rather than an + * approximation, and a color keeps to one side of its base as the level moves. + * + * Pass `'auto'` to leave manual mode; `configure()` never clears a field by + * omission. See `docs/api.md` for the ramp's edge cases (un-interpolable + * pairs, side changes) and the export-freeze rule. + * + * @default 'auto' + */ + contrastLevel?: number | 'auto'; /** * Automatically flip tone direction when contrast can't be met. * @@ -473,6 +508,12 @@ export interface GlazeConfigResolved { modes: Required; shadowTuning?: ShadowTuning; autoFlip: boolean; + /** + * Manual contrast level (0–100), or `'auto'` for the two-tier + * normal + high-contrast model. + * @see GlazeConfig.contrastLevel + */ + contrastLevel: number | 'auto'; /** * Instance-level pastel default (`def.pastel ?? config.pastel`). * Not set via `glaze.configure()` — only via per-theme / per-token @@ -499,6 +540,17 @@ export interface GlazeConfigOverride { darkDesaturation?: number; /** 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. + * + * Only an instance-authored level is frozen into `.export()` snapshots — a + * level inherited from the global config is treated as a live preference and + * re-read at restore time. + * + * @see GlazeConfig.contrastLevel + */ + contrastLevel?: number | 'auto'; /** * Instance-level pastel default for colors that omit per-color `pastel`. * Not available on `glaze.configure()` — set here or per-color. diff --git a/src/validation.ts b/src/validation.ts index 98e0c3c..4ff7fa0 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -7,9 +7,40 @@ * its base / bg / fg / target dependencies. */ -import { isAbsoluteTone } from './hc-pair'; +import { contrastMetricOf } from './contrast-solver'; +import { isAbsoluteTone, pairHC, pairNormal } from './hc-pair'; import { isMixDef, isShadowDef } from './shadow'; -import type { ColorMap, RegularColorDef, ResolvedColor } from './types'; +import type { + ColorMap, + ContrastSpec, + HCPair, + RegularColorDef, + ResolvedColor, +} from './types'; + +/** + * Reject a `contrast` pair whose two entries measure in different metrics. + * + * A WCAG ratio and an APCA Lc are different scales, so `[4.5, { apca: 75 }]` + * asks one color to be judged two incompatible ways. There is no target between + * them for a manual `contrastLevel` to resolve, and even in `'auto'` mode the + * two variants become incomparable. Pick one metric and pair its values + * instead: `{ wcag: [4.5, 7] }` or `{ apca: [60, 90] }`. + */ +function assertConsistentContrastMetric( + name: string, + contrast: HCPair | undefined, +): void { + if (contrast === undefined || !Array.isArray(contrast)) return; + const normal = contrastMetricOf(pairNormal(contrast)); + const hc = contrastMetricOf(pairHC(contrast)); + if (normal === hc) return; + throw new Error( + `glaze: color "${name}" has a "contrast" pair that switches metric ` + + `(${normal} → ${hc}). A WCAG ratio and an APCA Lc are different scales; ` + + `use one metric for both entries, e.g. { ${normal}: [normal, highContrast] }.`, + ); +} export function validateColorDefs( defs: ColorMap, @@ -69,11 +100,14 @@ export function validateColorDefs( `glaze: mix "${name}" target "${def.target}" references a shadow color.`, ); } + assertConsistentContrastMetric(name, def.contrast); continue; } const regDef = def as RegularColorDef; + assertConsistentContrastMetric(name, regDef.contrast); + if (regDef.contrast !== undefined && !regDef.base) { throw new Error(`glaze: color "${name}" has "contrast" without "base".`); }