#!/usr/bin/env node /** * Referenzrechnung des Planers fuer quick-260917-jdf (Bildmarke: ganzes T in * Akzentfarbe). Keine Abhaengigkeiten. Aufruf: * * node .planning/quick/260917-jdf-bildmarke-ganzes-t-uebernimmt-die-akzent/260917-jdf-oklab-kalibrierung.cjs * * Rechnet sRGB <-> OKLab nach CSS Color 4 (Matrizen von Bjoern Ottosson, wie * sie Browser fuer `color-mix(in oklab, ...)` verwenden) und bestimmt die * Mischparameter, mit denen `color-mix(in oklab, #ffed00 P%, #GRAU)` den * Olivton #9c9440 trifft. Dieselben zwei Umrechnungsfunktionen (rgbToOklab, * oklabToRgb) uebernimmt der Executor 1:1 in apps/web/src/components/brand/brand.test.ts. * * Ergebnis (Stand 2026-09-17): P = 54, GRAU = #363636 -> #9c9440 exakt (0/0/0). */ function hexToRgb(hex) { const h = hex.replace('#', ''); return [0, 2, 4].map((i) => Number.parseInt(h.slice(i, i + 2), 16) / 255); } function rgbToHex(rgb) { return `#${rgb .map((v) => Math.round(Math.min(1, Math.max(0, v)) * 255).toString(16).padStart(2, '0')) .join('')}`; } function srgbToLinear(c) { return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; } function linearToSrgb(c) { 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]) { 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]) { 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]) { const r = (h * Math.PI) / 180; return [L, C * Math.cos(r), C * Math.sin(r)]; } function labToLch([L, a, b]) { let h = (Math.atan2(b, a) * 180) / Math.PI; if (h < 0) h += 360; return [L, Math.hypot(a, b), h]; } /** Entspricht `color-mix(in oklab, A share%, B)`. */ function mixInOklabLab(labA, share, hexB) { const labB = rgbToOklab(hexToRgb(hexB)); const w = share / 100; return rgbToHex(oklabToRgb(labA.map((v, i) => w * v + (1 - w) * labB[i]))); } const mixInOklab = (hexA, share, hexB) => mixInOklabLab(rgbToOklab(hexToRgb(hexA)), share, hexB); const rgb255 = (hex) => hexToRgb(hex).map((v) => Math.round(v * 255)); const channelDiff = (h1, h2) => rgb255(h1).map((v, i) => v - rgb255(h2)[i]); const BRAND_YELLOW = '#ffed00'; const BRAND_OLIVE = '#9c9440'; console.log('OKLCH der Markenfarben:'); for (const hex of [BRAND_YELLOW, BRAND_OLIVE]) { const [L, C, H] = labToLch(rgbToOklab(hexToRgb(hex))); console.log(` ${hex} L=${L.toFixed(4)} C=${C.toFixed(4)} H=${H.toFixed(2)}`); } console.log('\nSuche: ganzzahliger Anteil 40..70 %, neutrales Grau #101010..#606060:'); const candidates = []; for (let share = 40; share <= 70; share++) { for (let g = 16; g <= 96; g++) { const gray = `#${g.toString(16).padStart(2, '0').repeat(3)}`; const result = mixInOklab(BRAND_YELLOW, share, gray); const err = Math.max(...channelDiff(result, BRAND_OLIVE).map(Math.abs)); candidates.push({ share, gray, result, err }); } } candidates.sort((a, b) => a.err - b.err || a.share - b.share); for (const c of candidates.slice(0, 5)) { console.log(` ${c.share}% ${c.gray} -> ${c.result} max. Abweichung ${c.err}`); } const SHARE = 54; const GRAY = '#363636'; console.log(`\nGewaehlt: color-mix(in oklab, var(--primary) ${SHARE}%, ${GRAY})`); const [, ga, gb] = rgbToOklab(hexToRgb(GRAY)); console.log(` Mischgrau in OKLab: a=${ga.toExponential(2)} b=${gb.toExponential(2)} (neutral -> Farbton der Akzentfarbe bleibt erhalten)`); console.log('\nAbgeleiteter Ton je Akzentfarbe:'); const rows = [ ['#ffed00 (BRAND_YELLOW, Kalibrierziel)', rgbToOklab(hexToRgb('#ffed00'))], ['oklch(0.91 0.19 102) (CSS-Standard --primary, globals.css)', lchToLab([0.91, 0.19, 102])], ['#ffffff', rgbToOklab(hexToRgb('#ffffff'))], ['#000000', rgbToOklab(hexToRgb('#000000'))], ['#0057b8', rgbToOklab(hexToRgb('#0057b8'))], ['#ff0000', rgbToOklab(hexToRgb('#ff0000'))], ]; for (const [label, lab] of rows) { const result = mixInOklabLab(lab, SHARE, GRAY); const [L1] = rgbToOklab(hexToRgb(result)); const diff = channelDiff(result, BRAND_OLIVE); const trend = L1 < lab[0] ? 'dunkler' : 'HELLER (kippt)'; console.log(` ${label.padEnd(62)} -> ${result} L ${lab[0].toFixed(3)} -> ${L1.toFixed(3)} ${trend} (Abstand zu #9c9440: ${diff.join('/')})`); }