Skip to content

feat: manual contrast level (contrastLevel) - #85

Merged
tenphi merged 4 commits into
mainfrom
feat/manual-contrast-level
Aug 3, 2026
Merged

feat: manual contrast level (contrastLevel)#85
tenphi merged 4 commits into
mainfrom
feat/manual-contrast-level

Conversation

@tenphi

@tenphi tenphi commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Turns the two-tier high-contrast model into a 0–100 slider, for products where contrast is a user preference rather than a binary OS tier.

glaze.configure({ contrastLevel: 60 });
const theme = glaze(280, 80, { contrastLevel: 60 });
glaze.color('#26fcb2', { contrastLevel: 60 });

number | 'auto', default 'auto' — which is today's behavior exactly. Available globally, per theme, per token, and through extend(), since it rides the existing GlazeConfig / GlazeConfigOverride plumbing.

Why resolve at the level

Level 0 reproduces the normal light / dark output and level 100 the lightContrast / darkContrast output bit for bit — by short-circuit, never by float arithmetic (a + (b-a)*1 is not b in IEEE 754).

Levels in between are resolved at that level rather than blended afterwards. Exactly three mechanisms make a high-contrast variant differ from its normal counterpart, and each is interpolated on the input side before the ordinary resolve runs:

Mechanism At the level
Authored HCPairs (tone, contrast, shadow intensity, mix value) ends interpolated
Tone-window bypass endpoints walk toward full range: light 10→0, dark 15→0 / 95→100
AA→AAA / APCA +15 Lc escalation both ends resolved to numbers, target interpolated

So a contrast floor is genuinely solved at every level (contrast: 'AA' at level 50 solves for 5.75) and met at every level, instead of being an unverified lerp of two solved anchors. autoFlip also decides once per level rather than after the fact. As a bonus, a manual resolve runs two passes instead of four.

A verified sweep over a surface/border/text ladder:

   0 | light text 28.7 | dark surf 11.9 border 23.9
  50 | light text 26.2 | dark surf  6.0 border 19.2
 100 | light text 25.0 | dark surf  3.0 border 17.0

High-contrast output

There is no second tier to emit while a level is set:

  • resolve() still returns four variants, but lightContrast / darkContrast mirror light / dark.
  • A global level defaults modes.highContrast to false, so no exporter emits the tier. An explicit modes: { highContrast: true } still wins and emits values identical to the normal ones — never misleading.
  • css() keeps its four-string contract, with the HC strings repeating the normal declarations. Existing @media (prefers-contrast: more) wiring keeps working with nothing to rewire.
  • A level on a single theme of a palette leaves its siblings' real HC tier alone.

Mirroring is what makes suppression an optimization rather than a correctness requirement — hence one line in resolveModes instead of threading the effective config through ~20 call sites.

A color never swaps sides mid-slider

This was a real bug, not a theoretical one. 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 let a color leap across its own base:

before  base=42 tone=-20 | 0:+50 20:+53 40:+56 || 50:-43 ... 100:-42   (~99 tone units)
before  base=50 tone=+10 | 0:-50 || 20:+48 ... 100:+50                 (switched at 20)

after   base=97 tone=-20 | 0:-50 ... 49:-58 50:-58 ... 100:-64          stable
after   base=50 tone=+10 | 0:-50 ... 49:-51 || 50:+49 ... 100:+50       one switch, at 50

The side is now decided once from the nearer endpoint and preferred throughout that half of the ramp, via a new preferInitial option on findToneForContrast. Flipping is only re-ordered, never disabled, so a side that physically cannot reach the target still falls back and the floor is met.

Worth knowing: for a color whose two ends genuinely disagree (no single side satisfies both a 4.5 and a 7 floor), "both endpoints bit-exact" and "direction never changes" are mutually exclusive. The switch is therefore unavoidable — it now lands at level 50, where un-interpolable tone pairs already switch, instead of wherever reachability happened to tip. Colors whose ends agree — the normal case — never change side.

Reviewer notes

  • One behavior change to existing authoring: a contrast pair may no longer switch metric. [4.5, { apca: 75 }] now throws in validateColorDefs. 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. Previously it resolved silently. Rejected in 'auto' mode too, deliberately.
  • .export() freezes only an authored level. A level inherited from the global config is a live user preference (an accessibility slider), so freezing it would pin a transient slider position into an authoring snapshot and make restored themes ignore the slider forever. This is a deliberate exception to the "freeze the effective merge" rule, with an inline comment and a test pinning it — the same reasoning already excludes modes and states.
  • configure() never clears by omission, so 'auto' is the way out of manual mode. Documented.
  • Un-interpolable tone pairs ([50, 'max'], [50, '+20'], ['max', 'min']) switch at 50 rather than blending across kinds, which would change which resolver branch runs mid-ramp and put a discontinuity at level 100.
  • New public exports: resolveContrastForLevel, contrastMetricOf, and preferInitial on FindToneForContrastOptions. All additive.

Commits

  • test: — a standalone fix for three assertions that were failing at HEAD on Node 24. Root cause is quantization, not float noise: cachedLuminance measures at a tone rounded to 4 decimals while the resolver emits the unrounded tone, so a re-measurement can sit up to ~0.021% under a target the solver satisfied. New expectMeetsWcag / expectMeetsApca helpers carry ~5x headroom over the measured worst case and stay far tighter than the library's own CONTRAST_WARN_SLACK_*. Verified by deleting the AA→AAA promotion — those assertions keep failing. The solver is untouched.
  • feat: — the feature, docs, and changeset.

Verification

pnpm test (352 passing, +71), pnpm typecheck, pnpm lint, pnpm format:check, pnpm build all clean. Tests cover bit-exact endpoints (toEqual on every variant channel, plus byte-identical css()), contrast monotonicity across the ramp, each of the three mechanisms in isolation, direction stability, mirroring and per-exporter suppression, the config/extend/export round-trip, and the preferInitial tie-break at solver level.

Docs: new ## Manual contrast level section in docs/api.md (+ TOC, config table, output-modes and validation tables, solver options), the window-lerp formula in docs/okhst.md, a note under ## High contrast in docs/methodology.md, a README bullet, and refreshed AGENTS.md rows. Minor changeset included.

🤖 Generated with Claude Code

tenphi and others added 2 commits August 3, 2026 14:44
Three assertions failed at HEAD under Node 24 with
`expected 6.999999999999998 to be greater than or equal to 7`.

The cause is quantization, not float noise. `cachedLuminance` measures a
candidate at a tone rounded to 4 decimals, but the resolver stores and emits
the unrounded tone — so re-measuring the emitted color lands off the value the
solver judged `met` against, by up to `21^1e-4` (~0.021%) for a WCAG ratio and
~0.023 Lc for APCA. An exact `>= target` therefore fails whenever a solve
converges near that rounding boundary; the three failures were simply the ones
that happened to.

Add `expectMeetsWcag` / `expectMeetsApca` with tolerances ~5x the measured
worst case — still far tighter than the library's own `CONTRAST_WARN_SLACK_*`
thresholds, so a wrong target still fails loudly (verified by deleting the
AA -> AAA promotion, which keeps failing these assertions). Applied to every
exact contrast assertion in the file, not only the three that surfaced, since
they all carried the same latent fragility.

The solver is untouched: it does meet the target in its own basis
(7.00104 for the case above, `met: true`).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ontrast

Turn the two-tier high-contrast model into a 0-100 slider. `contrastLevel` is a
config field, so it works globally via `glaze.configure()` and per theme, per
token, and through `extend()`. It defaults to `'auto'`, which is today's
behavior exactly.

Level 0 reproduces the normal `light` / `dark` output and level 100 the
`lightContrast` / `darkContrast` output, bit for bit — by short-circuit, never
by float arithmetic. Levels in between are resolved *at* that level rather than
interpolated afterwards: the three mechanisms that make high contrast differ are
interpolated on the input side and fed through the ordinary resolve —

  - authored `[normal, highContrast]` pairs on `tone` / `contrast` / shadow
    `intensity` / mix `value`,
  - the tone-window widening (light `[10,100] -> [0,100]`, dark
    `[15,95] -> [0,100]`),
  - the `AA -> AAA` / APCA `+15 Lc` escalation.

So a contrast floor is genuinely solved at every level (`contrast: 'AA'` at
level 50 solves for 5.75) and is met at every level, rather than being an
unverified blend of two solved anchors.

While a level is set there is no separate high-contrast tier: the HC slots
mirror their normal counterparts and a global level defaults
`modes.highContrast` to `false`. `css()` keeps its four-string shape with the HC
strings repeating the normal declarations, so existing
`@media (prefers-contrast: more)` wiring keeps working untouched. An explicit
`modes: { highContrast: true }` still wins, and a level on one theme of a
palette leaves its siblings' HC 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 let a color leap ~99 tone units across its
base. The side is now decided once from the nearer endpoint and preferred
throughout that half of the ramp, via a new `preferInitial` option on
`findToneForContrast`. Flipping is only re-ordered, never disabled, so a side
that cannot physically reach the target still falls back and the floor is met.

A `contrast` pair may no longer switch metric: `[4.5, { apca: 75 }]` now throws.
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.
Previously it resolved silently.

Also exports `resolveContrastForLevel` and `contrastMetricOf`. A manual resolve
runs two passes instead of four.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

📦 Snapshot release

Published 0.0.0-snapshot.5f6a5c2.

pnpm add @tenphi/glaze@0.0.0-snapshot.5f6a5c2

tenphi and others added 2 commits August 3, 2026 15:48
Behavior-preserving cleanups from a review pass:

- Extract the shared solver options in `resolveDependentColor`. The
  side-pinning probe and the real solve duplicated all eight arguments, so a
  change to one (say `pastel`) could silently miss the other.
- Replace the scheme filter in `verifyContrastDrift` with a guard clause,
  dropping an alias and a local.
- Drop the redundant equal-ends fast path in `parseToneValueAt` — identical
  entries already resolve to the same value through the remaining branches.
- Trim an over-explained comment in `resolveContrastSpec`.
- Cut the manual-contrast paragraph from `docs/okhst.md` that restated
  resolver behavior already documented in `docs/api.md`, keeping only the
  window-boundary interpolation, which completes a rule that page owns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`modes.highContrast: true` used to override the suppression and emit a
high-contrast tier of mirrored values. That was wrong: a manual contrast level
*is* the contrast preference, so a separate normal/high-contrast pair has no
meaning alongside it, and emitting a duplicate tier claims a variant that does
not exist.

`modes.highContrast` now reads as "emit a separate high-contrast set when
contrast is automatic" and goes inert while a global level is set — silently.
Switching a preference from auto to manual is normal use, not a mistake, so a
build config that still carries `highContrast: true` keeps working without a
warning or a required edit.

A level on a single theme still leaves its siblings' tier alone, where it
reports its own resolved values rather than dropping its colors.

Also corrects the changeset, which claimed `contrastMetricOf` was exported (it
is internal), and trims the `contrastLevel` JSDoc that restated `docs/api.md`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tenphi
tenphi merged commit ff20d49 into main Aug 3, 2026
3 checks passed
@github-actions github-actions Bot mentioned this pull request Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant