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/);