feat: emit the high-contrast tier under a manual contrastLevel - #91
Merged
Conversation
A manual `contrastLevel` no longer suppresses high-contrast output. The level now only positions the normal `light` / `dark` variants on the 0-100 slider; the high-contrast tier stays the true high-contrast resolution at every level and `modes.highContrast` alone decides whether it is emitted, so a slider and a `prefers-contrast: more` block compose. A global level of 100 is the one exception: the normal variants already are the high-contrast ones, so the tier would duplicate them and a single light/dark set is emitted instead. Also gates the side-stability probe on `!isHighContrast`. It was leaking into the high-contrast passes, which made a mid-level high-contrast variant diverge from its `'auto'` counterpart across 28% of a swept grid of base tones, saturations and contrast forms. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
📦 Snapshot releasePublished |
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.
Why
A manual
contrastLevelforcedmodes.highContrast = false, so setting a level suppressed high-contrast output entirely. That coupling was a mistake: if HC output is enabled, it should be emitted whether or not a level is set.The model now
contrastLeveland the high-contrast tier are orthogonal:contrastLevelpositions only the normallight/darkvariants on the 0–100 slider. That is all it does.lightContrast/darkContraststay the true high-contrast resolution — bit-identical to what'auto'resolves — at every level.modes.highContrastalone decides whether that tier is emitted.So a slider and a
prefers-contrast: moreblock compose: raising the baseline still leaves the media query real work to do.The one exception falls out of the model rather than being bolted on. At
contrastLevel: 100the normal variants are the high-contrast ones (already documented and tested as bit-identical), so a separate tier would be an exact duplicate — a global level of 100 emits a single light/dark set instead, even against an explicitmodes: { highContrast: true }.Two semantics that follow:
contrastLevel: 0now reproduces'auto'output exactly, HC tier included. It no longer implies "no HC tier" — that ismodes.highContrast: false, still the default.Bug fixed along the way
The side-stability probe at
src/resolver.ts:589ran on every pass, not just the normal ones. It was latent while the HC passes were skipped under a manual level, but it would have made the HC tier level-dependent the moment they ran.I swept an 80,639-row grid (hues × saturations × base tones × tone deltas × 12 contrast forms × roles, at levels 1–49) comparing gated against ungated: 22,714 rows (28%) diverged, across every contrast form and all saturations. Not a corner case.
Worth knowing for reviewers: no natural test caught this — not even a four-exporter comparison against
'auto'using the suite's existingfixture(). The divergence needs a base near the tone window's edge. Sokeeps the high-contrast tier off the side-stability probeuses eight cases each individually verified to fail without the gate; I confirmed it fails ungated and passes gated rather than assuming.Blast radius
Widest in
css(), which has nomodesoption and always returns four strings. Anyone pipingcss().lightContrastinto a@media (prefers-contrast: more)block now gets genuinely escalated values where they previously got a copy of the base block. The changeset leads with this.minor.'auto'output is bit-identical —hcMirrorsNormalis false there, so every code path is unchanged.Known limitation
The collapse rule reads the global config while resolution reads the merged effective config. An instance that opts out of a global
contrastLevel: 100({ contrastLevel: 'auto' }, or any instance level) resolves a real, distinct HC tier that no token exporter emits — andcss()still emits it, so the exporters disagree for that theme. This narrows prior behavior (before, any global level dropped the tier for every theme) but does not eliminate it. Fixing it means either threading the effective config intoresolveModes— which would let palette siblings disagree on emitted structure — or makingmodesper-instance. Flagging rather than deciding.Verification
Full CI gate green: build, lint, format:check, typecheck, 374 tests.
End-to-end on the built output: at level 60
tailwind()emits all four blocks with the HC tier carrying exactly the level-100 values; at level 100 it collapses to those values in two blocks.🤖 Generated with Claude Code