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