feat(t5): Farb-Token additiv, Sonnen-Farbsätze und Kontrast-Wächter (T5a A1)

Neue Token neben den bestehenden (Schrift auf Hauptfarbe und Warnung,
Zustands-, Bereichs- und Königinnen-Töne, Fokus- und Rahmenmaße,
Wabenstriche), zwei vollständige Sonnen-Sätze (hell/dunkel) mit
größerer Schrift, 3-px-Fokus und ohne Schatten, Holo-Sonne für die
Beute-Bühne und .holo-stage-Fokusring, Druck immer im hellen Satz,
forced-colors. Wächter misst alle 21 Paare der Spec-Tabelle 4.4 in
vier Sätzen (OKLCH → sRGB → WCAG, Tönungen alpha-gemischt), prüft den
sRGB-Farbraum und erzwingt das Umschalten in A4. Sichtbar ändert sich
nur ein minimal entsättigter Holo-Kantenton (vorher außerhalb sRGB).

Refs #26

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-16 16:23:33 +02:00
1 parent a7a7599304
commit 6b1b3cfa90
6 files changed
+846 -4

No files matched your search

+259 -3
View File
@@ -2,7 +2,10 @@
@import "tw-animate-css"; @import "tw-animate-css";
@import "shadcn/tailwind.css"; @import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:where(.dark, .dark *));
@custom-variant sun (&:where([data-contrast="sun"] *));
@custom-variant pointer-fine (@media (pointer: fine));
@custom-variant pointer-coarse (@media (pointer: coarse));
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
@@ -47,6 +50,24 @@
--color-holo-fill: var(--holo-fill); --color-holo-fill: var(--holo-fill);
--color-holo-label-bg: var(--holo-label-bg); --color-holo-label-bg: var(--holo-label-bg);
--color-holo-label-fg: var(--holo-label-fg); --color-holo-label-fg: var(--holo-label-fg);
--color-primary-ink: var(--primary-ink);
--color-warning-ink: var(--warning-ink);
--color-destructive-foreground: var(--destructive-foreground);
--color-condition-excellent-ink: var(--condition-excellent-ink);
--color-condition-good-ink: var(--condition-good-ink);
--color-condition-average-ink: var(--condition-average-ink);
--color-condition-weak-ink: var(--condition-weak-ink);
--color-condition-critical-ink: var(--condition-critical-ink);
--color-area-betrieb: var(--area-betrieb);
--color-area-erfassung: var(--area-erfassung);
--color-area-arbeit: var(--area-arbeit);
--color-area-ernte: var(--area-ernte);
--color-area-neutral: var(--area-neutral);
--color-queen-white: var(--queen-white);
--color-queen-yellow: var(--queen-yellow);
--color-queen-red: var(--queen-red);
--color-queen-green: var(--queen-green);
--color-queen-blue: var(--queen-blue);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
@@ -135,10 +156,35 @@
/* Hive "screen": always dark, in light and dark mode (spec 9.3). */ /* Hive "screen": always dark, in light and dark mode (spec 9.3). */
--holo-bg: oklch(0.18 0.03 250); --holo-bg: oklch(0.18 0.03 250);
--holo-edge: oklch(0.72 0.15 243); --holo-edge: oklch(0.72 0.15 243);
--holo-edge-strong: oklch(0.87 0.1 238); --holo-edge-strong: oklch(0.87 0.07 238);
--holo-fill: oklch(0.78 0.14 220 / 10%); --holo-fill: oklch(0.78 0.14 220 / 10%);
--holo-label-bg: oklch(0.26 0.04 250); --holo-label-bg: oklch(0.26 0.04 250);
--holo-label-fg: oklch(0.95 0.02 230); --holo-label-fg: oklch(0.95 0.02 230);
/* T5 tokens, end values (spec 4.2); the old values above switch in task A4. */
--primary-ink: oklch(0.47 0.115 55);
--warning-ink: oklch(0.5 0.11 65);
--destructive-foreground: oklch(0.985 0.005 30);
--condition-excellent-ink: oklch(0.4 0.09 150);
--condition-good-ink: oklch(0.41 0.09 125);
--condition-average-ink: oklch(0.44 0.09 85);
--condition-weak-ink: oklch(0.46 0.12 52);
--condition-critical-ink: oklch(0.47 0.16 28);
--area-betrieb: oklch(0.46 0.08 150);
--area-erfassung: oklch(0.5 0.12 60);
--area-arbeit: oklch(0.48 0.08 250);
--area-ernte: oklch(0.52 0.11 75);
--area-neutral: oklch(0.45 0.02 60);
--queen-white: oklch(0.97 0.005 90);
--queen-yellow: oklch(0.86 0.17 95);
--queen-red: oklch(0.58 0.2 27);
--queen-green: oklch(0.58 0.15 145);
--queen-blue: oklch(0.52 0.15 255);
--focus-width: 2px;
--focus-offset: 2px;
--control-border-width: 1px;
--comb-stroke-1: 2px;
--comb-stroke-2: 4px;
--comb-stroke-3: 6px;
} }
.dark { .dark {
@@ -195,10 +241,220 @@
/* Hive "screen": always dark, in light and dark mode (spec 9.3). */ /* Hive "screen": always dark, in light and dark mode (spec 9.3). */
--holo-bg: oklch(0.18 0.03 250); --holo-bg: oklch(0.18 0.03 250);
--holo-edge: oklch(0.72 0.15 243); --holo-edge: oklch(0.72 0.15 243);
--holo-edge-strong: oklch(0.87 0.1 238); --holo-edge-strong: oklch(0.87 0.07 238);
--holo-fill: oklch(0.78 0.14 220 / 10%); --holo-fill: oklch(0.78 0.14 220 / 10%);
--holo-label-bg: oklch(0.26 0.04 250); --holo-label-bg: oklch(0.26 0.04 250);
--holo-label-fg: oklch(0.95 0.02 230); --holo-label-fg: oklch(0.95 0.02 230);
/* T5 tokens, end values (spec 4.2); the old values above switch in task A4. */
--primary-ink: oklch(0.82 0.13 80);
--warning-ink: oklch(0.82 0.13 82);
--destructive-foreground: oklch(0.17 0.03 30);
--condition-excellent-ink: oklch(0.8 0.11 150);
--condition-good-ink: oklch(0.83 0.11 125);
--condition-average-ink: oklch(0.86 0.11 90);
--condition-weak-ink: oklch(0.82 0.11 60);
--condition-critical-ink: oklch(0.78 0.11 28);
--area-betrieb: oklch(0.74 0.09 150);
--area-erfassung: oklch(0.8 0.12 70);
--area-arbeit: oklch(0.76 0.07 250);
--area-ernte: oklch(0.8 0.1 80);
--area-neutral: oklch(0.76 0.02 70);
--queen-white: oklch(0.97 0.005 90);
--queen-yellow: oklch(0.86 0.17 95);
--queen-red: oklch(0.62 0.2 27);
--queen-green: oklch(0.66 0.15 145);
--queen-blue: oklch(0.62 0.15 255);
}
/* Sun ("Sonne"): maximum luminance difference outdoors (spec 4.3). The two sets follow the
order light, dark, sun-light, sun-dark; sun-dark restates everything sun-light sets. */
:root[data-contrast="sun"] {
--background: oklch(1 0 0);
--card: oklch(1 0 0);
--popover: oklch(1 0 0);
--sidebar: oklch(1 0 0);
--foreground: oklch(0.13 0.02 55);
--card-foreground: oklch(0.13 0.02 55);
--popover-foreground: oklch(0.13 0.02 55);
--sidebar-foreground: oklch(0.13 0.02 55);
--primary-foreground: oklch(0.13 0.02 55);
--ring: oklch(0.13 0.02 55);
--muted: oklch(0.95 0.01 80);
--muted-foreground: oklch(0.32 0.03 60);
--border: oklch(0.4 0.03 60);
--input: oklch(0.2 0.03 55);
--primary-ink: oklch(0.38 0.1 52);
--text-sm: 1rem;
--text-xs: 0.875rem;
--focus-width: 3px;
--control-border-width: 2px;
--comb-stroke-1: 3px;
--comb-stroke-2: 5px;
--comb-stroke-3: 7px;
--shadow-soft: none;
--shadow-elevated: none;
/* Hive stage: black screen, pure white edges and label text. */
--holo-bg: oklch(0 0 0);
--holo-edge: oklch(1 0 0);
--holo-edge-strong: oklch(1 0 0);
--holo-label-fg: oklch(1 0 0);
--holo-label-bg: oklch(0.12 0 0);
}
.dark[data-contrast="sun"] {
--background: oklch(0.1 0.005 55);
--card: oklch(0.1 0.005 55);
--popover: oklch(0.1 0.005 55);
--sidebar: oklch(0.1 0.005 55);
--foreground: oklch(0.99 0.005 85);
--card-foreground: oklch(0.99 0.005 85);
--popover-foreground: oklch(0.99 0.005 85);
--sidebar-foreground: oklch(0.99 0.005 85);
--primary-foreground: oklch(0.19 0.03 55);
--ring: oklch(0.99 0.005 85);
--muted: oklch(0.2 0.01 55);
--muted-foreground: oklch(0.88 0.02 80);
--border: oklch(0.7 0.02 70);
--input: oklch(0.92 0.01 80);
--primary-ink: oklch(0.82 0.13 80);
--text-sm: 1rem;
--text-xs: 0.875rem;
--focus-width: 3px;
--control-border-width: 2px;
--comb-stroke-1: 3px;
--comb-stroke-2: 5px;
--comb-stroke-3: 7px;
--shadow-soft: none;
--shadow-elevated: none;
--holo-bg: oklch(0 0 0);
--holo-edge: oklch(1 0 0);
--holo-edge-strong: oklch(1 0 0);
--holo-label-fg: oklch(1 0 0);
--holo-label-bg: oklch(0.12 0 0);
}
/* The hive stage is its own always-dark surface; the page focus ring (--primary-ink) has less
than 3:1 on it (spec 17.8), so the stage draws the ring in its strong edge colour. */
.holo-stage {
--ring: var(--holo-edge-strong);
}
/* Tinted surfaces: set --tint (and --tint-ink) on the element. In sun the tint becomes the
plain card with a 2 px border in the ink instead of a 15 % wash (spec 4.3). */
@layer components {
.tint {
--tint: var(--primary);
--tint-ink: var(--primary-ink);
background-color: color-mix(in oklch, var(--tint) 15%, transparent);
}
}
:root[data-contrast="sun"] .tint {
background-color: var(--card);
border: var(--control-border-width) solid var(--tint-ink);
}
:root[data-contrast="sun"] body header.sticky {
background-color: var(--card);
backdrop-filter: none;
}
/* Print and PDF preview always use the light set (spec 4.1). Kept in step with :root by
contrast.spec.ts. */
@media print {
:root,
.dark,
:root[data-contrast="sun"],
.dark[data-contrast="sun"] {
--background: oklch(0.955 0.017 80);
--foreground: oklch(0.22 0.028 55);
--card: oklch(0.995 0.004 90);
--card-foreground: oklch(0.22 0.028 55);
--popover: oklch(0.995 0.004 90);
--popover-foreground: oklch(0.22 0.028 55);
--primary: oklch(0.6 0.15 48);
--primary-foreground: oklch(0.99 0.02 85);
--secondary: oklch(0.44 0.07 145);
--secondary-foreground: oklch(0.98 0.015 100);
--muted: oklch(0.9 0.02 82);
--muted-foreground: oklch(0.48 0.032 65);
--accent: oklch(0.93 0.032 74);
--accent-foreground: oklch(0.26 0.045 50);
--destructive: oklch(0.5 0.16 30);
--border: oklch(0.85 0.024 78);
--input: oklch(0.85 0.024 78);
--ring: oklch(0.6 0.15 48);
--warning: oklch(0.68 0.14 75);
--warning-foreground: oklch(0.24 0.04 60);
--success: oklch(0.46 0.06 145);
--success-foreground: oklch(0.98 0.015 100);
--condition-excellent: oklch(0.58 0.13 145);
--condition-excellent-foreground: oklch(0.16 0.04 145);
--condition-good: oklch(0.64 0.13 118);
--condition-good-foreground: oklch(0.26 0.05 118);
--condition-average: oklch(0.7 0.14 90);
--condition-average-foreground: oklch(0.28 0.05 90);
--condition-weak: oklch(0.68 0.15 55);
--condition-weak-foreground: oklch(0.26 0.05 55);
--condition-critical: oklch(0.64 0.16 30);
--condition-critical-foreground: oklch(0.15 0.04 30);
--chart-1: oklch(0.6 0.135 50);
--chart-2: oklch(0.46 0.06 145);
--chart-3: oklch(0.68 0.14 75);
--chart-4: oklch(0.5 0.16 30);
--chart-5: oklch(0.55 0.05 250);
--sidebar: oklch(0.995 0.004 90);
--sidebar-foreground: oklch(0.22 0.028 55);
--sidebar-primary: oklch(0.6 0.15 48);
--sidebar-primary-foreground: oklch(0.99 0.02 85);
--sidebar-accent: oklch(0.93 0.032 74);
--sidebar-accent-foreground: oklch(0.26 0.045 50);
--sidebar-border: oklch(0.85 0.024 78);
--sidebar-ring: oklch(0.6 0.15 48);
--shadow-elevated: 0 1px 2px -1px oklch(0.3 0.05 50 / 8%), 0 8px 24px -8px oklch(0.3 0.05 50 / 16%);
--shadow-soft: 0 1px 2px -1px oklch(0.3 0.05 50 / 6%), 0 4px 12px -4px oklch(0.3 0.05 50 / 10%);
--holo-bg: oklch(0.18 0.03 250);
--holo-edge: oklch(0.72 0.15 243);
--holo-edge-strong: oklch(0.87 0.07 238);
--holo-fill: oklch(0.78 0.14 220 / 10%);
--holo-label-bg: oklch(0.26 0.04 250);
--holo-label-fg: oklch(0.95 0.02 230);
--primary-ink: oklch(0.47 0.115 55);
--warning-ink: oklch(0.5 0.11 65);
--destructive-foreground: oklch(0.985 0.005 30);
--condition-excellent-ink: oklch(0.4 0.09 150);
--condition-good-ink: oklch(0.41 0.09 125);
--condition-average-ink: oklch(0.44 0.09 85);
--condition-weak-ink: oklch(0.46 0.12 52);
--condition-critical-ink: oklch(0.47 0.16 28);
--area-betrieb: oklch(0.46 0.08 150);
--area-erfassung: oklch(0.5 0.12 60);
--area-arbeit: oklch(0.48 0.08 250);
--area-ernte: oklch(0.52 0.11 75);
--area-neutral: oklch(0.45 0.02 60);
--queen-white: oklch(0.97 0.005 90);
--queen-yellow: oklch(0.86 0.17 95);
--queen-red: oklch(0.58 0.2 27);
--queen-green: oklch(0.58 0.15 145);
--queen-blue: oklch(0.52 0.15 255);
--text-sm: 0.875rem;
--text-xs: 0.75rem;
--focus-width: 2px;
--control-border-width: 1px;
--comb-stroke-1: 2px;
--comb-stroke-2: 4px;
--comb-stroke-3: 6px;
}
}
/* Forced colours: the system palette wins; strokes use CanvasText so shapes stay visible. */
@media (forced-colors: active) {
:root,
.dark,
:root[data-contrast="sun"],
.dark[data-contrast="sun"] {
--ring: CanvasText;
--border: CanvasText;
--input: CanvasText;
}
} }
/* --------------------------------------------------------------------------- /* ---------------------------------------------------------------------------
@@ -30,7 +30,7 @@ export interface HoloTheme {
const FALLBACKS = { const FALLBACKS = {
"--holo-bg": "#0b1224", "--holo-bg": "#0b1224",
"--holo-edge": "#33c2f0", "--holo-edge": "#33c2f0",
"--holo-edge-strong": "#8fe3ff", "--holo-edge-strong": "#a9dcfd",
} as const; } as const;
type TokenName = keyof typeof FALLBACKS; type TokenName = keyof typeof FALLBACKS;
@@ -0,0 +1,359 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { atRuleDeclarations, blend, contrastOf, inGamut, oklchOf, tokenSet, type TokenSet } from "./css-tokens";
/**
* Task A4 sets this to true. Until then globals.css keeps the old base values for the tokens in
* TARGET; the guard measures the table against the end values so the plan numbers are proven
* now, and the switch then pins globals.css to exactly these values.
*/
export const VALUES_SWITCHED = false;
const css = readFileSync(join(process.cwd(), "src/app/globals.css"), "utf8");
const SETS: TokenSet[] = ["light", "dark", "sun-light", "sun-dark"];
const sets = Object.fromEntries(SETS.map((set) => [set, tokenSet(css, set)])) as Record<
TokenSet,
Record<string, string>
>;
const own = (set: TokenSet) => tokenSet(css, set, { own: true });
type Rgb = [number, number, number];
/** End values of the base sets (spec 4.2). `ring` is `--primary-ink` after the switch. */
const TARGET: Record<"light" | "dark", Record<string, string>> = {
light: {
background: "oklch(0.968 0.014 85)",
foreground: "oklch(0.21 0.03 55)",
card: "oklch(0.995 0.004 90)",
muted: "oklch(0.925 0.018 82)",
"muted-foreground": "oklch(0.44 0.03 60)",
primary: "oklch(0.80 0.145 78)",
"primary-foreground": "oklch(0.21 0.035 55)",
ring: "oklch(0.47 0.115 55)",
secondary: "oklch(0.44 0.07 145)",
"secondary-foreground": "oklch(0.98 0.015 100)",
destructive: "oklch(0.50 0.17 28)",
border: "oklch(0.86 0.022 78)",
input: "oklch(0.60 0.035 65)",
warning: "oklch(0.79 0.15 80)",
"warning-foreground": "oklch(0.23 0.04 60)",
"condition-excellent": "oklch(0.62 0.13 150)",
"condition-excellent-foreground": "oklch(0.15 0.04 150)",
"condition-good": "oklch(0.645 0.13 125)",
"condition-good-foreground": "oklch(0.17 0.04 125)",
"condition-average": "oklch(0.665 0.125 88)",
"condition-average-foreground": "oklch(0.19 0.04 90)",
"condition-weak": "oklch(0.665 0.14 58)",
"condition-weak-foreground": "oklch(0.18 0.04 55)",
"condition-critical": "oklch(0.62 0.17 30)",
"condition-critical-foreground": "oklch(0.15 0.04 30)",
"chart-1": "oklch(0.56 0.13 55)",
"chart-2": "oklch(0.46 0.08 145)",
"chart-3": "oklch(0.60 0.12 85)",
"chart-4": "oklch(0.50 0.17 28)",
"chart-5": "oklch(0.52 0.08 250)",
},
dark: {
background: "oklch(0.19 0.016 55)",
foreground: "oklch(0.95 0.018 80)",
card: "oklch(0.235 0.018 55)",
muted: "oklch(0.28 0.018 55)",
"muted-foreground": "oklch(0.76 0.025 75)",
primary: "oklch(0.80 0.14 78)",
"primary-foreground": "oklch(0.19 0.03 55)",
ring: "oklch(0.82 0.13 80)",
secondary: "oklch(0.66 0.08 145)",
"secondary-foreground": "oklch(0.16 0.02 145)",
destructive: "oklch(0.70 0.15 28)",
border: "oklch(0.34 0.02 60)",
input: "oklch(0.56 0.025 70)",
warning: "oklch(0.80 0.14 80)",
"warning-foreground": "oklch(0.20 0.03 60)",
"condition-excellent": "oklch(0.68 0.12 150)",
"condition-excellent-foreground": "oklch(0.17 0.04 150)",
"condition-good": "oklch(0.72 0.12 125)",
"condition-good-foreground": "oklch(0.18 0.04 125)",
"condition-average": "oklch(0.77 0.13 90)",
"condition-average-foreground": "oklch(0.20 0.04 90)",
"condition-weak": "oklch(0.74 0.13 60)",
"condition-weak-foreground": "oklch(0.19 0.04 55)",
"condition-critical": "oklch(0.68 0.16 28)",
"condition-critical-foreground": "oklch(0.16 0.04 30)",
"chart-1": "oklch(0.78 0.13 70)",
"chart-2": "oklch(0.70 0.09 145)",
"chart-3": "oklch(0.82 0.12 90)",
"chart-4": "oklch(0.70 0.15 28)",
"chart-5": "oklch(0.72 0.08 250)",
},
};
/** A token as it will be after task A4: new tokens and the sun sets are final today. */
function value(set: TokenSet, token: string): string {
const base = set === "light" || set === "sun-light" ? "light" : "dark";
const fromSun = set.startsWith("sun") ? own(set)[token] : undefined;
if (fromSun) return fromSun;
if (!VALUES_SWITCHED && TARGET[base][token]) return TARGET[base][token];
const found = sets[set][token];
if (!found) throw new Error(`token --${token} missing in ${set}`);
return found;
}
const rgb = (set: TokenSet, token: string): Rgb => oklchOf(value(set, token));
const TONES = ["excellent", "good", "average", "weak", "critical"] as const;
const AREA_TONES = ["betrieb", "erfassung", "arbeit", "ernte", "neutral"] as const;
const QUEEN_COLOURS = ["white", "yellow", "red", "green", "blue"] as const;
const CHARTS = [1, 2, 3, 4, 5] as const;
/** Translucent fills as written in the components: warning/condition `/15`, error `/10`. */
const TINT_ALPHA = 0.15;
const ERROR_TINT_ALPHA = 0.1;
const tint = (set: TokenSet, token: string, alpha = TINT_ALPHA): Rgb =>
blend(rgb(set, token), alpha, rgb(set, "card"));
const worst = (values: number[]) => Math.min(...values);
interface Row {
id: string;
limit: number;
/** Plan table "Gemessene Kontraste": hell, dunkel, Sonne hell, Sonne dunkel. */
expected: [number, number, number, number];
measure: (set: TokenSet) => number;
}
const ROWS: Row[] = [
{ id: "primary-text", limit: 7, expected: [9.38, 9.77, 10.61, 9.77],
measure: (s) => contrastOf(rgb(s, "primary-foreground"), rgb(s, "primary")) },
{ id: "text-page", limit: 7, expected: [16.23, 15.98, 20.15, 20.01],
measure: (s) => contrastOf(rgb(s, "foreground"), rgb(s, "background")) },
{ id: "text-card", limit: 7, expected: [17.56, 14.45, 20.15, 20.01],
measure: (s) => contrastOf(rgb(s, "foreground"), rgb(s, "card")) },
{ id: "muted-page", limit: 4.5, expected: [7.13, 8.6, 12.77, 14.33],
measure: (s) => contrastOf(rgb(s, "muted-foreground"), rgb(s, "background")) },
{ id: "muted-muted", limit: 4.5, expected: [6.27, 6.81, 11.03, 12.62],
measure: (s) => contrastOf(rgb(s, "muted-foreground"), rgb(s, "muted")) },
{ id: "link-page", limit: 4.5, expected: [6.48, 10.48, 10.37, 11.66],
measure: (s) => contrastOf(rgb(s, "primary-ink"), rgb(s, "background")) },
{ id: "input-card", limit: 3, expected: [3.92, 3.58, 18.19, 16.25],
measure: (s) => contrastOf(rgb(s, "input"), rgb(s, "card")) },
{ id: "input-page", limit: 3, expected: [3.62, 3.96, 18.19, 16.25],
measure: (s) => contrastOf(rgb(s, "input"), rgb(s, "background")) },
{ id: "focus-card", limit: 3, expected: [7.01, 9.47, 20.15, 20.01],
measure: (s) => contrastOf(rgb(s, "ring"), rgb(s, "card")) },
{ id: "focus-page", limit: 3, expected: [6.48, 10.48, 20.15, 20.01],
measure: (s) => contrastOf(rgb(s, "ring"), rgb(s, "background")) },
{ id: "warning-ink-card", limit: 4.5, expected: [6.09, 9.49, 6.18, 11.68],
measure: (s) => contrastOf(rgb(s, "warning-ink"), rgb(s, "card")) },
{ id: "warning-ink-tint", limit: 4.5, expected: [5.53, 6.92, 5.6, 9.37],
measure: (s) => contrastOf(rgb(s, "warning-ink"), tint(s, "warning")) },
{ id: "error-card", limit: 4.5, expected: [6.45, 5.87, 6.54, 7.23],
measure: (s) => contrastOf(rgb(s, "destructive"), rgb(s, "card")) },
{ id: "error-tint", limit: 4.5, expected: [5.5, 5.06, 5.57, 6.58],
measure: (s) => contrastOf(rgb(s, "destructive"), tint(s, "destructive", ERROR_TINT_ALPHA)) },
{ id: "sage-button", limit: 4.5, expected: [7.11, 6.43, 7.11, 6.43],
measure: (s) => contrastOf(rgb(s, "secondary-foreground"), rgb(s, "secondary")) },
{ id: "traffic-badge", limit: 4.5, expected: [6.06, 6.45, 6.14, 8.49],
measure: (s) =>
worst(TONES.map((t) => contrastOf(rgb(s, `condition-${t}-ink`), tint(s, `condition-${t}`)))) },
{ id: "traffic-text", limit: 4.5, expected: [5.02, 6.32, 5.02, 6.32],
measure: (s) =>
worst(TONES.map((t) => contrastOf(rgb(s, `condition-${t}-foreground`), rgb(s, `condition-${t}`)))) },
{ id: "traffic-surface", limit: 3, expected: [3.03, 5.41, 3.07, 6.66],
measure: (s) => worst(TONES.map((t) => contrastOf(rgb(s, `condition-${t}`), rgb(s, "card")))) },
{ id: "charts", limit: 3, expected: [3.93, 5.87, 3.99, 7.23],
measure: (s) => worst(CHARTS.map((n) => contrastOf(rgb(s, `chart-${n}`), rgb(s, "card")))) },
// The plan table repeats the light/dark numbers for the sun sets; the sun cards (white / near-black)
// measure 5.62 and 9.26. Both clear the limit.
{ id: "area-tones", limit: 3, expected: [5.54, 7.52, 5.62, 9.26],
measure: (s) => worst(AREA_TONES.map((a) => contrastOf(rgb(s, `area-${a}`), rgb(s, "card")))) },
{ id: "queen-ring", limit: 3, expected: [17.56, 14.45, 20.15, 20.01],
measure: (s) => contrastOf(rgb(s, "foreground"), rgb(s, "card")) },
];
const HOLO_TOKENS = ["holo-bg", "holo-edge", "holo-edge-strong", "holo-label-bg", "holo-label-fg"];
const NEW_COLOUR_TOKENS = [
"primary-ink",
"warning-ink",
"destructive-foreground",
...TONES.map((t) => `condition-${t}-ink`),
...AREA_TONES.map((a) => `area-${a}`),
...QUEEN_COLOURS.map((q) => `queen-${q}`),
];
/** Old light values slightly outside sRGB (near-white with chroma); task A4 replaces them. */
const LEGACY_OUT_OF_GAMUT = ["primary-foreground", "sidebar-primary-foreground"];
const SUN_TABLE = [
"background", "card", "popover", "sidebar", "foreground", "primary-foreground", "ring",
"muted", "muted-foreground", "border", "input", "primary-ink",
];
describe("token sets", () => {
it("every colour in @theme inline exists in light and dark", () => {
const block = /@theme inline \{([\s\S]*?)\n\}/.exec(css)?.[1] ?? "";
const used = [...block.matchAll(/--color-[\w-]+:\s*var\(--([\w-]+)\)/g)].map((m) => m[1]);
expect(used.length).toBeGreaterThan(20);
for (const token of used) {
expect(sets.light[token], `--${token} in light`).toBeDefined();
expect(sets.dark[token], `--${token} in dark`).toBeDefined();
}
});
it.each([...NEW_COLOUR_TOKENS, ...HOLO_TOKENS])("--color-%s is exposed to Tailwind", (token) => {
expect(css).toContain(`--color-${token}: var(--${token});`);
});
it.each(SETS)("all colour tokens are inside the sRGB gamut in %s", (set) => {
const outside = Object.entries(sets[set])
.filter(([key]) => VALUES_SWITCHED || !LEGACY_OUT_OF_GAMUT.includes(key))
.filter(([, v]) => /^oklch\(/.test(v))
.filter(([, v]) => !inGamut(oklchOf(v)))
.map(([k]) => k);
expect(outside).toEqual([]);
});
it.each(["light", "dark"] as const)("new colour tokens are defined in %s", (set) => {
for (const token of NEW_COLOUR_TOKENS) expect(sets[set][token], `--${token}`).toBeDefined();
});
it("the focus ring token equals --primary-ink in light and dark", () => {
if (!VALUES_SWITCHED) return; // --ring keeps its old value until task A4
for (const set of ["light", "dark"] as const)
expect(sets[set].ring).toBe(sets[set]["primary-ink"]);
});
it("sun sets define every token listed in the sun table", () => {
for (const set of ["sun-light", "sun-dark"] as const)
for (const token of [...SUN_TABLE, ...HOLO_TOKENS])
expect(own(set)[token], `--${token} in ${set}`).toBeDefined();
});
it("sun-dark restores every token that sun-light overrides (cascade safety)", () => {
for (const token of Object.keys(own("sun-light")))
expect(own("sun-dark")[token], `--${token}`).toBeDefined();
});
it("sun sets carry the rule variables", () => {
for (const set of ["sun-light", "sun-dark"] as const) {
expect(own(set)["text-sm"]).toBe("1rem");
expect(own(set)["text-xs"]).toBe("0.875rem");
expect(own(set)["focus-width"]).toBe("3px");
expect(own(set)["control-border-width"]).toBe("2px");
expect(own(set)["comb-stroke-1"]).toBe("3px");
expect(own(set)["comb-stroke-2"]).toBe("5px");
expect(own(set)["comb-stroke-3"]).toBe("7px");
expect(own(set)["shadow-soft"]).toBe("none");
expect(own(set)["shadow-elevated"]).toBe("none");
}
});
it("the Tailwind variants, print and forced-colors blocks are declared", () => {
expect(css).toContain("@custom-variant dark (&:where(.dark, .dark *));");
expect(css).toMatch(/@custom-variant sun\b/);
expect(css).toMatch(/@custom-variant pointer-fine\b/);
expect(css).toMatch(/@custom-variant pointer-coarse\b/);
expect(css).toContain("@media print");
expect(css).toContain("@media (forced-colors: active)");
});
it("@media print restates every colour token of the light set", () => {
const print = atRuleDeclarations(css, "@media print");
for (const [token, v] of Object.entries(sets.light).filter(([, v]) => /^oklch\(/.test(v)))
expect(print[token], `--${token}`).toBe(v);
});
it("@media print also restates the root defaults of the sun rule variables", () => {
const print = atRuleDeclarations(css, "@media print");
for (const token of ["focus-width", "control-border-width", "comb-stroke-1", "comb-stroke-2",
"comb-stroke-3", "shadow-soft", "shadow-elevated"])
expect(print[token], `--${token}`).toBe(sets.light[token]);
expect(print["text-sm"]).toBe("0.875rem");
expect(print["text-xs"]).toBe("0.75rem");
});
it(".tint defaults sit in @layer components so utility classes can override --tint", () => {
const layered = /@layer components\s*\{\s*\.tint\s*\{[^}]*--tint:/.test(css);
expect(layered).toBe(true);
expect(css).not.toMatch(/^\.tint\s*\{/m); // no unlayered defaults
});
it("VALUES_SWITCHED is flipped as soon as globals.css holds the end values", () => {
const allEqual = (["light", "dark"] as const).every((base) =>
Object.entries(TARGET[base]).every(([token, v]) => sets[base][token] === v),
);
expect(allEqual && !VALUES_SWITCHED).toBe(false);
});
it("forced-colors maps ring, border and input to CanvasText", () => {
const forced = atRuleDeclarations(css, "@media (forced-colors: active)");
expect(forced).toMatchObject({ ring: "CanvasText", border: "CanvasText", input: "CanvasText" });
});
it("pins the base sets to the end values once switched", () => {
if (!VALUES_SWITCHED) return;
for (const base of ["light", "dark"] as const)
for (const [token, expected] of Object.entries(TARGET[base]))
expect(sets[base][token], `--${token} in ${base}`).toBe(expected);
});
});
describe.each(SETS)("contrast guard in %s", (set) => {
it.each(AREA_TONES)("area tone %s reaches 3:1 on the card", (tone) => {
expect(contrastOf(rgb(set, `area-${tone}`), rgb(set, "card"))).toBeGreaterThanOrEqual(3);
});
it.each(QUEEN_COLOURS)("queen colour %s stands out from card or ring, and the ring reaches 3:1 on the card", (c) => {
// A marking on the card is drawn with a 2 px --foreground ring: the ring carries 3:1 on the
// card, and the colour must be distinguishable from the ring it sits inside.
expect(contrastOf(rgb(set, "foreground"), rgb(set, "card"))).toBeGreaterThanOrEqual(3);
// Each colour stands out from the card or, where it is as light as the card, from the ring.
const vsCard = contrastOf(rgb(set, `queen-${c}`), rgb(set, "card"));
const vsRing = contrastOf(rgb(set, `queen-${c}`), rgb(set, "foreground"));
expect(Math.max(vsCard, vsRing)).toBeGreaterThanOrEqual(3);
});
it("holo label text on label surface reaches 7:1", () => {
expect(contrastOf(rgb(set, "holo-label-fg"), rgb(set, "holo-label-bg"))).toBeGreaterThanOrEqual(7);
});
it("holo-edge-strong (the .holo-stage focus ring) reaches 3:1 on the stage", () => {
expect(contrastOf(rgb(set, "holo-edge-strong"), rgb(set, "holo-bg"))).toBeGreaterThanOrEqual(3);
});
it("holo edge reaches 3:1 on the stage", () => {
expect(contrastOf(rgb(set, "holo-edge"), rgb(set, "holo-bg"))).toBeGreaterThanOrEqual(3);
});
it("the page focus ring (--primary-ink) misses 3:1 on the stage in the light sets, which is why .holo-stage exists", () => {
if (set !== "light" && set !== "sun-light") return;
expect(contrastOf(rgb(set, "primary-ink"), rgb(set, "holo-bg"))).toBeLessThan(3);
});
});
describe("the stage frame", () => {
it(".holo-stage sets --ring to --holo-edge-strong", () => {
expect(css).toMatch(/\.holo-stage\s*\{[^}]*--ring:\s*var\(--holo-edge-strong\)/);
});
it("the stage tokens are identical in light and dark (always-dark screen)", () => {
for (const token of HOLO_TOKENS) expect(sets.light[token]).toBe(sets.dark[token]);
});
it("sun makes the stage black with pure white edges and label text", () => {
for (const set of ["sun-light", "sun-dark"] as const) {
expect(own(set)["holo-bg"]).toBe("oklch(0 0 0)");
expect(own(set)["holo-edge"]).toBe("oklch(1 0 0)");
expect(own(set)["holo-edge-strong"]).toBe("oklch(1 0 0)");
expect(own(set)["holo-label-fg"]).toBe("oklch(1 0 0)");
expect(own(set)["holo-label-bg"]).toBe("oklch(0.12 0 0)");
}
});
});
describe("table 4.4 (end values)", () => {
const cases = ROWS.flatMap((row) =>
SETS.map((set, i) => ({ id: row.id, row, set, expected: row.expected[i] })),
);
it.each(cases)("$id reaches its limit and the measured ratio in $set", ({ row, set, expected }) => {
const measured = row.measure(set);
expect(measured).toBeGreaterThanOrEqual(row.limit);
expect(Math.abs(measured - expected)).toBeLessThanOrEqual(0.05);
});
});
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { alphaOf, atRuleDeclarations, blend, contrastOf, inGamut, oklchOf, tokenSet } from "./css-tokens";
const CSS = `
@theme inline { --color-x: var(--x); }
:root { --x: oklch(0.5 0.1 80); --y: oklch(1 0 0 / 12%); --len: 0.75rem;
@variant sm { --nested: oklch(0 0 0); } }
.dark { --x: oklch(0.2 0.1 80); --y: oklch(0 0 0); }
:root[data-contrast="sun"] { --x: oklch(0.9 0 0); }
.dark[data-contrast="sun"] { --x: oklch(0.95 0 0); }
`;
describe("css-tokens", () => {
it("reads oklch values with and without alpha", () => {
expect(oklchOf("oklch(0.5 0.1 80)")).toEqual([0.5, 0.1, 80]);
expect(oklchOf("oklch(1 0 0 / 12%)")).toEqual([1, 0, 0]);
expect(alphaOf("oklch(1 0 0 / 12%)")).toBeCloseTo(0.12);
expect(alphaOf("oklch(1 0 0)")).toBe(1);
expect(() => oklchOf("1rem")).toThrow();
});
it("contrast of black on white is 21", () => {
expect(contrastOf([0, 0, 0], [1, 0, 0])).toBeCloseTo(21, 1);
expect(contrastOf([1, 0, 0], [1, 0, 0])).toBeCloseTo(1, 5);
});
it("blends a 15 % tint over its background before measuring", () => {
const white: [number, number, number] = [1, 0, 0];
const black: [number, number, number] = [0, 0, 0];
const mixed = blend(black, 0.15, white);
expect(mixed[0]).toBeGreaterThan(0.8);
expect(mixed[0]).toBeLessThan(1);
expect(contrastOf(mixed, white)).toBeLessThan(contrastOf(black, white));
expect(blend(black, 1, white)[0]).toBeCloseTo(0, 5);
});
it("sun-light layers over light and sun-dark over dark", () => {
expect(tokenSet(CSS, "light").x).toBe("oklch(0.5 0.1 80)");
expect(tokenSet(CSS, "dark").x).toBe("oklch(0.2 0.1 80)");
expect(tokenSet(CSS, "sun-light").x).toBe("oklch(0.9 0 0)");
expect(tokenSet(CSS, "sun-light").len).toBe("0.75rem");
expect(tokenSet(CSS, "sun-dark").x).toBe("oklch(0.95 0 0)");
expect(tokenSet(CSS, "sun-dark").y).toBe("oklch(0 0 0)");
});
it("returns only the own declarations of a sun set on request", () => {
expect(tokenSet(CSS, "sun-light", { own: true })).toEqual({ x: "oklch(0.9 0 0)" });
});
it("ignores declarations nested in at-rules", () => {
expect(tokenSet(CSS, "light").nested).toBeUndefined();
});
it("reads the declarations inside an at-rule", () => {
const css = "@media print { :root, .dark { --a: 1; } .x { --b: 2; } } :root { --c: 3; }";
expect(atRuleDeclarations(css, "@media print")).toEqual({ a: "1", b: "2" });
});
it("detects colours outside the sRGB gamut", () => {
expect(inGamut([0.6, 0.1, 50])).toBe(true);
expect(inGamut([0.6, 0.4, 150])).toBe(false);
});
});
+160
View File
@@ -0,0 +1,160 @@
/**
* Test helpers for the design tokens in globals.css (used by the contrast guard only).
* Colours are OKLCH triples [L, C, H]; contrast follows WCAG 2.x on the clipped sRGB value.
*/
export type TokenSet = "light" | "dark" | "sun-light" | "sun-dark";
export type Oklch = [number, number, number];
const SELECTORS: Record<TokenSet, string> = {
light: ":root",
dark: ".dark",
"sun-light": ':root[data-contrast="sun"]',
"sun-dark": '.dark[data-contrast="sun"]',
};
const OKLCH = /^oklch\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*(?:\/\s*([\d.]+)(%?)\s*)?\)$/;
const GAMUT_EPSILON = 0.002;
/** Declarations of a rule body at depth 0 (nested at-rules such as `@variant sm {}` are dropped). */
function ownDeclarations(body: string): Record<string, string> {
let flat = "";
let depth = 0;
for (const char of body) {
if (char === "{") depth += 1;
else if (char === "}") depth -= 1;
else if (depth === 0) flat += char;
}
const result: Record<string, string> = {};
for (const match of flat.matchAll(/--([\w-]+)\s*:\s*([^;]+);/g)) {
result[match[1]] = match[2].trim();
}
return result;
}
/** Every top-level rule `selector { … }` of the stylesheet, in source order. */
function topLevelRules(source: string): Array<{ selector: string; body: string }> {
const css = source.replace(/\/\*[\s\S]*?\*\//g, "");
const rules: Array<{ selector: string; body: string }> = [];
let depth = 0;
let selectorStart = 0;
let bodyStart = 0;
let selector = "";
for (let i = 0; i < css.length; i += 1) {
const char = css[i];
if (char === "{") {
if (depth === 0) {
selector = css.slice(selectorStart, i).trim();
bodyStart = i + 1;
}
depth += 1;
} else if (char === "}") {
depth -= 1;
if (depth === 0) {
rules.push({ selector, body: css.slice(bodyStart, i) });
selectorStart = i + 1;
}
} else if (char === ";" && depth === 0) {
selectorStart = i + 1;
}
}
return rules;
}
function declarationsOf(css: string, selector: string): Record<string, string> {
return topLevelRules(css)
.filter((rule) => rule.selector.replace(/\s+/g, " ") === selector)
.reduce((all, rule) => ({ ...all, ...ownDeclarations(rule.body) }), {});
}
/** Custom properties declared inside an at-rule such as `@media print` (all its rules merged). */
export function atRuleDeclarations(css: string, atRule: string): Record<string, string> {
return topLevelRules(css)
.filter((rule) => rule.selector.replace(/\s+/g, " ") === atRule)
.flatMap((rule) => topLevelRules(rule.body))
.reduce((all, rule) => ({ ...all, ...ownDeclarations(rule.body) }), {});
}
/**
* Custom properties of one set. The sun sets lie on their base set ("sun-light" on "light",
* "sun-dark" on "dark"), exactly as the cascade resolves them; `{ own: true }` returns only
* what the sun set itself declares.
*/
export function tokenSet(
css: string,
set: TokenSet,
options: { own?: boolean } = {},
): Record<string, string> {
const own = declarationsOf(css, SELECTORS[set]);
if (options.own || !set.startsWith("sun")) return own;
const base = set === "sun-light" ? "light" : "dark";
return { ...declarationsOf(css, SELECTORS[base]), ...own };
}
export function oklchOf(value: string): Oklch {
const match = OKLCH.exec(value.trim());
if (!match) throw new Error(`not an oklch() colour: ${value}`);
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
export function alphaOf(value: string): number {
const match = OKLCH.exec(value.trim());
if (!match) throw new Error(`not an oklch() colour: ${value}`);
if (match[4] === undefined) return 1;
return match[5] === "%" ? Number(match[4]) / 100 : Number(match[4]);
}
/** OKLCH to linear sRGB (Ottosson), not clipped. */
function rawLinearRgb([l, c, h]: Oklch): number[] {
const a = c * Math.cos((h * Math.PI) / 180);
const b = c * Math.sin((h * Math.PI) / 180);
const l3 = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3;
const m3 = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3;
const s3 = (l - 0.0894841775 * a - 1.291485548 * b) ** 3;
return [
4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3,
-1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3,
-0.0041960863 * l3 - 0.7034186147 * m3 + 1.707614701 * s3,
];
}
const clip = (channel: number) => Math.min(1, Math.max(0, channel));
function luminance(color: Oklch): number {
const [r, g, b] = rawLinearRgb(color).map(clip);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
export function contrastOf(a: Oklch, b: Oklch): number {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
}
const toGamma = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
const fromGamma = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
/**
* `fg` at `alpha` over `bg`, composited the way a browser paints a translucent fill
* (`bg-x/15`): per channel in gamma-encoded sRGB. Returned as OKLCH.
*/
export function blend(fg: Oklch, alpha: number, bg: Oklch): Oklch {
const f = rawLinearRgb(fg).map(clip).map(toGamma);
const g = rawLinearRgb(bg).map(clip).map(toGamma);
const mixed = f.map((channel, i) => channel * alpha + g[i] * (1 - alpha)).map(fromGamma);
return linearRgbToOklch(mixed);
}
function linearRgbToOklch([r, g, b]: number[]): Oklch {
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);
const lightness = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
const bb = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
const hue = ((Math.atan2(bb, a) * 180) / Math.PI + 360) % 360;
return [lightness, Math.hypot(a, bb), hue];
}
export function inGamut(color: Oklch): boolean {
return rawLinearRgb(color).every((c) => c >= -GAMUT_EPSILON && c <= 1 + GAMUT_EPSILON);
}
@@ -439,6 +439,8 @@ it('no file passes a gradient prop any more', ...); // liest src/**/*.tsx
### Task A4: Farb-Codemod und Tokenwerte (Spec 10.8 Schritte 3 und 4, Spec 4.2) ### Task A4: Farb-Codemod und Tokenwerte (Spec 10.8 Schritte 3 und 4, Spec 4.2)
- (A1-Review) `dark:` ist jetzt `:where(.dark, .dark *)` (Spezifität 0). Beim Codemod `dark:bg-x` nie mit un-präfixiertem `hover:bg-y` paaren: Hover gewinnt dann im Dunkeln. A4 setzt außerdem `VALUES_SWITCHED = true` (ein Wächter-Test schlägt sonst an).
**Files** **Files**
- Create: `scripts/codemod-t5-colors.mjs` (+ `scripts/codemod-t5-colors.spec.mjs`) - Create: `scripts/codemod-t5-colors.mjs` (+ `scripts/codemod-t5-colors.spec.mjs`)
- Modify: `src/app/globals.css` (Werte aus der Token-Tabelle, `--gradient-*` entfernen), `src/styles/contrast.spec.ts` (`VALUES_SWITCHED = true`), `src/components/ui/*` (alle `dark:`-Klassen raus; `button.tsx` Standard ohne Verlauf), `src/lib/colonies/labels.ts` (`COLONY_CONDITION_TONE_CLASS`), `src/components/colonies/status-light-badge.tsx`, `src/components/form/rating-scale.tsx`, `src/lib/appearance/appearance.ts` (Flag entfällt), `appearance-section.tsx`, alle Treffer des Codemods (`grep -rlE "text-primary\b|text-warning\b|border-primary\b" src`, 22 + 6 Dateien) und die acht Dateien mit `dark:` - Modify: `src/app/globals.css` (Werte aus der Token-Tabelle, `--gradient-*` entfernen), `src/styles/contrast.spec.ts` (`VALUES_SWITCHED = true`), `src/components/ui/*` (alle `dark:`-Klassen raus; `button.tsx` Standard ohne Verlauf), `src/lib/colonies/labels.ts` (`COLONY_CONDITION_TONE_CLASS`), `src/components/colonies/status-light-badge.tsx`, `src/components/form/rating-scale.tsx`, `src/lib/appearance/appearance.ts` (Flag entfällt), `appearance-section.tsx`, alle Treffer des Codemods (`grep -rlE "text-primary\b|text-warning\b|border-primary\b" src`, 22 + 6 Dateien) und die acht Dateien mit `dark:`
@@ -476,6 +478,8 @@ it('ui components contain no dark: classes', ...);
### Task A5: Fokus, Größen, Schrift (Spec 4.5, 4.6, Schritt 5) ### Task A5: Fokus, Größen, Schrift (Spec 4.5, 4.6, Schritt 5)
- (A1-Review) `.holo-stage` muss auf die Wurzeln von `components/hive/hive-card.tsx`, des Lade-Fallbacks in `app/(app)/colonies/[id]/hive/page.tsx`, `components/hive/editor/hive-editor.tsx` (Zeile ~316) und `editor/editor-notice.tsx` (Zeile ~16); sonst greift der Bühnen-Fokusring (`--holo-edge-strong`) nicht.
**Files** **Files**
- Create: `scripts/codemod-t5-focus.mjs` (+ Spec), `src/styles/baukasten-guard.spec.ts` - Create: `scripts/codemod-t5-focus.mjs` (+ Spec), `src/styles/baukasten-guard.spec.ts`
- Modify: `src/app/globals.css` (`--text-xs: 0.8125rem`, Basisregel `:where(:focus-visible)`, `--focus-width/-offset`, Trefferfläche `pointer-coarse`), `src/components/ui/button.tsx`, `input.tsx`, `select.tsx`, `textarea.tsx`, `tabs.tsx`, `checkbox.tsx` (Fokus), `src/components/form/field.tsx`, `number-stepper.tsx`, `choice-group.tsx`, `src/components/empty-state.tsx`, `src/components/detail-header.tsx`, alle Treffer von `ring-ring/50`, `outline-none` (≈ 20 Dateien), `sm:h-` (4 Dateien), `md:text-sm` in Input/Textarea - Modify: `src/app/globals.css` (`--text-xs: 0.8125rem`, Basisregel `:where(:focus-visible)`, `--focus-width/-offset`, Trefferfläche `pointer-coarse`), `src/components/ui/button.tsx`, `input.tsx`, `select.tsx`, `textarea.tsx`, `tabs.tsx`, `checkbox.tsx` (Fokus), `src/components/form/field.tsx`, `number-stepper.tsx`, `choice-group.tsx`, `src/components/empty-state.tsx`, `src/components/detail-header.tsx`, alle Treffer von `ring-ring/50`, `outline-none` (≈ 20 Dateien), `sm:h-` (4 Dateien), `md:text-sm` in Input/Textarea