Files
tessera-ctl/.planning/quick/260917-jdf-bildmarke-ganzes-t-uebernimmt-die-akzent/260917-jdf-oklab-kalibrierung.cjs
T
schalli 5a444ec8f2
Tessera CI/CD / Lint & Type Check (push) Successful in 47s
Tessera CI/CD / Tests (push) Successful in 1m5s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 5m21s
Tessera CI/CD / Build & Publish Images (push) Successful in 4m20s
docs(quick-260917-jdf/jdh/jdd/jn2): Aktenstand — Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse
Vier Quick-Tasks mit Plan, Bericht und Verifikation; Browser-Nachweis der
Web-Teile lokal erbracht (Kacheln #284a7b bei #0057b8, Proxy-Symbol trotz
Zertifikatsfehler, Direktbild bei interner Adresse, Sortierung ueber Reload).
Offen: CI-Beweis des Desktop-Skips nach diesem Push, Windows-VM-Probe der
Client-Aenderungen mit dem CI-Paket.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:10:20 +02:00

122 lines
5.0 KiB
JavaScript

#!/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('/')})`);
}