feat: manual contrast level (contrastLevel) - #85
Merged
Conversation
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>
Contributor
📦 Snapshot releasePublished |
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>
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Turns the two-tier high-contrast model into a
0–100slider, for products where contrast is a user preference rather than a binary OS tier.number | 'auto', default'auto'— which is today's behavior exactly. Available globally, per theme, per token, and throughextend(), since it rides the existingGlazeConfig/GlazeConfigOverrideplumbing.Why resolve at the level
Level
0reproduces the normallight/darkoutput and level100thelightContrast/darkContrastoutput bit for bit — by short-circuit, never by float arithmetic (a + (b-a)*1is notbin 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:
HCPairs (tone,contrast, shadowintensity, mixvalue)10→0, dark15→0 / 95→100AA→AAA/ APCA+15 LcescalationSo 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.autoFlipalso 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:
High-contrast output
There is no second tier to emit while a level is set:
resolve()still returns four variants, butlightContrast/darkContrastmirrorlight/dark.modes.highContrasttofalse, so no exporter emits the tier. An explicitmodes: { 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.Mirroring is what makes suppression an optimization rather than a correctness requirement — hence one line in
resolveModesinstead 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:The side is now decided once from the nearer endpoint and preferred throughout that half of the ramp, via a new
preferInitialoption onfindToneForContrast. 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
contrastpair may no longer switch metric.[4.5, { apca: 75 }]now throws invalidateColorDefs. 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 excludesmodesandstates.configure()never clears by omission, so'auto'is the way out of manual mode. Documented.[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.resolveContrastForLevel,contrastMetricOf, andpreferInitialonFindToneForContrastOptions. All additive.Commits
test:— a standalone fix for three assertions that were failing atHEADon Node 24. Root cause is quantization, not float noise:cachedLuminancemeasures 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. NewexpectMeetsWcag/expectMeetsApcahelpers carry ~5x headroom over the measured worst case and stay far tighter than the library's ownCONTRAST_WARN_SLACK_*. Verified by deleting theAA→AAApromotion — 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 buildall clean. Tests cover bit-exact endpoints (toEqualon every variant channel, plus byte-identicalcss()), 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 thepreferInitialtie-break at solver level.Docs: new
## Manual contrast levelsection indocs/api.md(+ TOC, config table, output-modes and validation tables, solver options), the window-lerp formula indocs/okhst.md, a note under## High contrastindocs/methodology.md, a README bullet, and refreshedAGENTS.mdrows. Minor changeset included.🤖 Generated with Claude Code