import { converter, formatHex, parse } from "culori"; export const TAILWIND_BENCHMARK_VERSION = "1.0.0"; export const TAILWIND_BENCHMARK_PROTOCOL = "scale-generation-protocol-1.0.0"; export const TAILWIND_BENCHMARK_METRICS = "scale-accessibility-metrics-1.0.0"; export const TAILWIND_BENCHMARK_RUNNER = "color-pick-tailwind-runner-1.0.0"; export const BENCHMARK_SHADE_STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const; export type BenchmarkShade = (typeof BENCHMARK_SHADE_STEPS)[number]; export type BenchmarkMethod = "hsl" | "srgb" | "linear-srgb" | "oklch"; export const BENCHMARK_METHODS: readonly BenchmarkMethod[] = ["hsl", "srgb", "linear-srgb", "oklch"]; export const BENCHMARK_METHOD_LABELS: Record = { hsl: "HSL interpolation", srgb: "sRGB interpolation", "linear-srgb": "Linear-sRGB interpolation", oklch: "OKLCH interpolation", }; export const BENCHMARK_MIX_WEIGHTS: Record = { 50: { target: "white", amount: 0.94 }, 100: { target: "white", amount: 0.86 }, 200: { target: "white", amount: 0.70 }, 300: { target: "white", amount: 0.50 }, 400: { target: "white", amount: 0.25 }, 500: { target: "seed", amount: 0 }, 600: { target: "black", amount: 0.12 }, 700: { target: "black", amount: 0.26 }, 800: { target: "black", amount: 0.43 }, 900: { target: "black", amount: 0.60 }, 950: { target: "black", amount: 0.75 }, }; export interface BenchmarkSeed { id: string; hex: string; name?: string; family?: string; sourceCode?: string; cohort?: "reference" | "holdout" | "control" | "user"; } export interface ShadeMeasurement { shade: BenchmarkShade; hex: string; oklch: { l: number; c: number; h: number }; contrastOnWhite: number; contrastOnBlack: number; gamutMapped: boolean; } export interface TailwindMethodResult { method: BenchmarkMethod; label: string; shades: ShadeMeasurement[]; metrics: { adjacentDeltaE: number[]; meanAdjacentDeltaE: number; standardDeviation: number; coefficientOfVariation: number; monotonicLightness: boolean; lightnessViolations: number; maximumHueDrift: number; meanHueDrift: number; gamutMappedShades: number; bodyTextOnWhiteShades: number; bodyTextOnBlackShades: number; largeTextOnWhiteShades: number; largeTextOnBlackShades: number; adjacentPairsAtThreeToOne: number; whiteTextActionCandidates: number; blackTextActionCandidates: number; }; } export interface TailwindSeedResult { seed: BenchmarkSeed; versions: { benchmark: string; protocol: string; metrics: string; runner: string; }; methods: TailwindMethodResult[]; } type Rgb = { r: number; g: number; b: number }; type Hsl = { h: number; s: number; l: number }; type Oklch = { l: number; c: number; h: number }; const toOklab = converter("oklab"); const toOklch = converter("oklch"); const toRgb = converter("rgb"); const clamp = (value: number, min = 0, max = 1) => Math.min(max, Math.max(min, value)); const round = (value: number, digits = 6) => Number(value.toFixed(digits)); export function normalizeBenchmarkHex(value: string): string | null { const raw = value.trim().replace(/^#/, ""); if (/^[0-9a-f]{3}$/i.test(raw)) return `#${raw.split("").map((character) => `${character}${character}`).join("")}`.toUpperCase(); if (/^[0-9a-f]{6}$/i.test(raw)) return `#${raw}`.toUpperCase(); return null; } function hexToRgb(hex: string): Rgb { const normalized = normalizeBenchmarkHex(hex) ?? "#2563EB"; return { r: Number.parseInt(normalized.slice(1, 3), 16) / 255, g: Number.parseInt(normalized.slice(3, 5), 16) / 255, b: Number.parseInt(normalized.slice(5, 7), 16) / 255, }; } function rgbToHex(rgb: Rgb): string { const channel = (value: number) => Math.round(clamp(value) * 255).toString(16).padStart(2, "0"); return `#${channel(rgb.r)}${channel(rgb.g)}${channel(rgb.b)}`.toUpperCase(); } function rgbToHsl(rgb: Rgb): Hsl { const max = Math.max(rgb.r, rgb.g, rgb.b); const min = Math.min(rgb.r, rgb.g, rgb.b); const difference = max - min; const lightness = (max + min) / 2; if (difference === 0) return { h: 0, s: 0, l: lightness }; const saturation = difference / (1 - Math.abs(2 * lightness - 1)); let hue = 0; if (max === rgb.r) hue = 60 * (((rgb.g - rgb.b) / difference) % 6); else if (max === rgb.g) hue = 60 * (((rgb.b - rgb.r) / difference) + 2); else hue = 60 * (((rgb.r - rgb.g) / difference) + 4); return { h: (hue + 360) % 360, s: saturation, l: lightness }; } function hslToRgb(hsl: Hsl): Rgb { const hue = ((hsl.h % 360) + 360) % 360; const chroma = (1 - Math.abs(2 * hsl.l - 1)) * hsl.s; const x = chroma * (1 - Math.abs(((hue / 60) % 2) - 1)); const match = hsl.l - chroma / 2; let channels: [number, number, number]; if (hue < 60) channels = [chroma, x, 0]; else if (hue < 120) channels = [x, chroma, 0]; else if (hue < 180) channels = [0, chroma, x]; else if (hue < 240) channels = [0, x, chroma]; else if (hue < 300) channels = [x, 0, chroma]; else channels = [chroma, 0, x]; return { r: channels[0] + match, g: channels[1] + match, b: channels[2] + match }; } function srgbToLinear(channel: number) { return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; } function linearToSrgb(channel: number) { return channel <= 0.0031308 ? 12.92 * channel : 1.055 * channel ** (1 / 2.4) - 0.055; } function mixRgb(seed: Rgb, target: "white" | "black", amount: number, linear: boolean): Rgb { const endpoint = target === "white" ? 1 : 0; if (!linear) { return { r: seed.r + (endpoint - seed.r) * amount, g: seed.g + (endpoint - seed.g) * amount, b: seed.b + (endpoint - seed.b) * amount, }; } return { r: linearToSrgb(srgbToLinear(seed.r) + (endpoint - srgbToLinear(seed.r)) * amount), g: linearToSrgb(srgbToLinear(seed.g) + (endpoint - srgbToLinear(seed.g)) * amount), b: linearToSrgb(srgbToLinear(seed.b) + (endpoint - srgbToLinear(seed.b)) * amount), }; } function isInSrgb(color: Oklch) { const rgb = toRgb({ mode: "oklch", ...color }); return Boolean(rgb && [rgb.r, rgb.g, rgb.b].every((value) => typeof value === "number" && value >= -1e-7 && value <= 1 + 1e-7)); } function mapOklchToSrgb(color: Oklch): { color: Oklch; mapped: boolean } { const normalized = { l: clamp(color.l), c: Math.max(0, color.c), h: ((color.h % 360) + 360) % 360 }; if (isInSrgb(normalized)) return { color: normalized, mapped: false }; let low = 0; let high = normalized.c; for (let index = 0; index < 26; index += 1) { const candidate = { ...normalized, c: (low + high) / 2 }; if (isInSrgb(candidate)) low = candidate.c; else high = candidate.c; } return { color: { ...normalized, c: low }, mapped: true }; } function oklchToHex(color: Oklch) { return (formatHex({ mode: "oklch", ...color }) ?? "#2563EB").toUpperCase(); } function hexToOklch(hex: string): Oklch { const color = toOklch(parse(hex)); return { l: color?.l ?? 0, c: color?.c ?? 0, h: ((color?.h ?? 0) + 360) % 360, }; } function generateShade(seedHex: string, method: BenchmarkMethod, shade: BenchmarkShade): { hex: string; mapped: boolean } { const instruction = BENCHMARK_MIX_WEIGHTS[shade]; if (instruction.target === "seed") return { hex: seedHex, mapped: false }; if (method === "srgb" || method === "linear-srgb") { return { hex: rgbToHex(mixRgb(hexToRgb(seedHex), instruction.target, instruction.amount, method === "linear-srgb")), mapped: false }; } if (method === "hsl") { const seed = rgbToHsl(hexToRgb(seedHex)); const targetLightness = instruction.target === "white" ? 1 : 0; return { hex: rgbToHex(hslToRgb({ h: seed.h, s: seed.s * (1 - instruction.amount), l: seed.l + (targetLightness - seed.l) * instruction.amount, })), mapped: false, }; } const seed = hexToOklch(seedHex); const targetLightness = instruction.target === "white" ? 1 : 0; const mapped = mapOklchToSrgb({ l: seed.l + (targetLightness - seed.l) * instruction.amount, c: seed.c * (1 - instruction.amount), h: seed.h, }); return { hex: oklchToHex(mapped.color), mapped: mapped.mapped }; } function relativeLuminance(hex: string) { const rgb = hexToRgb(hex); return 0.2126 * srgbToLinear(rgb.r) + 0.7152 * srgbToLinear(rgb.g) + 0.0722 * srgbToLinear(rgb.b); } export function benchmarkContrastRatio(first: string, second: string) { const a = relativeLuminance(first); const b = relativeLuminance(second); return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); } function deltaEOK(first: string, second: string) { const a = toOklab(parse(first)); const b = toOklab(parse(second)); if (!a || !b) return 0; return Math.sqrt((a.l - b.l) ** 2 + ((a.a ?? 0) - (b.a ?? 0)) ** 2 + ((a.b ?? 0) - (b.b ?? 0)) ** 2) * 100; } function circularHueDifference(first: number, second: number) { const difference = Math.abs(first - second) % 360; return Math.min(difference, 360 - difference); } function mean(values: number[]) { return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0; } function standardDeviation(values: number[]) { const average = mean(values); return Math.sqrt(mean(values.map((value) => (value - average) ** 2))); } export function benchmarkScaleMethod(seedHex: string, method: BenchmarkMethod): TailwindMethodResult { const normalizedSeed = normalizeBenchmarkHex(seedHex) ?? "#2563EB"; const seedOklch = hexToOklch(normalizedSeed); const shades = BENCHMARK_SHADE_STEPS.map((shade): ShadeMeasurement => { const generated = generateShade(normalizedSeed, method, shade); const oklch = hexToOklch(generated.hex); return { shade, hex: generated.hex, oklch: { l: round(oklch.l), c: round(oklch.c), h: round(oklch.h, 3) }, contrastOnWhite: round(benchmarkContrastRatio(generated.hex, "#FFFFFF"), 4), contrastOnBlack: round(benchmarkContrastRatio(generated.hex, "#000000"), 4), gamutMapped: generated.mapped, }; }); const adjacentDeltaE = shades.slice(1).map((shade, index) => round(deltaEOK(shades[index].hex, shade.hex), 4)); const averageDeltaE = mean(adjacentDeltaE); const deviation = standardDeviation(adjacentDeltaE); const lightnessViolations = shades.slice(1).filter((shade, index) => shade.oklch.l > shades[index].oklch.l + 0.0005).length; const hueDrifts = shades .filter((shade) => shade.oklch.c >= 0.02 && seedOklch.c >= 0.02) .map((shade) => circularHueDifference(shade.oklch.h, seedOklch.h)); const actionShades = shades.filter((shade) => shade.shade === 500 || shade.shade === 600 || shade.shade === 700); return { method, label: BENCHMARK_METHOD_LABELS[method], shades, metrics: { adjacentDeltaE, meanAdjacentDeltaE: round(averageDeltaE, 4), standardDeviation: round(deviation, 4), coefficientOfVariation: round(averageDeltaE ? deviation / averageDeltaE : 0, 6), monotonicLightness: lightnessViolations === 0, lightnessViolations, maximumHueDrift: round(hueDrifts.length ? Math.max(...hueDrifts) : 0, 3), meanHueDrift: round(mean(hueDrifts), 3), gamutMappedShades: shades.filter((shade) => shade.gamutMapped).length, bodyTextOnWhiteShades: shades.filter((shade) => shade.contrastOnWhite >= 4.5).length, bodyTextOnBlackShades: shades.filter((shade) => shade.contrastOnBlack >= 4.5).length, largeTextOnWhiteShades: shades.filter((shade) => shade.contrastOnWhite >= 3).length, largeTextOnBlackShades: shades.filter((shade) => shade.contrastOnBlack >= 3).length, adjacentPairsAtThreeToOne: shades.slice(1).filter((shade, index) => benchmarkContrastRatio(shade.hex, shades[index].hex) >= 3).length, whiteTextActionCandidates: actionShades.filter((shade) => shade.contrastOnWhite >= 4.5).length, blackTextActionCandidates: actionShades.filter((shade) => shade.contrastOnBlack >= 4.5).length, }, }; } export function runTailwindSeedBenchmark(seed: BenchmarkSeed, methods: readonly BenchmarkMethod[] = BENCHMARK_METHODS): TailwindSeedResult { const normalized = normalizeBenchmarkHex(seed.hex); if (!normalized) throw new Error(`Invalid benchmark seed: ${seed.hex}`); return { seed: { ...seed, hex: normalized }, versions: { benchmark: TAILWIND_BENCHMARK_VERSION, protocol: TAILWIND_BENCHMARK_PROTOCOL, metrics: TAILWIND_BENCHMARK_METRICS, runner: TAILWIND_BENCHMARK_RUNNER, }, methods: methods.map((method) => benchmarkScaleMethod(normalized, method)), }; } export function summarizeMethodResults(results: TailwindSeedResult[]) { return BENCHMARK_METHODS.flatMap((method) => { const rows = results.map((result) => result.methods.find((entry) => entry.method === method)).filter((entry): entry is TailwindMethodResult => Boolean(entry)); if (!rows.length) return []; const variation = rows.map((row) => row.metrics.coefficientOfVariation).sort((a, b) => a - b); const hueDrift = rows.map((row) => row.metrics.maximumHueDrift).sort((a, b) => a - b); const median = (values: number[]) => values.length % 2 ? values[(values.length - 1) / 2] : (values[values.length / 2 - 1] + values[values.length / 2]) / 2; const wins = results.filter((result) => { const minimum = Math.min(...result.methods.map((entry) => entry.metrics.coefficientOfVariation)); return result.methods.find((entry) => entry.method === method)?.metrics.coefficientOfVariation === minimum; }).length; return [{ method, label: BENCHMARK_METHOD_LABELS[method], sampleSize: rows.length, meanStepVariation: round(mean(variation), 6), medianStepVariation: round(median(variation), 6), worstStepVariation: round(Math.max(...variation), 6), uniformityWins: wins, monotonicScales: rows.filter((row) => row.metrics.monotonicLightness).length, medianMaximumHueDrift: round(median(hueDrift), 3), mappedShadeCount: rows.reduce((sum, row) => sum + row.metrics.gamutMappedShades, 0), bodyTextOnWhiteShadeCount: rows.reduce((sum, row) => sum + row.metrics.bodyTextOnWhiteShades, 0), bodyTextOnBlackShadeCount: rows.reduce((sum, row) => sum + row.metrics.bodyTextOnBlackShades, 0), whiteTextActionCandidateCount: rows.reduce((sum, row) => sum + row.metrics.whiteTextActionCandidates, 0), blackTextActionCandidateCount: rows.reduce((sum, row) => sum + row.metrics.blackTextActionCandidates, 0), }]; }); }