Add an opt-in wider gamut, emitted as oklch() - #11
Merged
Conversation
Hct.toInt solves within the sRGB cube, so every colour is gamut clipped before we ever see it. On a vibrant or expressive scheme that throws away a lot of chroma a P3 display could have shown. The new "gamut" option re-solves the hue, chroma and tone a colour asks for against Display P3 or Rec. 2020 primaries instead, and emits oklch(). "srgb" stays the default and still emits hex, so existing themes are unchanged, byte for byte. Reading the resolved ARGB back would be too late, as the chroma is already gone, so the requested HCT triple is reconstructed the way the two spec versions' calculation delegates build it: 2021 takes the palette's chroma, 2025 scales it by the colour's chroma multiplier. That reconstruction reproduces Material's own resolved colour exactly across all 50976 combinations of source colour, variant, spec version, mode and contrast level. The fit gives up chroma but never tone. Tone is CIE L*, and it is the invariant the solve holds, so Y is preserved and the contrast guarantees between a colour and its on- pair survive intact. Note that HCT and OKLCH are not the same space despite both being cylindrical: their hue angles disagree by up to 19 degrees in the blues and their chroma scales differ by a factor of about 400. Everything here converts rather than reinterprets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Hct.toIntsolves within the sRGB cube, so every color is gamut clipped before the plugin ever sees it. On avibrantorexpressivescheme that throws away a lot of chroma a P3 display could have shown.This adds a
gamutoption that re-solves the hue, chroma and tone a color asks for against Display P3 or Rec. 2020 primaries instead, and emitsoklch():srgbstays the default and still emits hex, so existing themes are unchanged byte for byte —theme test.snapshot.jsonis untouched by this PR.How the chroma is recovered
Reading the resolved ARGB back would be too late, since the chroma is already gone. So
requestedHctinindex.jsreconstructs the triple the color actually asked for, the way the two spec versions' calculation delegates build it: 2021 takes the palette's chroma, 2025 scales it by the color's chroma multiplier.color.jsthen bisects CAM16Jonto the targetL*and fits chroma to the wider primaries.That reconstruction was verified rather than assumed: across 6 source colors × 9 variants × 2 spec versions × light/dark × 4 contrast levels — 50,976 colors — it reproduces Material's own resolved ARGB exactly.
Tone is preserved, so contrast is too
The fit gives up chroma but never tone. Tone is CIE
L*and it is the invariant the solve holds, soYis preserved and the contrast guarantees between a color and itson-pair survive intact. There is a test that goes back fromoklch()toYand checksL*against the requested tone, in every gamut.Notes
HCT and OKLCH are both cylindrical perceptual spaces but they are not the same space — their hue angles disagree by up to 19° in the blues and their chroma scales differ by a factor of ~400 — so everything here converts rather than reinterprets coordinates.
Measured on a vibrant/2025 theme: 51 colors gained chroma (up to +36%), 8 moved down by ≤1.1%, which is 8-bit quantization noise in the hex baseline they are compared against. Gamut ordering checks out: max chroma sRGB 0.303 < P3 0.322 < Rec. 2020 0.392.
Two caveats documented in the README: on an sRGB screen the browser does the gamut mapping rather than Material, so output is very close to but not byte-identical to
srgb; and anything downstream parsing the theme for#rrggbbwill break.oklch()has been Baseline since 2023, which is older than thelight-dark()these colors already ship in.The example app now opts in to
display-p3. Happy to revert that to sRGB if the demo should stay conservative.Testing
26 tests pass,
tscandprettierclean, and the example builds with 160oklch()values and no fallback rewriting from Lightning CSS.🤖 Generated with Claude Code