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:
1 parent
a7a7599304
commit
6b1b3cfa90
6 files changed
+846
-4
No files matched your search
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user