5a444ec8f2
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>
122 lines
5.0 KiB
JavaScript
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('/')})`);
|
|
}
|