feat(t5): neue Farbwerte, Dunkel für alle, Farb-Codemod (T5a A4)

- globals.css: Hell, Dunkel und Druck mit den Spec-Werten aus 4.2,
  Fokusring = --primary-ink, Verlaufs-Token entfernt.
- Codemod scripts/codemod-t5-colors.mjs: text-primary/-warning auf -ink,
  Vordergrund nur auf voller Fläche, Auswahlrahmen dunkel; --check-Modus
  und Spec halten src sauber.
- Kontrast-Wächter misst die echte globals.css (VALUES_SWITCHED), alle 21
  Zeilen aus 4.4 in allen vier Sätzen; keine dark:-Klassen mehr in src.
- Dunkel ist ohne Entwickler-Schalter wählbar, Standard bleibt Hell.
- Review-Nacharbeit: Auswahl-Umrisse und aktueller Schritt in Vordergrund,
  deaktivierte Felder bg-muted, Avatar ohne Blend, Honig-Check-Rahmen
  über --input.

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 17:23:21 +02:00
1 parent 6edae3efec
commit d8238855fc
91 files changed
+673 -391

No files matched your search

@@ -18,7 +18,7 @@ export const APPEARANCE_KEYS = {
export type ThemeChoice = "light" | "dark" | "system";
export type ContrastChoice = "normal" | "sun";
/** Colour scheme on a device that never chose one (open question 3, answer A). */
/** Colour scheme on a device that never chose one (open question 3, answer A: dark only on request). */
export const DEFAULT_THEME: ThemeChoice = "light";
/** Fired on window after every change of colour scheme or contrast (the 3D scene listens). */
@@ -44,8 +44,6 @@ export interface StoredAppearance {
export interface AppearanceEnvironment {
prefersDark: boolean;
prefersMore: boolean;
/** Dev flag NEXT_PUBLIC_APPEARANCE_DARK=1; removed with task A4. */
darkEnabled: boolean;
}
export interface ResolvedAppearance {
@@ -73,7 +71,7 @@ export function resolveAppearance(
const theme = isThemeChoice(stored.theme) ? stored.theme : DEFAULT_THEME;
const wantsDark = theme === "dark" || (theme === "system" && env.prefersDark);
return {
dark: env.darkEnabled && wantsDark,
dark: wantsDark,
sun: isContrastChoice(stored.contrast) ? stored.contrast === "sun" : env.prefersMore,
motion: isMotionLevel(stored.motion) ? stored.motion : "full",
};
@@ -88,17 +86,17 @@ export function themeColorOf({ dark, sun }: Pick<ResolvedAppearance, "dark" | "s
* Source of the blocking inline script (an IIFE, ES5 so it parses everywhere).
* Only constants go in; nothing from the request or from storage is embedded.
*/
export function appearanceScript(darkEnabled: boolean): string {
export function appearanceScript(): string {
const keys = JSON.stringify(APPEARANCE_KEYS);
const colors = JSON.stringify(THEME_COLORS);
return `(function(){try{
var K=${keys},C=${colors},DEF=${JSON.stringify(DEFAULT_THEME)},DARK=${darkEnabled ? "true" : "false"};
var K=${keys},C=${colors},DEF=${JSON.stringify(DEFAULT_THEME)};
var w=window,root=document.documentElement;
function get(k){try{return w.localStorage.getItem(k)}catch(e){return null}}
function mq(q){try{return w.matchMedia(q).matches}catch(e){return false}}
var t=get(K.theme),c=get(K.contrast),m=get(K.motion);
if(t!=="light"&&t!=="dark"&&t!=="system")t=DEF;
var dark=DARK&&(t==="dark"||(t==="system"&&mq(${JSON.stringify(DARK_QUERY)})));
var dark=t==="dark"||(t==="system"&&mq(${JSON.stringify(DARK_QUERY)}));
var sun=c==="sun"?true:c==="normal"?false:mq(${JSON.stringify(MORE_CONTRAST_QUERY)});
if(m!=="full"&&m!=="subtle"&&m!=="off")m="full";
root.classList.toggle("dark",dark);
@@ -17,7 +17,7 @@ afterEach(() => {
});
describe("resolveAppearance", () => {
const env = { prefersDark: false, prefersMore: false, darkEnabled: true };
const env = { prefersDark: false, prefersMore: false };
it("defaults to light", () => {
expect(DEFAULT_THEME).toBe("light");
@@ -39,8 +39,9 @@ describe("resolveAppearance", () => {
expect(resolveAppearance({ contrast: "sun" }, env).sun).toBe(true);
});
it("dark is ignored without the dev flag", () => {
expect(resolveAppearance({ theme: "dark" }, { ...env, darkEnabled: false }).dark).toBe(false);
it("dark is selectable without a flag and only on request", () => {
expect(resolveAppearance({ theme: "dark" }, env).dark).toBe(true);
expect(resolveAppearance({}, { ...env, prefersDark: true }).dark).toBe(false);
});
it("ignores garbage values", () => {
@@ -65,7 +66,6 @@ describe("useAppearance", () => {
});
it("system follows matchMedia and reacts to a change", () => {
vi.stubEnv("NEXT_PUBLIC_APPEARANCE_DARK", "1");
window.localStorage.setItem("apicore.theme", "system");
const { result } = renderHook(() => useAppearance());
expect(result.current.resolvedTheme).toBe("light");
@@ -120,7 +120,6 @@ describe("useAppearance", () => {
});
it("a storage event from another tab is picked up", () => {
vi.stubEnv("NEXT_PUBLIC_APPEARANCE_DARK", "1");
const { result } = renderHook(() => useAppearance());
act(() => {
@@ -132,13 +131,13 @@ describe("useAppearance", () => {
expect(document.documentElement).toHaveClass("dark");
});
it("dark is ignored without the dev flag", () => {
window.localStorage.setItem("apicore.theme", "dark");
it("dark is selectable without a flag", () => {
const { result } = renderHook(() => useAppearance());
expect(result.current.resolvedTheme).toBe("light");
act(() => result.current.setTheme("dark"));
expect(result.current.resolvedTheme).toBe("light");
expect(document.documentElement).not.toHaveClass("dark");
expect(result.current.resolvedTheme).toBe("dark");
expect(window.localStorage.getItem("apicore.theme")).toBe("dark");
expect(document.documentElement).toHaveClass("dark");
});
it("a motion change reaches useEffectiveMotionLevel in the same tab", () => {
@@ -37,11 +37,6 @@ export interface Appearance {
setMotion(value: MotionLevel): void;
}
/** Whether "Dunkel" may be applied at all (until task A4 brings readable dark values). */
export function isDarkEnabled(): boolean {
return process.env.NEXT_PUBLIC_APPEARANCE_DARK === "1";
}
/** Values chosen in this visit; used when localStorage is blocked (private mode). */
const session: { theme?: string; contrast?: string } = {};
@@ -84,7 +79,7 @@ function readSnapshot(): Snapshot {
/** Same effect as the inline script, for changes after the first paint. */
function applyToDocument(snapshot: Snapshot): void {
const { dark, sun } = resolveAppearance(snapshot, { ...snapshot, darkEnabled: isDarkEnabled() });
const { dark, sun } = resolveAppearance(snapshot, snapshot);
const root = document.documentElement;
root.classList.toggle("dark", dark);
root.setAttribute("data-contrast", sun ? "sun" : "normal");
@@ -168,7 +163,7 @@ export function useAppearance(): Appearance {
const raw = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const state = useMemo(() => {
const parsed = JSON.parse(raw) as Snapshot & { motion: MotionLevel };
const resolved = resolveAppearance(parsed, { ...parsed, darkEnabled: isDarkEnabled() });
const resolved = resolveAppearance(parsed, parsed);
return {
theme: isThemeChoice(parsed.theme) ? parsed.theme : DEFAULT_THEME,
contrast: isContrastChoice(parsed.contrast) ? parsed.contrast : ("normal" as const),
+85 -1
View File
@@ -1,5 +1,40 @@
import { describe, expect, it } from "vitest";
import { AREAS, type AreaKey } from "./areas";
import {
BarChart3,
Bell,
Boxes,
Bug,
CalendarDays,
ClipboardCheck,
Crown,
Database,
Droplet,
Factory,
FlaskConical,
Flower2,
History,
Layers,
LayoutDashboard,
Lightbulb,
ListTodo,
type LucideIcon,
MapPin,
Package,
Scissors,
Search,
ShoppingBag,
Snowflake,
Sprout,
Syringe,
Tag,
UserRound,
Users,
Utensils,
Warehouse,
Wrench,
Zap,
} from "lucide-react";
import { AREAS, type AreaKey, type AreaTone } from "./areas";
import { ADMIN_GROUP, NAV_GROUPS } from "@/app/(app)/nav-config";
import { QUICK_ACTIONS } from "@/app/(app)/quick-actions";
@@ -53,3 +88,52 @@ describe("AREAS", () => {
}
});
});
/** Spec 11, table "Icon und Farbe je Bereich": the 32 areas of the spec, row by row. */
const SPEC_AREAS: ReadonlyArray<[AreaKey, LucideIcon, AreaTone]> = [
["dashboard", LayoutDashboard, "neutral"],
["search", Search, "neutral"],
["apiaries", MapPin, "betrieb"],
["colonies", Layers, "betrieb"],
["queens", Crown, "betrieb"],
["winter", Snowflake, "betrieb"],
["phenology", Flower2, "betrieb"],
["inspections", ClipboardCheck, "erfassung"],
["varroa", Bug, "erfassung"],
["swarm-control", Sprout, "erfassung"],
["treatments", Syringe, "erfassung"],
["feedings", Utensils, "erfassung"],
["tasks", ListTodo, "arbeit"],
["calendar", CalendarDays, "arbeit"],
["suggestions", Lightbulb, "arbeit"],
["notifications", Bell, "arbeit"],
["harvests", Scissors, "ernte"],
["extractions", FlaskConical, "ernte"],
["honey-batches", Droplet, "ernte"],
["bottling", Package, "ernte"],
["products", ShoppingBag, "ernte"],
["raw-materials", Boxes, "ernte"],
["production-batches", Factory, "ernte"],
["storage", Warehouse, "ernte"],
["equipment", Wrench, "ernte"],
["statistics", BarChart3, "neutral"],
["users-roles", Users, "neutral"],
["automation", Zap, "neutral"],
["label-templates", Tag, "neutral"],
["backup", Database, "neutral"],
["audit-log", History, "neutral"],
["profile", UserRound, "neutral"],
];
describe("AREAS against spec 11", () => {
it("lists all 32 rows of the spec table", () => {
expect(SPEC_AREAS).toHaveLength(32);
expect(new Set(SPEC_AREAS.map(([key]) => key)).size).toBe(32);
});
it.each(SPEC_AREAS)("%s has the icon and tone of the spec table", (key, icon, tone) => {
expect(AREAS[key].key).toBe(key);
expect(AREAS[key].icon).toBe(icon);
expect(AREAS[key].tone).toBe(tone);
});
});
+5 -5
View File
@@ -68,11 +68,11 @@ export const COLONY_CONDITION_LABELS: Record<ColonyCondition, string> = {
// Tailwind-Klassen auf Basis der --condition-* Tokens aus globals.css (hell/dunkel-faehig).
export const COLONY_CONDITION_TONE_CLASS: Record<ColonyCondition, string> = {
EXCELLENT: "border-transparent bg-condition-excellent/15 text-condition-excellent-foreground",
GOOD: "border-transparent bg-condition-good/15 text-condition-good-foreground",
AVERAGE: "border-transparent bg-condition-average/15 text-condition-average-foreground",
WEAK: "border-transparent bg-condition-weak/15 text-condition-weak-foreground",
CRITICAL: "border-transparent bg-condition-critical/15 text-condition-critical-foreground",
EXCELLENT: "border-transparent bg-condition-excellent/15 text-condition-excellent-ink",
GOOD: "border-transparent bg-condition-good/15 text-condition-good-ink",
AVERAGE: "border-transparent bg-condition-average/15 text-condition-average-ink",
WEAK: "border-transparent bg-condition-weak/15 text-condition-weak-ink",
CRITICAL: "border-transparent bg-condition-critical/15 text-condition-critical-ink",
};
/** Volksstärke 1-10 (besetzte Waben), as select values named by themselves. */
@@ -1,73 +0,0 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const MIN_CONTRAST = 4.5;
const TONES = ["critical", "weak", "average", "good", "excellent"] as const;
const css = readFileSync(join(process.cwd(), "src/app/globals.css"), "utf8");
function block(selector: string): string {
const start = css.indexOf(`${selector} {`);
return css.slice(start, css.indexOf("\n}", start));
}
function oklch(block: string, token: string): [number, number, number] {
const match = new RegExp(`--${token}:\\s*oklch\\(([\\d.]+) ([\\d.]+) ([\\d.]+)\\)`).exec(block);
if (!match) throw new Error(`token ${token} not found`);
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
/** oklch -> linear sRGB (Ottosson), clipped to gamut. */
function linearRgb([l, c, h]: [number, number, number]): number[] {
const a = c * Math.cos((h * Math.PI) / 180);
const b = c * Math.sin((h * Math.PI) / 180);
const l_ = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3;
const m_ = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3;
const s_ = (l - 0.0894841775 * a - 1.291485548 * b) ** 3;
return [
4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_,
-1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_,
-0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_,
].map((channel) => Math.min(1, Math.max(0, channel)));
}
function luminance(color: [number, number, number]): number {
const [r, g, b] = linearRgb(color);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contrast(a: [number, number, number], b: [number, number, number]): number {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
}
describe.each([
["light", ":root"],
["dark", ".dark"],
])("condition tones in %s mode", (_mode, selector) => {
it.each(TONES)(`%s: text on tone is at least ${MIN_CONTRAST}:1`, (tone) => {
const scope = block(selector);
const ratio = contrast(
oklch(scope, `condition-${tone}`),
oklch(scope, `condition-${tone}-foreground`),
);
expect(ratio).toBeGreaterThanOrEqual(MIN_CONTRAST);
});
});
describe.each([
["light", ":root"],
["dark", ".dark"],
])("hive label in %s mode", (_mode, selector) => {
it(`holo label text on label background is at least ${MIN_CONTRAST}:1`, () => {
const scope = block(selector);
const ratio = contrast(oklch(scope, "holo-label-fg"), oklch(scope, "holo-label-bg"));
expect(ratio).toBeGreaterThanOrEqual(MIN_CONTRAST);
});
it(`holo edge on the screen background is at least ${MIN_CONTRAST}:1`, () => {
const scope = block(selector);
const ratio = contrast(oklch(scope, "holo-edge"), oklch(scope, "holo-bg"));
expect(ratio).toBeGreaterThanOrEqual(MIN_CONTRAST);
});
});