From ecff144449d80fb688832ff73fc7110dc32242dd Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 17 Sep 2026 14:18:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(brand):=20ganzes=20T=20der=20Bildmarke=20?= =?UTF-8?q?=C3=BCbernimmt=20die=20Akzentfarbe=20=E2=80=93=20Kacheln=20als?= =?UTF-8?q?=20abgeleiteter=20dunklerer=20Ton?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BRAND_OLIVE_MIX (54 %, #363636) und BRAND_OLIVE_FILL in brand.ts, Kalibrierung kommentiert - vier achsenparallele Kacheln in tessera-logo.tsx: Inline-Style BRAND_OLIVE_FILL, festes Oliv-Attribut als Rückfall - brand.test.ts (neu): rechnet Kalibrierung, Neutralität und Grenzfälle aus denselben Konstanten nach - tessera-logo.test.tsx: Kacheltest auf Token-Fuellung (1x) und abgeleitete Fuellung (4x) umgestellt Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/components/brand/brand.test.ts | 157 ++++++++++++++++++ apps/web/src/components/brand/brand.ts | 46 ++++- .../components/brand/tessera-logo.test.tsx | 17 +- .../web/src/components/brand/tessera-logo.tsx | 68 ++++++-- 4 files changed, 266 insertions(+), 22 deletions(-) create mode 100644 apps/web/src/components/brand/brand.test.ts diff --git a/apps/web/src/components/brand/brand.test.ts b/apps/web/src/components/brand/brand.test.ts new file mode 100644 index 0000000..d4468c7 --- /dev/null +++ b/apps/web/src/components/brand/brand.test.ts @@ -0,0 +1,157 @@ +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]); + }); + }); +}); diff --git a/apps/web/src/components/brand/brand.ts b/apps/web/src/components/brand/brand.ts index 1391a6a..87dedf7 100644 --- a/apps/web/src/components/brand/brand.ts +++ b/apps/web/src/components/brand/brand.ts @@ -5,7 +5,9 @@ * notiert wird. Jede Stelle der Oberflaeche, die eine Markenfarbe braucht * (die Bildmarken-Komponente, das Anmelde-Panel, der Vorgabewert der * persoenlichen Akzentfarbe), importiert sie von hier. Eine spaetere - * mandantenspezifische Gestaltung setzt an dieser Datei an. + * mandantenspezifische Gestaltung setzt an dieser Datei an. Auch die + * Ableitungsregel fuer den Olivton der vier achsenparallelen Kacheln lebt + * hier (BRAND_OLIVE_MIX, BRAND_OLIVE_FILL). */ /** @@ -16,8 +18,48 @@ */ export const BRAND_YELLOW = '#ffed00'; -/** Olivton der vier achsenparallelen Kacheln. */ +/** + * Olivton der vier achsenparallelen Kacheln bei Standard-Gelb. In der + * Bildmarke seit quick-260917-jdf Kalibrierziel und Rueckfall + * (Praesentationsattribut fuer Browser ohne `color-mix()`) -- die + * eigentliche Fuellung leitet BRAND_OLIVE_FILL aus der Akzentfarbe ab. + * `apps/web/src/app/icon.svg` (Favicon) und die daraus erzeugten + * Tauri-Icons verwenden den Wert weiterhin fest. + */ export const BRAND_OLIVE = '#9c9440'; /** Farbe der abgerundeten Grundplatte. */ export const BRAND_PLATE = '#1a1a1a'; + +/** + * Mischanteil der Akzentfarbe (in Prozent) und neutrales Mischgrau fuer den + * abgeleiteten Olivton der vier achsenparallelen Kacheln (quick-260917-jdf). + * + * Gesucht war `color-mix(in oklab, #ffed00 P%, #GRAU)` = `#9c9440`. Rechnung + * sRGB -> OKLab -> sRGB nach CSS Color 4 (Referenz: + * `260917-jdf-oklab-kalibrierung.cjs` im Quick-Ordner, Nachweis in + * brand.test.ts). Ergebnis: 54 % / #363636 -> `#9c9440` exakt (Abweichung + * 0/0/0 je Kanal). OKLCH-Verhaeltnis Gelb -> Oliv: L 0.931 -> 0.656, + * C 0.197 -> 0.106, Farbton 104 Grad gleich. + * + * Warum `oklab` statt `oklch`: mit neutralem Grau ist das Ergebnis + * identisch (Farbton bleibt, Chroma und L skalieren linear), aber ohne + * Abhaengigkeit von der Sonderregel fuer den Farbton achromatischer Farben + * in polaren Raeumen. + * + * Verhalten fuer andere Akzentfarben: CSS-Standard `oklch(0.91 0.19 102)` + * (globals.css) -> `#9a903f` (-2/-4/-1), #ffffff -> neutrales Grau + * `#9c9c9c`, #0057b8 -> `#284a7b`. Akzentfarben dunkler als das Mischgrau + * (OKLab-L < 0.333, z. B. Schwarz -> `#0c0c0c`) werden heller statt dunkler + * -- bewusst hingenommen, kein Schutzmechanismus. + */ +export const BRAND_OLIVE_MIX = { primaryShare: 54, mixWith: '#363636' } as const; + +/** + * CSS-Ausdruck fuer den Inline-`fill` der vier achsenparallelen Kacheln in + * tessera-logo.tsx -- die einzige Stelle, an der dieser Ausdruck gebildet + * wird. `--primary` setzt `applyAccentColor` (auth-store.ts); der + * CSS-Standardwert steht in globals.css; der `var()`-Rueckfall + * (BRAND_YELLOW) greift nur, wenn gar kein Stylesheet geladen ist. + */ +export const BRAND_OLIVE_FILL = `color-mix(in oklab, var(--primary, ${BRAND_YELLOW}) ${BRAND_OLIVE_MIX.primaryShare}%, ${BRAND_OLIVE_MIX.mixWith})`; diff --git a/apps/web/src/components/brand/tessera-logo.test.tsx b/apps/web/src/components/brand/tessera-logo.test.tsx index 1b95753..aae7cbd 100644 --- a/apps/web/src/components/brand/tessera-logo.test.tsx +++ b/apps/web/src/components/brand/tessera-logo.test.tsx @@ -1,6 +1,6 @@ import { cleanup, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { BRAND_OLIVE, BRAND_YELLOW } from './brand'; +import { BRAND_OLIVE, BRAND_OLIVE_FILL, BRAND_YELLOW } from './brand'; import { TesseraLogo } from './tessera-logo'; import de from '@/messages/de.json'; import en from '@/messages/en.json'; @@ -94,7 +94,7 @@ describe('TesseraLogo', () => { expect(plate?.getAttribute('class')).not.toContain('dark:stroke-white/25'); }); - it('renders exactly five tiles; only the rotated one is filled from the accent token with the brand yellow as fallback', () => { + it('renders exactly five tiles; the rotated one takes the accent token, the other four the derived olive tone with the fixed olive as fallback', () => { render(); const mark = screen.getByRole('img', { name: 'Tessera Logo' }); @@ -111,10 +111,15 @@ describe('TesseraLogo', () => { const presentationYellowTiles = tiles.filter((tile) => tile.getAttribute('fill') === BRAND_YELLOW); expect(presentationYellowTiles).toHaveLength(0); - const oliveTiles = tiles.filter((tile) => tile.getAttribute('fill') === BRAND_OLIVE); - expect(oliveTiles).toHaveLength(4); - for (const tile of oliveTiles) { - expect(tile.getAttribute('style')).toBeNull(); + // BRAND_OLIVE_FILL haengt selbst am --primary-Token — Zusicherung, dass + // die vier Kacheln wirklich daran haengen und nicht an einer festen Farbe. + expect(BRAND_OLIVE_FILL).toContain('var(--primary'); + + const derivedOliveTiles = tiles.filter((tile) => tile.style.fill === BRAND_OLIVE_FILL); + expect(derivedOliveTiles).toHaveLength(4); + for (const tile of derivedOliveTiles) { + expect(tile.getAttribute('fill')).toBe(BRAND_OLIVE); + expect(tile.hasAttribute('transform')).toBe(false); } }); }); diff --git a/apps/web/src/components/brand/tessera-logo.tsx b/apps/web/src/components/brand/tessera-logo.tsx index 89b49d5..db8519f 100644 --- a/apps/web/src/components/brand/tessera-logo.tsx +++ b/apps/web/src/components/brand/tessera-logo.tsx @@ -1,7 +1,7 @@ 'use client'; import { useTranslations } from 'next-intl'; -import { BRAND_OLIVE, BRAND_PLATE, BRAND_YELLOW } from './brand'; +import { BRAND_OLIVE, BRAND_OLIVE_FILL, BRAND_PLATE, BRAND_YELLOW } from './brand'; export type TesseraLogoVariant = 'mark' | 'horizontal'; @@ -66,16 +66,39 @@ function LogoMark({ : 'stroke-transparent' } /> + {/* + Alle fuenf Kacheln folgen `--primary`: die gedrehte Signalkachel + direkt, die vier anderen als abgeleiteter dunklerer Ton + (BRAND_OLIVE_FILL, Kalibrierung in brand.ts). `var()`/`color-mix()` + sind in SVG-Praesentationsattributen nicht zuverlaessig, im + Inline-Style schon -- deshalb tragen alle fuenf Kacheln ihre + Akzentfarben-Fuellung als Inline-Style. Der feste Olivton bleibt an + den vier Kacheln zusaetzlich als Praesentationsattribut stehen: ein + Browser ohne `color-mix()`-Unterstuetzung verwirft die + Inline-Deklaration, und ohne dieses Attribut waere die Kachel + schwarz (SVG-Standardfuellung). `--primary` wird von + applyAccentColor() in auth-store.ts gesetzt und hat in globals.css + immer einen Standardwert (oklch(0.91 0.19 102), Markengelb). + */} - - - {/* - `var()` ist in SVG-Praesentationsattributen (fill="...") nicht - zuverlaessig, im Inline-Style schon — deshalb hier style statt fill. - `--primary` wird von applyAccentColor() in auth-store.ts gesetzt; - ohne angemeldeten Nutzer (z. B. Anmeldeseite) ist der Token nicht - definiert und der Rueckfall in var() greift: BRAND_YELLOW. - */} + + - - + + ); @@ -98,8 +137,9 @@ function LogoMark({ * mandantenspezifische Gestaltung setzt hier an, nicht an den einzelnen * Verwendungsstellen. * - * Die gedrehte Signalkachel der Bildmarke folgt der persoenlichen - * Akzentfarbe (CSS-Token `--primary`, siehe LogoMark). + * Die gesamte Bildmarke folgt der persoenlichen Akzentfarbe (CSS-Token + * `--primary`, siehe LogoMark): die gedrehte Signalkachel direkt, die vier + * anderen als daraus abgeleiteter dunklerer Ton (siehe brand.ts). */ export function TesseraLogo({ variant = 'horizontal',