import { describe, expect, it } from 'vitest'; import { BRAND_OLIVE, BRAND_OLIVE_FILL, BRAND_OLIVE_MIX, BRAND_YELLOW } from './brand'; /** * brand.test — Kalibrierung des abgeleiteten Olivtons der vier * achsenparallelen Kacheln (quick-260917-jdf). * * Rechnet sRGB <-> OKLab nach CSS Color 4 (Matrizen von Bjoern Ottosson, wie * sie Browser fuer `color-mix(in oklab, ...)` verwenden) und bildet damit * exakt nach, was `BRAND_OLIVE_FILL` als CSS-Ausdruck im Browser bewirkt. * Dieselbe Rechnung, dieselben Matrixzahlen wie in der Referenzrechnung des * Planers (`.planning/quick/260917-jdf-.../260917-jdf-oklab-kalibrierung.cjs`), * hier als typisierte lokale Funktionen -- kein neues Modul unter src, die * Rechnung wird ausschliesslich hier gebraucht. */ type Rgb = [number, number, number]; type Lab = [number, number, number]; function hexToRgb(hex: string): Rgb { const h = hex.replace('#', ''); const [r, g, b] = [0, 2, 4].map((i) => Number.parseInt(h.slice(i, i + 2), 16) / 255); return [r, g, b]; } function rgbToHex(rgb: Rgb): string { return `#${rgb .map((v) => Math.round(Math.min(1, Math.max(0, v)) * 255).toString(16).padStart(2, '0')) .join('')}`; } function srgbToLinear(c: number): number { return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; } function linearToSrgb(c: number): number { return c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055; } /** sRGB (0..1 je Kanal) -> OKLab [L, a, b]. */ function rgbToOklab([r, g, b]: Rgb): Lab { const R = srgbToLinear(r); const G = srgbToLinear(g); const B = srgbToLinear(b); const l = Math.cbrt(0.4122214708 * R + 0.5363325363 * G + 0.0514459929 * B); const m = Math.cbrt(0.2119034982 * R + 0.6806995451 * G + 0.1073969566 * B); const s = Math.cbrt(0.0883024619 * R + 0.2817188376 * G + 0.6299787005 * B); return [ 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s, ]; } /** OKLab [L, a, b] -> sRGB (0..1 je Kanal, ungeclippt). */ function oklabToRgb([L, a, b]: Lab): Rgb { const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3; const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3; const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3; return [ linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s), ]; } function lchToLab([L, C, h]: [number, number, number]): Lab { const r = (h * Math.PI) / 180; return [L, C * Math.cos(r), C * Math.sin(r)]; } /** Entspricht `color-mix(in oklab, A share%, B)`. */ function mixInOklabLab(labA: Lab, share: number, hexB: string): string { const labB = rgbToOklab(hexToRgb(hexB)); const w = share / 100; const mixed: Lab = [ w * labA[0] + (1 - w) * labB[0], w * labA[1] + (1 - w) * labB[1], w * labA[2] + (1 - w) * labB[2], ]; return rgbToHex(oklabToRgb(mixed)); } function mixInOklab(hexA: string, share: number, hexB: string): string { return mixInOklabLab(rgbToOklab(hexToRgb(hexA)), share, hexB); } const rgb255 = (hex: string) => hexToRgb(hex).map((v) => Math.round(v * 255)); /** Groesste Kanalabweichung (0..255) zwischen zwei Hex-Farben. */ function maxChannelDiff(hexA: string, hexB: string): number { const a = rgb255(hexA); const b = rgb255(hexB); return Math.max(...a.map((v, i) => Math.abs(v - b[i]))); } describe('Markenfarben — abgeleiteter Olivton (BRAND_OLIVE_MIX)', () => { it('mixInOklab(BRAND_YELLOW, BRAND_OLIVE_MIX) trifft BRAND_OLIVE auf hoechstens 2 Einheiten je Kanal (erwartet 0/0/0, #9c9440)', () => { const result = mixInOklab(BRAND_YELLOW, BRAND_OLIVE_MIX.primaryShare, BRAND_OLIVE_MIX.mixWith); expect(result).toBe('#9c9440'); expect(maxChannelDiff(result, BRAND_OLIVE)).toBeLessThanOrEqual(2); }); it('BRAND_OLIVE_FILL matcht das color-mix-Muster und traegt genau die Zahlen aus BRAND_OLIVE_MIX', () => { const match = BRAND_OLIVE_FILL.match( /^color-mix\(in oklab, var\(--primary, (#[0-9a-f]{6})\) (\d+)%, (#[0-9a-f]{6})\)$/, ); expect(match).not.toBeNull(); const [, primaryFallback, share, mixWith] = match as RegExpMatchArray; expect(primaryFallback).toBe(BRAND_YELLOW); expect(Number(share)).toBe(BRAND_OLIVE_MIX.primaryShare); expect(mixWith).toBe(BRAND_OLIVE_MIX.mixWith); }); it('das Mischgrau ist in OKLab neutral — der Farbton der Akzentfarbe bleibt erhalten', () => { const [, a, b] = rgbToOklab(hexToRgb(BRAND_OLIVE_MIX.mixWith)); expect(Math.abs(a)).toBeLessThan(1e-4); expect(Math.abs(b)).toBeLessThan(1e-4); }); it('CSS-Standardwert von --primary (oklch(0.91 0.19 102), globals.css) liegt hoechstens 4 Einheiten je Kanal neben BRAND_OLIVE (erwartet #9a903f)', () => { // Deckt sich mit dem :root-Standard von `--primary` in globals.css Z. 66; // das des Plans sichert, dass globals.css ihn noch enthaelt. const cssDefaultPrimary = lchToLab([0.91, 0.19, 102]); const result = mixInOklabLab(cssDefaultPrimary, BRAND_OLIVE_MIX.primaryShare, BRAND_OLIVE_MIX.mixWith); expect(result).toBe('#9a903f'); expect(maxChannelDiff(result, BRAND_OLIVE)).toBeLessThanOrEqual(4); }); describe('Nebenpruefung Helligkeit — OKLab-L des Ergebnisses gegen L der Quelle', () => { it('#0057b8 wird dunkler (erwartet #284a7b)', () => { const lab = rgbToOklab(hexToRgb('#0057b8')); const result = mixInOklabLab(lab, BRAND_OLIVE_MIX.primaryShare, BRAND_OLIVE_MIX.mixWith); expect(result).toBe('#284a7b'); const [resultL] = rgbToOklab(hexToRgb(result)); expect(resultL).toBeLessThan(lab[0]); }); it('#ffffff wird dunkler und bleibt neutral (erwartet #9c9c9c)', () => { const lab = rgbToOklab(hexToRgb('#ffffff')); const result = mixInOklabLab(lab, BRAND_OLIVE_MIX.primaryShare, BRAND_OLIVE_MIX.mixWith); expect(result).toBe('#9c9c9c'); const [resultL, resultA, resultB] = rgbToOklab(hexToRgb(result)); expect(resultL).toBeLessThan(lab[0]); expect(Math.abs(resultA)).toBeLessThan(1e-3); expect(Math.abs(resultB)).toBeLessThan(1e-3); }); it('#000000 wird HELLER statt dunkler — dokumentiertes Kippen bei sehr dunklen Akzentfarben (erwartet #0c0c0c)', () => { const lab = rgbToOklab(hexToRgb('#000000')); const result = mixInOklabLab(lab, BRAND_OLIVE_MIX.primaryShare, BRAND_OLIVE_MIX.mixWith); expect(result).toBe('#0c0c0c'); const [resultL] = rgbToOklab(hexToRgb(result)); expect(resultL).toBeGreaterThan(lab[0]); }); }); });