Skip to content

feat: emit the high-contrast tier under a manual contrastLevel - #91

Merged
tenphi merged 1 commit into
mainfrom
feat/hc-tier-under-manual-contrast-level
Aug 14, 2026
Merged

feat: emit the high-contrast tier under a manual contrastLevel#91
tenphi merged 1 commit into
mainfrom
feat/hc-tier-under-manual-contrast-level

Conversation

@tenphi

@tenphi tenphi commented Aug 14, 2026

Copy link
Copy Markdown
Owner

Why

A manual contrastLevel forced modes.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

contrastLevel and the high-contrast tier are orthogonal:

  • contrastLevel positions only the normal light / dark variants on the 0–100 slider. That is all it does.
  • lightContrast / darkContrast stay the true high-contrast resolution — bit-identical to what 'auto' resolves — at every level.
  • modes.highContrast alone decides whether that tier is emitted.

So a slider and a prefers-contrast: more block 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: 100 the 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 explicit modes: { highContrast: true }.

Two semantics that follow:

  • contrastLevel: 0 now reproduces 'auto' output exactly, HC tier included. It no longer implies "no HC tier" — that is modes.highContrast: false, still the default.
  • The collapse reads the global config only, so a per-theme or per-token level never reshapes a palette's shared token structure.

Bug fixed along the way

The side-stability probe at src/resolver.ts:589 ran 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 existing fixture(). The divergence needs a base near the tone window's edge. So keeps the high-contrast tier off the side-stability probe uses 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 no modes option and always returns four strings. Anyone piping css().lightContrast into 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 — hcMirrorsNormal is 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 — and css() 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 into resolveModes — which would let palette siblings disagree on emitted structure — or making modes per-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

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>
@github-actions

Copy link
Copy Markdown
Contributor

📦 Snapshot release

Published 0.0.0-snapshot.46ef2cf.

pnpm add @tenphi/glaze@0.0.0-snapshot.46ef2cf

@tenphi
tenphi merged commit 5bf6195 into main Aug 14, 2026
3 checks passed
@github-actions github-actions Bot mentioned this pull request Aug 14, 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