feat(brand): ganzes T der Bildmarke übernimmt die Akzentfarbe – Kacheln als abgeleiteter dunklerer Ton

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 14:18:58 +02:00
parent 38c14005c6
commit ecff144449
4 changed files with 266 additions and 22 deletions
+157
View File
@@ -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 <verify> 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]);
});
});
});
+44 -2
View File
@@ -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})`;
@@ -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(<TesseraLogo variant="mark" />);
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);
}
});
});
+53 -13
View File
@@ -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'
}
/>
<g>
<rect x="16" y="16" width="12" height="12" rx="2.5" fill={BRAND_OLIVE} />
<rect x="30" y="16" width="12" height="12" rx="2.5" fill={BRAND_OLIVE} />
{/*
`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.
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).
*/}
<g>
<rect
x="16"
y="16"
width="12"
height="12"
rx="2.5"
fill={BRAND_OLIVE}
style={{ fill: BRAND_OLIVE_FILL }}
/>
<rect
x="30"
y="16"
width="12"
height="12"
rx="2.5"
fill={BRAND_OLIVE}
style={{ fill: BRAND_OLIVE_FILL }}
/>
<rect
x="45"
y="15"
@@ -85,8 +108,24 @@ function LogoMark({
transform="rotate(12 51 21)"
style={{ fill: `var(--primary, ${BRAND_YELLOW})` }}
/>
<rect x="30" y="30" width="12" height="12" rx="2.5" fill={BRAND_OLIVE} />
<rect x="30" y="44" width="12" height="12" rx="2.5" fill={BRAND_OLIVE} />
<rect
x="30"
y="30"
width="12"
height="12"
rx="2.5"
fill={BRAND_OLIVE}
style={{ fill: BRAND_OLIVE_FILL }}
/>
<rect
x="30"
y="44"
width="12"
height="12"
rx="2.5"
fill={BRAND_OLIVE}
style={{ fill: BRAND_OLIVE_FILL }}
/>
</g>
</svg>
);
@@ -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',