Skip to content

Add an opt-in wider gamut, emitted as oklch() - #11

Merged
SantaClaas merged 1 commit into
mainfrom
wide-gamut-colors
Aug 6, 2026
Merged

Add an opt-in wider gamut, emitted as oklch()#11
SantaClaas merged 1 commit into
mainfrom
wide-gamut-colors

Conversation

@SantaClaas

Copy link
Copy Markdown
Owner

Hct.toInt solves within the sRGB cube, so every color is gamut clipped before the plugin ever sees it. On a vibrant or expressive scheme that throws away a lot of chroma a P3 display could have shown.

This adds a gamut option that re-solves the hue, chroma and tone a color asks for against Display P3 or Rec. 2020 primaries instead, and emits oklch():

@plugin "@claas.dev/material-tailwind" {
  source-color: #0c1445;
  variant: vibrant;
  gamut: display-p3; /* or rec2020, or p3 as an alias */
}
srgb        --color-primary: light-dark(#1c41ff, #bbc3ff);
display-p3  --color-primary: light-dark(oklch(0.50916 0.29357 264.68), …);

srgb stays the default and still emits hex, so existing themes are unchanged byte for byte — theme test.snapshot.json is 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 requestedHct in index.js reconstructs 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.js then bisects CAM16 J onto the target L* 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, so Y is preserved and the contrast guarantees between a color and its on- pair survive intact. There is a test that goes back from oklch() to Y and checks L* 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 #rrggbb will break. oklch() has been Baseline since 2023, which is older than the light-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, tsc and prettier clean, and the example builds with 160 oklch() values and no fallback rewriting from Lightning CSS.

🤖 Generated with Claude Code

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>
@SantaClaas
SantaClaas merged commit c6641ca into main Aug 6, 2026
@SantaClaas
SantaClaas deleted the wide-gamut-colors branch August 6, 2026 22:01
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