diff --git a/ApiCore-Frontend/src/app/globals.css b/ApiCore-Frontend/src/app/globals.css index 2593307..7d78e53 100644 --- a/ApiCore-Frontend/src/app/globals.css +++ b/ApiCore-Frontend/src/app/globals.css @@ -2,7 +2,10 @@ @import "tw-animate-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 { --color-background: var(--background); @@ -47,6 +50,24 @@ --color-holo-fill: var(--holo-fill); --color-holo-label-bg: var(--holo-label-bg); --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: var(--accent); --color-muted-foreground: var(--muted-foreground); @@ -135,10 +156,35 @@ /* Hive "screen": always dark, in light and dark mode (spec 9.3). */ --holo-bg: oklch(0.18 0.03 250); --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-label-bg: oklch(0.26 0.04 250); --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 { @@ -195,10 +241,220 @@ /* Hive "screen": always dark, in light and dark mode (spec 9.3). */ --holo-bg: oklch(0.18 0.03 250); --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-label-bg: oklch(0.26 0.04 250); --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; + } } /* --------------------------------------------------------------------------- diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/theme.ts b/ApiCore-Frontend/src/components/hive/hive-scene/theme.ts index b5f39c4..930da68 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/theme.ts +++ b/ApiCore-Frontend/src/components/hive/hive-scene/theme.ts @@ -30,7 +30,7 @@ export interface HoloTheme { const FALLBACKS = { "--holo-bg": "#0b1224", "--holo-edge": "#33c2f0", - "--holo-edge-strong": "#8fe3ff", + "--holo-edge-strong": "#a9dcfd", } as const; type TokenName = keyof typeof FALLBACKS; diff --git a/ApiCore-Frontend/src/styles/contrast.spec.ts b/ApiCore-Frontend/src/styles/contrast.spec.ts new file mode 100644 index 0000000..bb38552 --- /dev/null +++ b/ApiCore-Frontend/src/styles/contrast.spec.ts @@ -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 +>; +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> = { + 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); + }); +}); diff --git a/ApiCore-Frontend/src/styles/css-tokens.spec.ts b/ApiCore-Frontend/src/styles/css-tokens.spec.ts new file mode 100644 index 0000000..ab4d2ee --- /dev/null +++ b/ApiCore-Frontend/src/styles/css-tokens.spec.ts @@ -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); + }); +}); diff --git a/ApiCore-Frontend/src/styles/css-tokens.ts b/ApiCore-Frontend/src/styles/css-tokens.ts new file mode 100644 index 0000000..4a11170 --- /dev/null +++ b/ApiCore-Frontend/src/styles/css-tokens.ts @@ -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 = { + 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 { + 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 = {}; + 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 { + 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 { + 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 { + 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); +} diff --git a/docs/superpowers/plans/2026-10-14-t5-optik.md b/docs/superpowers/plans/2026-10-14-t5-optik.md index 1e60a46..8df401a 100644 --- a/docs/superpowers/plans/2026-10-14-t5-optik.md +++ b/docs/superpowers/plans/2026-10-14-t5-optik.md @@ -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) +- (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** - 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:` @@ -476,6 +478,8 @@ it('ui components contain no dark: classes', ...); ### 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** - 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