From 1aaf1317b89b73e1242f1f141c1d4554c311ed23 Mon Sep 17 00:00:00 2001
From: Claas
Date: Thu, 6 Aug 2026 23:54:45 +0200
Subject: [PATCH] Add an opt-in wider gamut, emitted as oklch()
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
---
README.md | 59 +++++++--
color.js | 289 ++++++++++++++++++++++++++++++++++++++++++
example/src/App.jsx | 5 +-
example/src/index.css | 5 +
index.js | 146 ++++++++++++++++++---
index.test.js | 107 +++++++++++++++-
6 files changed, 584 insertions(+), 27 deletions(-)
create mode 100644 color.js
diff --git a/README.md b/README.md
index f5cd181..c0b5c82 100644
--- a/README.md
+++ b/README.md
@@ -48,11 +48,12 @@ export default {
### Options
-| Option | Values | Default |
-| -------------- | -------------------------------------------------------------------------------------------------- | ------------ |
-| `source-color` | Any hex color | _(required)_ |
+| Option | Values | Default |
+| -------------- | --------------------------------------------------------------------------------------------------------------- | ------------ |
+| `source-color` | Any hex color | _(required)_ |
| `variant` | `monochrome`, `neutral`, `tonal-spot`, `vibrant`, `expressive`, `fidelity`, `content`, `rainbow`, `fruit-salad` | `tonal-spot` |
-| `spec-version` | `2021`, `2025` | `2021` |
+| `spec-version` | `2021`, `2025` | `2021` |
+| `gamut` | `srgb`, `display-p3` (or `p3`), `rec2020` | `srgb` |
Every option also accepts camelCase (`sourceColor`, `specVersion`) so the same names
work in a `tailwind.config.js`.
@@ -62,6 +63,7 @@ work in a `tailwind.config.js`.
source-color: #0c1445;
variant: vibrant;
spec-version: 2025;
+ gamut: display-p3;
}
```
@@ -85,6 +87,47 @@ slightly and adds the `-dim` colors:
The `-dim` utilities are generated under both spec versions, so you can use them without
opting in to `2025`.
+#### `gamut`
+
+Which gamut colors are fitted into. `srgb` is the default and emits hex, exactly matching
+the [Material Theme Builder](https://material-foundation.github.io/material-theme-builder/).
+
+`display-p3` and `rec2020` fit the same colors into a wider gamut instead and emit
+`oklch()`, so saturated colors keep chroma that sRGB cannot hold:
+
+```css
+@plugin "@claas.dev/material-tailwind" {
+ source-color: #0c1445;
+ variant: vibrant;
+ gamut: display-p3;
+}
+```
+
+```
+srgb --color-primary: light-dark(#1c41ff, #bbc3ff);
+display-p3 --color-primary: light-dark(oklch(0.50916 0.29357 264.68), …);
+```
+
+How much this gains depends on how much chroma your scheme asks for. `tonal-spot` and
+`neutral` stay close to sRGB and change little. `vibrant`, `expressive`, `content` and
+`fidelity` with a saturated source color are where sRGB was clipping most, and where a P3
+display has the most to show.
+
+Some things worth knowing before turning it on:
+
+- **Tone is preserved exactly.** Material expresses its contrast guarantees in tone, and
+ fitting to a wider gamut only ever spends chroma, never tone. Contrast between a color
+ and its `on-` pair is unchanged.
+- **sRGB displays are unaffected in principle.** Browsers gamut-map `oklch()` to the
+ display, so these colors still render — but on an sRGB screen the browser does the
+ fitting rather than Material, so the result is very close to, but not byte-identical
+ to, the `srgb` output.
+- **Out-of-sRGB colors are not hex.** If anything downstream parses your theme expecting
+ `#rrggbb`, keep the default.
+
+Browser support is not a concern: `oklch()` has been Baseline since 2023, which is older
+than the `light-dark()` these colors are already emitted with.
+
# How it works
The plugin generates colors with [@material/material-color-utilites](https://www.npmjs.com/package/@material/material-color-utilities) and extends the Tailwind CSS theme to make them available for you. Additionally this plugin extends the default theme with various design tokens collected from [material.io](https://material.io) and the [Material 3 Design Kit (Community)](https://www.figma.com/community/file/1035203688168086460).
@@ -131,10 +174,10 @@ Under the 2021 spec the fixed roles are fixed tones: `primary-fixed` is tone 90
8:1 instead, and its `primary-fixed` is a good deal darker. With a high chroma variant
there is then no room left below, so the color bottoms out at tone 0:
-| spec | `primary-fixed` | `on-primary-fixed` | contrast |
-| ---- | --------------- | ------------------ | -------- |
-| 2021 | `#dfe0ff` (tone 90) | `#000e5f` (tone 10) | 13.2 |
-| 2025 | `#8a99ff` (tone 66) | `#000000` (tone 0) | 8.1 |
+| spec | `primary-fixed` | `on-primary-fixed` | contrast |
+| ---- | ------------------- | ------------------- | -------- |
+| 2021 | `#dfe0ff` (tone 90) | `#000e5f` (tone 10) | 13.2 |
+| 2025 | `#8a99ff` (tone 66) | `#000000` (tone 0) | 8.1 |
The contrast target is still met, so this is what the spec produces rather than a bug in
this plugin, which passes the values through unchanged. It is not specific to one source
diff --git a/color.js b/color.js
new file mode 100644
index 0000000..f7964ed
--- /dev/null
+++ b/color.js
@@ -0,0 +1,289 @@
+import {
+ Cam16,
+ ViewingConditions,
+ yFromLstar,
+ redFromArgb,
+ greenFromArgb,
+ blueFromArgb,
+ linearized,
+} from "@material/material-color-utilities";
+
+/**
+ * HCT and OKLCH are both cylindrical perceptual spaces, but they are not the
+ * same space: HCT takes hue and chroma from CAM16 and tone from CIE L*, while
+ * OKLCH is the polar form of Oklab. Their hue angles disagree by up to ~19
+ * degrees in the blues and their chroma scales differ by a factor of ~400, so
+ * the coordinates cannot be renamed into each other. Everything here converts
+ * rather than reinterprets.
+ */
+
+/**
+ * The gamut colors are fitted into. Material's own solver only ever targets
+ * sRGB, so anything wider is where this module earns its keep.
+ * @typedef {"srgb" | "display-p3" | "rec2020"} Gamut
+ */
+
+/**
+ * XYZ (D65) to linear RGB, per gamut. The sRGB matrix is the one
+ * material-color-utilities uses internally, so an in-gamut color resolves to
+ * the same value it would have had going through `Hct.toInt`.
+ * @type {Record}
+ */
+const xyzToLinearRgb = {
+ srgb: [
+ [3.2413774792388685, -1.5376652402851851, -0.49885366846268053],
+ [-0.9691452513005321, 1.8758853451067872, 0.04156585616912061],
+ [0.05562093689691305, -0.20395524564742123, 1.0571799111220335],
+ ],
+ "display-p3": [
+ [2.4934969119414263, -0.9313836179191241, -0.40271078445071684],
+ [-0.8294889695615747, 1.7626640603183463, 0.023624685841943577],
+ [0.03584583024378447, -0.07617238926804182, 0.9568845240076872],
+ ],
+ rec2020: [
+ [1.7166511879712674, -0.3556707837763924, -0.2533662813736599],
+ [-0.6666843518324892, 1.6164812366349395, 0.01576854581391113],
+ [0.017639857445310783, -0.042770613257808524, 0.9421031212354739],
+ ],
+};
+
+/** @type {Gamut[]} */
+export const gamuts = /** @type {Gamut[]} */ (Object.keys(xyzToLinearRgb));
+
+/** @type {Gamut} */
+export const defaultGamut = "srgb";
+
+const viewingConditions = ViewingConditions.DEFAULT;
+
+/** @type {Map} */
+const cache = new Map();
+
+/**
+ * The largest CAM16 chroma worth searching. Real gamuts top out well below
+ * this; the bound only keeps the search finite.
+ */
+const maximumChroma = 200;
+
+/**
+ * Converts unclamped linear sRGB to Oklab. Components outside 0..1 are
+ * meaningful here: they are how a color wider than sRGB is expressed, and
+ * `Math.cbrt` is defined for negatives.
+ * @param {number[]} linearRgb
+ * @returns {[l: number, a: number, b: number]}
+ */
+function oklabFromLinearRgb([red, green, blue]) {
+ const long = Math.cbrt(
+ 0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue,
+ );
+ const medium = Math.cbrt(
+ 0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue,
+ );
+ const short = Math.cbrt(
+ 0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue,
+ );
+
+ return [
+ 0.2104542553 * long + 0.793617785 * medium - 0.0040720468 * short,
+ 1.9779984951 * long - 2.428592205 * medium + 0.4505937099 * short,
+ 0.0259040371 * long + 0.7827717662 * medium - 0.808675766 * short,
+ ];
+}
+
+/**
+ * @param {number[]} linearRgb
+ * @returns {[lightness: number, chroma: number, hue: number]}
+ */
+function oklchFromLinearRgb(linearRgb) {
+ const [lightness, a, b] = oklabFromLinearRgb(linearRgb);
+ const hue = (Math.atan2(b, a) * 180) / Math.PI;
+
+ return [lightness, Math.hypot(a, b), hue < 0 ? hue + 360 : hue];
+}
+
+/**
+ * Converts XYZ (on material-color-utilities' 0..100 scale) to linear RGB in the
+ * given gamut, without clamping so the caller can tell whether it fits.
+ * @param {number[]} xyz
+ * @param {Gamut} gamut
+ * @returns {number[]}
+ */
+function linearRgbFromXyz([x, y, z], gamut) {
+ return xyzToLinearRgb[gamut].map(
+ (row) => (row[0] * x + row[1] * y + row[2] * z) / 100,
+ );
+}
+
+/** Room for floating point error when testing gamut boundaries */
+const epsilon = 1e-7;
+
+/**
+ * @param {number[]} linearRgb
+ * @returns {boolean}
+ */
+function isInGamut(linearRgb) {
+ return linearRgb.every(
+ (component) =>
+ Number.isFinite(component) &&
+ component >= -epsilon &&
+ component <= 1 + epsilon,
+ );
+}
+
+/**
+ * The Y a CAM16 color with this lightness, chroma and hue resolves to.
+ * @param {number} lightness CAM16 J
+ * @param {number} chroma
+ * @param {number} hue
+ * @returns {number}
+ */
+function yFromCam16(lightness, chroma, hue) {
+ return Cam16.fromJchInViewingConditions(
+ lightness,
+ chroma,
+ hue,
+ viewingConditions,
+ ).xyzInViewingConditions(viewingConditions)[1];
+}
+
+/**
+ * Converts an HCT triple to XYZ without constraining it to any gamut.
+ *
+ * HCT's tone is CIE L*, but CAM16 is parameterised by its own lightness J, and
+ * J depends on chroma and hue as well as luminance. There is no closed form for
+ * the one from the other, so J is bisected until the color lands on the Y the
+ * requested tone asks for. This is the same invariant `HctSolver` holds, which
+ * is what keeps Material's contrast guarantees intact: they are expressed in
+ * tone, and tone is preserved exactly.
+ * @param {number} hue
+ * @param {number} chroma
+ * @param {number} tone
+ * @returns {number[]} XYZ on the 0..100 scale
+ */
+function xyzFromHct(hue, chroma, tone) {
+ const targetY = yFromLstar(tone);
+ if (targetY <= 0) return [0, 0, 0];
+
+ // Y increases with J at a fixed chroma and hue, so the target is bracketed.
+ let low = 0;
+ let high = 100;
+ // A chromatic color needs a higher J than white to reach white's luminance,
+ // so the upper bound occasionally has to grow.
+ while (high < 1e4 && yFromCam16(high, chroma, hue) < targetY) high *= 2;
+
+ for (let iteration = 0; iteration < 64 && high - low > 1e-9; iteration++) {
+ const middle = (low + high) / 2;
+ if (yFromCam16(middle, chroma, hue) < targetY) low = middle;
+ else high = middle;
+ }
+
+ return Cam16.fromJchInViewingConditions(
+ (low + high) / 2,
+ chroma,
+ hue,
+ viewingConditions,
+ ).xyzInViewingConditions(viewingConditions);
+}
+
+/**
+ * Finds the most chromatic color of this hue and tone that fits in the gamut,
+ * giving up chroma but never tone. Mirrors what `HctSolver` does for sRGB.
+ *
+ * The gamut decides only how much chroma survives. The color itself is always
+ * carried as XYZ, because Oklab is defined off linear sRGB and feeding it
+ * another gamut's primaries would silently reinterpret the color.
+ * @param {number} hue
+ * @param {number} chroma
+ * @param {number} tone
+ * @param {Gamut} gamut
+ * @returns {number[]} XYZ on the 0..100 scale
+ */
+function solveToXyz(hue, chroma, tone, gamut) {
+ const requested = xyzFromHct(hue, chroma, tone);
+ if (isInGamut(linearRgbFromXyz(requested, gamut))) return requested;
+
+ // Tone alone is always achievable: chroma 0 is the neutral of that tone.
+ let low = 0;
+ let high = Math.min(chroma, maximumChroma);
+ let answer = xyzFromHct(hue, 0, tone);
+
+ // 0.01 chroma is far below a perceptible step and below what 10 bits resolve
+ for (let iteration = 0; iteration < 24 && high - low > 0.01; iteration++) {
+ const middle = (low + high) / 2;
+ const candidate = xyzFromHct(hue, middle, tone);
+ if (isInGamut(linearRgbFromXyz(candidate, gamut))) {
+ answer = candidate;
+ low = middle;
+ } else {
+ high = middle;
+ }
+ }
+
+ return answer;
+}
+
+/**
+ * Rounds to at most `digits` decimals and drops trailing zeroes so the CSS
+ * stays readable. Five decimals on lightness and chroma and two on hue round
+ * trip every sRGB color exactly; four leaves some off by 1/255.
+ * @param {number} value
+ * @param {number} digits
+ * @returns {string}
+ */
+function format(value, digits) {
+ return String(Number(value.toFixed(digits)));
+}
+
+/**
+ * @param {[lightness: number, chroma: number, hue: number]} oklch
+ * @returns {string}
+ */
+function toCss([lightness, chroma, hue]) {
+ // A neutral has no meaningful hue angle, and omitting it avoids noise in the
+ // output when the source color is grey
+ if (chroma < 5e-6) return `oklch(${format(lightness, 5)} 0 0)`;
+
+ return `oklch(${format(lightness, 5)} ${format(chroma, 5)} ${format(hue, 2)})`;
+}
+
+/**
+ * Converts a resolved sRGB color to an `oklch()` value. Used for colors that
+ * material-color-utilities has already fitted into sRGB.
+ * @param {number} argb
+ * @returns {string}
+ */
+export function oklchFromArgb(argb) {
+ return toCss(
+ oklchFromLinearRgb([
+ linearized(redFromArgb(argb)) / 100,
+ linearized(greenFromArgb(argb)) / 100,
+ linearized(blueFromArgb(argb)) / 100,
+ ]),
+ );
+}
+
+/**
+ * Converts an HCT triple to an `oklch()` value, fitted to `gamut` rather than
+ * to sRGB. This is the point of the whole module: `Hct.toInt` always clips to
+ * sRGB, so going through it throws away chroma a P3 display could have shown.
+ * @param {number} hue
+ * @param {number} chroma
+ * @param {number} tone
+ * @param {Gamut} gamut
+ * @returns {string}
+ */
+export function oklchFromHct(hue, chroma, tone, gamut) {
+ // The eight contrast schemes share their palettes, so the same triple comes
+ // up many times over one theme and the nested solves are worth caching
+ const key = `${hue}|${chroma}|${tone}|${gamut}`;
+ const cached = cache.get(key);
+ if (cached !== undefined) return cached;
+
+ const xyz = solveToXyz(hue, chroma, tone, gamut);
+
+ // Always via linear sRGB: that is the space Oklab is defined against, and
+ // components outside 0..1 are exactly how a wider-than-sRGB color is carried.
+ const css = toCss(oklchFromLinearRgb(linearRgbFromXyz(xyz, "srgb")));
+ cache.set(key, css);
+
+ return css;
+}
diff --git a/example/src/App.jsx b/example/src/App.jsx
index 790769c..58e6e11 100644
--- a/example/src/App.jsx
+++ b/example/src/App.jsx
@@ -18,8 +18,9 @@ export default function App() {
Every color below is generated at build time from the source color,
- variant and spec version in src/index.css. Edit them
- there and the whole page follows.
+ variant, spec version and gamut in src/index.css. Edit
+ them there and the whole page follows. On a P3 display the wider gamut
+ is visible in the most saturated colors.
diff --git a/example/src/index.css b/example/src/index.css
index c317e39..292a096 100644
--- a/example/src/index.css
+++ b/example/src/index.css
@@ -12,4 +12,9 @@
/* 2021 is the established spec, 2025 is Material 3 Expressive.
Defaults to 2021 when left out. */
spec-version: 2025;
+
+ /* Fit the colors to a wider gamut than sRGB and emit oklch(), so a P3
+ display shows chroma sRGB has to clip. Try rec2020, or leave it out
+ for the sRGB hex the Material Theme Builder gives you. */
+ gamut: display-p3;
}
diff --git a/index.js b/index.js
index fa8db0a..08bf207 100644
--- a/index.js
+++ b/index.js
@@ -18,9 +18,11 @@ import {
import plugin from "tailwindcss/plugin.js";
import defaultConfiguration from "./default.config.js";
+import { defaultGamut, gamuts, oklchFromArgb, oklchFromHct } from "./color.js";
/**
* @import {DynamicScheme} from "@material/material-color-utilities"
+ * @import {Gamut} from "./color.js"
*/
/**
@@ -80,9 +82,10 @@ function camelToKebabCase(value) {
/**
*
* @param {TonalPalette} palette
+ * @param {Gamut} gamut
* @returns {Generator<[number, string]>}
*/
-function* generatePaletteSteps(palette) {
+function* generatePaletteSteps(palette, gamut) {
// Supported color steps
// Material Design goes from 0 to 100 for lightness (like percent)
// whereas Tailwind goes from 50 to 950
@@ -97,22 +100,49 @@ function* generatePaletteSteps(palette) {
];
for (const step of materialPalletteSteps) {
- yield [step, hexFromArgb(palette.tone(step))];
+ yield [step, resolvePaletteStep(palette, step, gamut)];
}
}
+/**
+ * TonalPalette averages tones 98 and 100 for tone 99 of a yellow palette,
+ * which is an sRGB operation and not an HCT triple we could re-solve in a
+ * wider gamut. It lands next to white with almost no chroma, so nothing is
+ * lost by taking the sRGB answer for it.
+ * @param {TonalPalette} palette
+ * @param {number} tone
+ * @returns {boolean}
+ */
+function isAveragedTone(palette, tone) {
+ return tone === 99 && Hct.isYellow(palette.hue);
+}
+
+/**
+ * @param {TonalPalette} palette
+ * @param {number} tone
+ * @param {Gamut} gamut
+ * @returns {string}
+ */
+function resolvePaletteStep(palette, tone, gamut) {
+ if (gamut === "srgb") return hexFromArgb(palette.tone(tone));
+ if (isAveragedTone(palette, tone)) return oklchFromArgb(palette.tone(tone));
+
+ return oklchFromHct(palette.hue, palette.chroma, tone, gamut);
+}
+
/** @typedef {[name: string, palette: TonalPalette][]} PaletteArray */
/**
*
* @param {PaletteArray} materialPalettes
+ * @param {Gamut} gamut
*/
-function createPalettes(materialPalettes) {
+function createPalettes(materialPalettes, gamut) {
/** @type {Record} */
const palettes = {};
for (let [name, palette] of materialPalettes) {
name = camelToKebabCase(name);
- for (const [step, color] of generatePaletteSteps(palette)) {
+ for (const [step, color] of generatePaletteSteps(palette, gamut)) {
palettes[`${name}-${step}`] = color;
}
}
@@ -145,21 +175,57 @@ const colorNames = /** @type {ColorName[]} */ (
* that scheme's spec version (e.g. the "dim" colors before "2025").
* @param {DynamicScheme} scheme
* @param {ColorName} name
+ * @param {Gamut} gamut
* @returns {string | undefined}
*/
-function resolveColor(scheme, name) {
+function resolveColor(scheme, name, gamut) {
const color = scheme.colors[name]();
if (!(color instanceof DynamicColor)) return undefined;
- return hexFromArgb(scheme.getArgb(color));
+ if (gamut === "srgb") return hexFromArgb(scheme.getArgb(color));
+
+ // The 2021 spec resolves through TonalPalette, so it inherits the averaged
+ // tone 99 of a yellow palette
+ if (
+ scheme.specVersion !== "2025" &&
+ isAveragedTone(color.palette(scheme), color.getTone(scheme))
+ )
+ return oklchFromArgb(scheme.getArgb(color));
+
+ return oklchFromHct(...requestedHct(scheme, color), gamut);
+}
+
+/**
+ * The hue, chroma and tone a dynamic color asks for, before `Hct.toInt` fits it
+ * into sRGB. Going through the resolved ARGB instead would mean re-reading a
+ * color chroma has already been clipped out of, so the wider gamut would have
+ * nothing left to give back.
+ *
+ * This mirrors what the two spec versions' calculation delegates do: 2021 takes
+ * the palette's own chroma, 2025 scales it by the color's chroma multiplier.
+ * @param {DynamicScheme} scheme
+ * @param {DynamicColor} color
+ * @returns {[hue: number, chroma: number, tone: number]}
+ */
+function requestedHct(scheme, color) {
+ const palette = color.palette(scheme);
+ const tone = color.getTone(scheme);
+
+ if (scheme.specVersion === "2025") {
+ const multiplier = color.chromaMultiplier?.(scheme) ?? 1;
+ return [palette.hue, palette.chroma * multiplier, tone];
+ }
+
+ return [palette.hue, palette.chroma, tone];
}
/**
* Creates colors
* @param {Schemes} schemes
+ * @param {Gamut} gamut
* @returns {Record>}
*/
-function createColors(schemes) {
+function createColors(schemes, gamut) {
/** @type {Record >} */
const colors = {};
@@ -168,7 +234,7 @@ function createColors(schemes) {
const schemeColors = {};
for (const name of colorNames) {
- const color = resolveColor(scheme, name);
+ const color = resolveColor(scheme, name, gamut);
if (color === undefined) continue;
schemeColors[camelToKebabCase(name)] = color;
@@ -191,12 +257,12 @@ function createColors(schemes) {
for (const name of colorNames) {
for (const [prefix, light, dark] of contrasts) {
- const lightHex = resolveColor(schemes[light], name);
- const darkHex = resolveColor(schemes[dark], name);
- if (lightHex === undefined || darkHex === undefined) continue;
+ const lightColor = resolveColor(schemes[light], name, gamut);
+ const darkColor = resolveColor(schemes[dark], name, gamut);
+ if (lightColor === undefined || darkColor === undefined) continue;
colors[`${prefix}${camelToKebabCase(name)}`] =
- `light-dark(${lightHex}, ${darkHex})`;
+ `light-dark(${lightColor}, ${darkColor})`;
}
}
@@ -238,10 +304,11 @@ function createSchemes(sourceColor, variant, specVersion) {
* @param {string} sourceColor
* @param {VariantName} variant
* @param {SpecVersion} specVersion
+ * @param {Gamut} gamut
*/
-function createTheme(sourceColor, variant, specVersion) {
+function createTheme(sourceColor, variant, specVersion, gamut) {
const schemes = createSchemes(sourceColor, variant, specVersion);
- const colors = createColors(schemes);
+ const colors = createColors(schemes, gamut);
// The palettes are the same for light and dark
/** @type {PaletteArray} */
@@ -250,15 +317,20 @@ function createTheme(sourceColor, variant, specVersion) {
// Remove "palette" postfix
.map(([key, value]) => [key.replace("Palette", ""), value]);
- const palettes = createPalettes(sourcePalettes);
+ const palettes = createPalettes(sourcePalettes, gamut);
const tailwindTheme = defaultConfiguration;
+ // The source color is an sRGB hex the user gave us, so there is no wider
+ // gamut version of it to recover. It is only restated in the theme's format.
+ const source =
+ gamut === "srgb" ? sourceColor : oklchFromArgb(argbFromHex(sourceColor));
+
// Set colors
tailwindTheme.extend = {
...tailwindTheme.extend,
colors: {
- source: sourceColor,
+ source,
...colors,
...palettes,
},
@@ -301,6 +373,33 @@ class UnknownSpecVersionError extends Error {
}
}
+class UnknownGamutError extends Error {
+ /** @param {string} gamut */
+ constructor(gamut) {
+ super(
+ `"${gamut}" is not a supported gamut. Pick one of: ${gamuts.join(", ")}.`,
+ );
+ }
+}
+
+/**
+ * Aliases accepted for a gamut, so the CSS can say what reads naturally. A Map
+ * rather than an object so a name like "constructor" misses instead of
+ * resolving to something inherited.
+ * @type {Map}
+ */
+const gamutAliases = new Map(
+ /** @type {[string, Gamut][]} */ ([
+ ["srgb", "srgb"],
+ ["s-rgb", "srgb"],
+ ["p3", "display-p3"],
+ ["display-p3", "display-p3"],
+ ["displayp3", "display-p3"],
+ ["rec2020", "rec2020"],
+ ["rec-2020", "rec2020"],
+ ]),
+);
+
/**
* Reads the first of `names` that is set in the options
* @param {Record} options
@@ -360,10 +459,25 @@ const materialTailwindPlugin = plugin.withOptions(
if (!specVersions.includes(specVersion))
throw new UnknownSpecVersionError(specVersion);
+ /** @type {Gamut} */
+ let gamut = defaultGamut;
+ const requestedGamut = readOption(options, [
+ "gamut",
+ "color-gamut",
+ "colorGamut",
+ ]);
+ if (requestedGamut !== undefined) {
+ const alias = gamutAliases.get(requestedGamut.trim().toLowerCase());
+ if (alias === undefined) throw new UnknownGamutError(requestedGamut);
+
+ gamut = alias;
+ }
+
const tailwindTheme = createTheme(
sourceColor,
variantName,
/** @type {SpecVersion} */ (specVersion),
+ gamut,
);
return { theme: tailwindTheme };
},
diff --git a/index.test.js b/index.test.js
index def6ef4..93772d9 100644
--- a/index.test.js
+++ b/index.test.js
@@ -83,12 +83,117 @@ it("Creates the dim colors the 2025 spec adds", () => {
});
it("Rejects an unknown variant or spec version", () => {
- expect(() => createColors({ variant: "nope" })).toThrowError(/not a Material/);
+ expect(() => createColors({ variant: "nope" })).toThrowError(
+ /not a Material/,
+ );
expect(() => createColors({ "spec-version": "2030" })).toThrowError(
/not a Material/,
);
});
+/** Matches an `oklch()` value with a lightness, a chroma and a hue */
+const oklch = /^oklch\(\d+(\.\d+)? \d+(\.\d+)? \d+(\.\d+)?\)$/;
+
+it("Defaults to sRGB, in hex", () => {
+ expect(createColors({ gamut: "srgb" })).toStrictEqual(createColors({}));
+ expect(createColors({}).light.primary).toMatch(/^#[0-9a-f]{6}$/);
+});
+
+it.each(["display-p3", "rec2020"])("Creates %s colors in oklch", (gamut) => {
+ const colors = createColors({ gamut });
+
+ expect(colors.light.primary).toMatch(oklch);
+ expect(colors["primary-40"]).toMatch(oklch);
+ expect(colors.primary).toMatch(
+ /^light-dark\(oklch\([^)]+\), oklch\([^)]+\)\)$/,
+ );
+});
+
+it("Accepts p3 as an alias and is case insensitive", () => {
+ expect(createColors({ gamut: "p3" })).toStrictEqual(
+ createColors({ gamut: "display-p3" }),
+ );
+ expect(createColors({ gamut: "Display-P3" })).toStrictEqual(
+ createColors({ gamut: "display-p3" }),
+ );
+ expect(createColors({ "color-gamut": "p3" })).toStrictEqual(
+ createColors({ gamut: "display-p3" }),
+ );
+});
+
+it("Names the same colors in every gamut", () => {
+ const names = (options) => Object.keys(createColors(options)).sort();
+
+ expect(names({ gamut: "display-p3" })).toStrictEqual(names({}));
+ expect(names({ gamut: "rec2020" })).toStrictEqual(names({}));
+});
+
+it("Recovers chroma a wider gamut can show", () => {
+ const chroma = (value) => Number(value.split(" ")[1]);
+
+ // Vibrant asks for far more chroma than sRGB can hold, so the wider gamuts
+ // have something to give back. Each gamut contains the previous one, so
+ // chroma can only ever grow.
+ const options = { variant: "vibrant", "spec-version": "2025" };
+ const p3 = createColors({ ...options, gamut: "display-p3" });
+ const rec2020 = createColors({ ...options, gamut: "rec2020" });
+
+ expect(chroma(rec2020["primary-60"])).toBeGreaterThan(
+ chroma(p3["primary-60"]),
+ );
+});
+
+/**
+ * The luminance an `oklch()` value resolves to, on the 0..100 scale
+ * `lstarFromY` expects. Oklab's own lightness is not L*, so the only way to
+ * check a tone survived is to go back to Y.
+ * @param {string} css
+ * @returns {number}
+ */
+function yFromOklch(css) {
+ const [lightness, chroma, hue] = css.slice(6, -1).split(" ").map(Number);
+ const a = chroma * Math.cos((hue * Math.PI) / 180);
+ const b = chroma * Math.sin((hue * Math.PI) / 180);
+
+ const long = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3;
+ const medium = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3;
+ const short = (lightness - 0.0894841775 * a - 1.29148555 * b) ** 3;
+
+ const red =
+ 4.0767416621 * long - 3.3077115913 * medium + 0.2309699292 * short;
+ const green =
+ -1.2684380046 * long + 2.6097574011 * medium - 0.3413193965 * short;
+ const blue =
+ -0.0041960863 * long - 0.7034186147 * medium + 1.707614701 * short;
+
+ return (0.2126 * red + 0.7152 * green + 0.0722 * blue) * 100;
+}
+
+it("Keeps the tone, and so the contrast, of every color", async () => {
+ // Material expresses its contrast guarantees in tone, so a wider gamut must
+ // only ever spend chroma. Tone is the one thing it may not touch.
+ const { Hct, argbFromHex, lstarFromY } =
+ await import("@material/material-color-utilities");
+ const { oklchFromHct } = await import("./color.js");
+
+ const hue = Hct.fromInt(argbFromHex("#0c1445")).hue;
+
+ for (const gamut of ["srgb", "display-p3", "rec2020"]) {
+ for (const tone of [10, 30, 50, 70, 90]) {
+ // Chroma well past what any gamut holds, so the fit has to give some up
+ const tail = oklchFromHct(hue, 120, tone, gamut);
+
+ expect(lstarFromY(yFromOklch(tail))).toBeCloseTo(tone, 2);
+ }
+ }
+});
+
+it("Rejects an unknown gamut", () => {
+ expect(() => createColors({ gamut: "cmyk" })).toThrowError(
+ /not a supported gamut/,
+ );
+});
+
it("Requires a source color", () => {
// @ts-expect-error deliberately missing options
expect(() => materialTailwind().config).toThrowError(/source color/);