alouette
Version:
A modern, customizable design system built on top of NativeWind v5 with configurable defaults
128 lines (115 loc) • 4.68 kB
text/typescript
/* eslint-disable import-x/extensions */
// OKLCH color-scale generator. Palettes are generated in OKLCH so a single
// lightness ramp is perceptually uniform across every hue (HSL lightness is not
// — yellow at HSL L=56 reads far brighter than blue at the same L). Chroma is
// specified *relative to the target gamut*: each step requests a fraction of the
// maximum chroma that gamut can render at that step's lightness and hue. The
// fraction curve is shared by every palette and tiered by usage (muted surface
// tints, medium highlight mids, near-vivid interactive and text steps), so all
// hues are equally saturated relative to what's possible — no per-palette chroma
// table. The same ramp generated against display-p3 instead of sRGB is what the
// `oklch()` palette ships to web.
import Color from "colorjs.io";
import type { PaletteSpec } from "./paletteSpecs.ts";
import type { Mode, ScaleNum } from "./tokenScaleMap.ts";
export interface OklchColor {
lightness: number;
chroma: number;
hue: number;
}
export type ColorScale = Record<ScaleNum, string>;
export type OklchScale = Record<ScaleNum, OklchColor>;
// step: 1 2 3 4 5 6 7 8 9 10 11
const lightnessRamps = {
grayscale: {
dark: [0.18, 0.24, 0.28, 0.32, 0.36, 0.4, 0.45, 0.795, 0.865, 0.96, 1],
// only diff is on the first 2 values
light: [1, 0.98, 0.948, 0.89, 0.85, 0.79, 0.61, 0.54, 0.48, 0.42, 0.27],
},
accent: {
dark: [0.18, 0.22, 0.26, 0.3, 0.34, 0.4, 0.5, 0.7, 0.82, 0.865, 0.955],
light: [
0.988, 0.968, 0.948, 0.89, 0.85, 0.79, 0.62, 0.54, 0.48, 0.42, 0.27,
],
},
brightAccent: {
dark: [0.28, 0.32, 0.4, 0.45, 0.5, 0.53, 0.56, 0.8, 0.86, 0.9, 0.955],
light: [0.988, 0.968, 0.948, 0.928, 0.89, 0.82, 0.8, 0.6, 0.56, 0.44, 0.27],
},
} as const;
// Fraction of the max in-gamut chroma requested at each step. Usage tiers
// mirror the old HSL generator's 56/82/96 saturation bands: steps 1-3 pale
// surfaces, 4-6 highlight/message tints, 7-11 interactive fills and text.
const relativeChromaCurve: Record<Mode, number[]> = {
dark: [0.6, 0.68, 0.72, 0.76, 0.78, 0.84, 0.88, 0.88, 0.85, 0.82, 0.86],
light: [0.5, 0.52, 0.55, 0.6, 0.75, 0.78, 0.92, 0.97, 0.97, 0.97, 0.95],
};
/**
* Which gamut the chroma budget is measured against. `srgb` is what every
* platform can render (and what the hex output is mapped into); `p3` gives the
* same lightness ramp more chroma headroom, used by the `oklch()` output that
* only web consumes.
*/
export type Gamut = "p3" | "srgb";
interface MaxChromaParams {
lightness: number;
hue: number;
gamut: Gamut;
}
// Largest OKLCH chroma still inside the given gamut at this lightness/hue.
export const maxChroma = ({
lightness,
hue,
gamut,
}: MaxChromaParams): number => {
const space = gamut === "p3" ? "p3" : "srgb";
let low = 0;
let high = 0.5;
for (let i = 0; i < 20; i++) {
const mid = (low + high) / 2;
if (new Color("oklch", [lightness, mid, hue]).to(space).inGamut()) {
low = mid;
} else {
high = mid;
}
}
return low;
};
export const maxSrgbChroma = (lightness: number, hue: number): number =>
maxChroma({ lightness, hue, gamut: "srgb" });
export const toHex = ({ lightness, chroma, hue }: OklchColor): string => {
const color = new Color("oklch", [lightness, chroma, hue]).to("srgb");
const hex = color.toGamut({ method: "css" }).toString({ format: "hex" });
// colorjs collapses to 3-digit shorthand (#050); expand so appended alpha
// suffixes (e.g. selection = step + "40") stay valid 8-digit hex.
const full =
hex.length === 4 ? hex.replace(/^#(.)(.)(.)$/, "#$1$1$2$2$3$3") : hex;
return full.toUpperCase();
};
export const createOklchScale = (
spec: PaletteSpec,
mode: Mode,
gamut: Gamut,
): OklchScale => {
const hueHi = spec.hueHi ?? spec.hue;
const hueLo = spec.hueLo ?? spec.hue;
const intensity = spec.intensity ?? 1;
const ramp = lightnessRamps[spec.type][mode];
const steps = ramp.map((lightness, index): OklchColor => {
const hue = hueLo + (hueHi - hueLo) * lightness;
const chroma =
relativeChromaCurve[mode][index]! *
intensity *
maxChroma({ lightness, hue, gamut });
return { lightness, chroma, hue };
});
return Object.fromEntries(
steps.map((color, index) => [index + 1, color]),
) as unknown as OklchScale;
};
export const createColorScale = (spec: PaletteSpec, mode: Mode): ColorScale => {
const scale = createOklchScale(spec, mode, "srgb");
return Object.fromEntries(
Object.entries(scale).map(([step, color]) => [step, toHex(color)]),
) as unknown as ColorScale;
};