feat(t5): Darstellung hell/dunkel/Sonne ohne Flackern (T5a A2)

Blockierendes Skript im <head> setzt Darstellung, Kontrast und
Bewegung vor dem ersten Zeichnen (gleiche Logik wie resolveAppearance,
für alle Eingaben getestet), theme-color je Satz, Wahl je Gerät in
localStorage mit Rückfall bei gesperrtem Speicher; Abgleich über Tabs,
Systemeinstellungen und Seitenwechsel an einer Stelle. Profil-Karte
„Darstellung“, Sonnenmodus im Benutzermenü; Dunkel bleibt bis A4 hinter
NEXT_PUBLIC_APPEARANCE_DARK. next-themes entfernt. ARCHITECTURE §9:
'unsafe-inline' der Frontend-CSP als getragenes Risiko mit Begründung.

Refs #26

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-16 16:35:28 +02:00
1 parent 947a46f435
commit cf53cf35ac
22 files changed
+980 -97

No files matched your search

@@ -0,0 +1,111 @@
/**
* Pure part of the appearance settings (colour scheme, contrast, motion): keys,
* values, the decision function and the inline script source. No "use client"
* on purpose - the server component AppearanceScript calls appearanceScript().
*
* The inline script runs before the first paint and has to decide exactly like
* resolveAppearance(); appearance-script.spec.ts checks both against every
* combination. Keep the two in step.
*/
import type { MotionLevel } from "@/lib/motion/motion-preference";
export const APPEARANCE_KEYS = {
theme: "apicore.theme",
contrast: "apicore.contrast",
motion: "apicore.motion",
} as const;
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). */
export const DEFAULT_THEME: ThemeChoice = "light";
/** Fired on window after every change of colour scheme or contrast (the 3D scene listens). */
export const APPEARANCE_EVENT = "apicore:appearance";
export const DARK_QUERY = "(prefers-color-scheme: dark)";
export const MORE_CONTRAST_QUERY = "(prefers-contrast: more)";
/** Page colour of the four sets: the browser chrome (theme-color) takes it over. */
export const THEME_COLORS = {
light: "#f9f4ea",
dark: "#1a120d",
sunLight: "#ffffff",
sunDark: "#040302",
} as const;
export interface StoredAppearance {
theme?: string | null;
contrast?: string | null;
motion?: string | null;
}
export interface AppearanceEnvironment {
prefersDark: boolean;
prefersMore: boolean;
/** Dev flag NEXT_PUBLIC_APPEARANCE_DARK=1; removed with task A4. */
darkEnabled: boolean;
}
export interface ResolvedAppearance {
dark: boolean;
sun: boolean;
motion: MotionLevel;
}
export function isThemeChoice(value: unknown): value is ThemeChoice {
return value === "light" || value === "dark" || value === "system";
}
export function isContrastChoice(value: unknown): value is ContrastChoice {
return value === "normal" || value === "sun";
}
function isMotionLevel(value: unknown): value is MotionLevel {
return value === "full" || value === "subtle" || value === "off";
}
export function resolveAppearance(
stored: StoredAppearance,
env: AppearanceEnvironment,
): ResolvedAppearance {
const theme = isThemeChoice(stored.theme) ? stored.theme : DEFAULT_THEME;
const wantsDark = theme === "dark" || (theme === "system" && env.prefersDark);
return {
dark: env.darkEnabled && wantsDark,
sun: isContrastChoice(stored.contrast) ? stored.contrast === "sun" : env.prefersMore,
motion: isMotionLevel(stored.motion) ? stored.motion : "full",
};
}
export function themeColorOf({ dark, sun }: Pick<ResolvedAppearance, "dark" | "sun">): string {
if (sun) return dark ? THEME_COLORS.sunDark : THEME_COLORS.sunLight;
return dark ? THEME_COLORS.dark : THEME_COLORS.light;
}
/**
* 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 {
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 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 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);
root.setAttribute("data-contrast",sun?"sun":"normal");
root.setAttribute("data-motion",m);
var color=sun?(dark?C.sunDark:C.sunLight):(dark?C.dark:C.light);
function paint(){var l=document.querySelectorAll('meta[name="theme-color"]');for(var i=0;i<l.length;i++)l[i].setAttribute("content",color);return l.length}
if(!paint())document.addEventListener("DOMContentLoaded",paint);
}catch(e){}})();`;
}
@@ -0,0 +1,185 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useEffectiveMotionLevel } from "@/lib/motion/motion-preference";
import { APPEARANCE_EVENT, DEFAULT_THEME, resolveAppearance } from "./appearance-core";
import { useAppearance } from "./appearance";
import { DARK_QUERY, MORE_QUERY, mockMedia, resetDocument } from "./test-helpers";
let media: ReturnType<typeof mockMedia>;
beforeEach(() => {
resetDocument();
media = mockMedia();
});
afterEach(() => {
media.restore();
resetDocument();
});
describe("resolveAppearance", () => {
const env = { prefersDark: false, prefersMore: false, darkEnabled: true };
it("defaults to light", () => {
expect(DEFAULT_THEME).toBe("light");
expect(resolveAppearance({}, { ...env, prefersDark: true })).toEqual({
dark: false,
sun: false,
motion: "full",
});
});
it("system follows the device scheme", () => {
expect(resolveAppearance({ theme: "system" }, { ...env, prefersDark: true }).dark).toBe(true);
expect(resolveAppearance({ theme: "system" }, env).dark).toBe(false);
});
it("prefers-contrast: more without a stored value selects sun, a stored value wins", () => {
expect(resolveAppearance({}, { ...env, prefersMore: true }).sun).toBe(true);
expect(resolveAppearance({ contrast: "normal" }, { ...env, prefersMore: true }).sun).toBe(false);
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("ignores garbage values", () => {
expect(resolveAppearance({ theme: "x", contrast: "y", motion: "z" }, env)).toEqual({
dark: false,
sun: false,
motion: "full",
});
});
});
describe("useAppearance", () => {
it("defaults to light, normal contrast and full motion", () => {
const { result } = renderHook(() => useAppearance());
expect(result.current).toMatchObject({
theme: "light",
contrast: "normal",
motion: "full",
resolvedTheme: "light",
sunActive: false,
});
});
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");
act(() => media.set(DARK_QUERY, true));
expect(result.current.resolvedTheme).toBe("dark");
expect(document.documentElement).toHaveClass("dark");
});
it("prefers-contrast: more without a stored value selects sun", () => {
media.set(MORE_QUERY, true);
const { result } = renderHook(() => useAppearance());
expect(result.current.sunActive).toBe(true);
expect(result.current.contrast).toBe("normal");
});
it("setContrast stores the value, sets the attribute and announces the change", () => {
const heard = vi.fn();
window.addEventListener(APPEARANCE_EVENT, heard);
const { result } = renderHook(() => useAppearance());
act(() => result.current.setContrast("sun"));
expect(window.localStorage.getItem("apicore.contrast")).toBe("sun");
expect(document.documentElement.dataset.contrast).toBe("sun");
expect(result.current.sunActive).toBe(true);
expect(heard).toHaveBeenCalled();
window.removeEventListener(APPEARANCE_EVENT, heard);
});
it("blocked storage falls back to the default and still applies for this visit", () => {
const getItem = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("blocked");
});
const setItem = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("blocked");
});
try {
const { result } = renderHook(() => useAppearance());
expect(result.current.theme).toBe("light");
expect(result.current.resolvedTheme).toBe("light");
act(() => result.current.setContrast("sun"));
expect(document.documentElement.dataset.contrast).toBe("sun");
expect(result.current.contrast).toBe("sun");
} finally {
getItem.mockRestore();
setItem.mockRestore();
}
});
it("a storage event from another tab is picked up", () => {
vi.stubEnv("NEXT_PUBLIC_APPEARANCE_DARK", "1");
const { result } = renderHook(() => useAppearance());
act(() => {
window.localStorage.setItem("apicore.theme", "dark");
window.dispatchEvent(new StorageEvent("storage", { key: "apicore.theme" }));
});
expect(result.current.theme).toBe("dark");
expect(document.documentElement).toHaveClass("dark");
});
it("dark is ignored without the dev flag", () => {
window.localStorage.setItem("apicore.theme", "dark");
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");
});
it("a motion change reaches useEffectiveMotionLevel in the same tab", () => {
const effective = renderHook(() => useEffectiveMotionLevel());
const appearance = renderHook(() => useAppearance());
act(() => appearance.result.current.setMotion("off"));
expect(effective.result.current).toBe("off");
expect(appearance.result.current.motion).toBe("off");
expect(document.documentElement.dataset.motion).toBe("off");
});
it("removes its listeners on unmount", () => {
const remove = vi.spyOn(window, "removeEventListener");
const { unmount } = renderHook(() => useAppearance());
unmount();
const removed = remove.mock.calls.map(([type]) => type);
expect(removed).toEqual(expect.arrayContaining(["storage", APPEARANCE_EVENT]));
remove.mockRestore();
});
it("a motion change in another tab sets data-motion here", () => {
const { result } = renderHook(() => useAppearance());
act(() => {
window.localStorage.setItem("apicore.motion", "subtle");
window.dispatchEvent(new StorageEvent("storage", { key: "apicore.motion" }));
});
expect(document.documentElement.dataset.motion).toBe("subtle");
expect(result.current.motion).toBe("subtle");
});
it("registers the window listeners once for several mounted hooks", () => {
const add = vi.spyOn(window, "addEventListener");
const first = renderHook(() => useAppearance());
const second = renderHook(() => useAppearance());
expect(add.mock.calls.filter(([type]) => type === "storage")).toHaveLength(1);
first.unmount();
second.unmount();
add.mockRestore();
});
});
@@ -0,0 +1,191 @@
"use client";
import { useCallback, useMemo, useSyncExternalStore } from "react";
import {
MOTION_EVENT,
applyMotionLevel,
readMotionLevel,
setMotionLevel,
type MotionLevel,
} from "@/lib/motion/motion-preference";
import {
APPEARANCE_EVENT,
APPEARANCE_KEYS,
DARK_QUERY,
DEFAULT_THEME,
MORE_CONTRAST_QUERY,
isContrastChoice,
isThemeChoice,
resolveAppearance,
themeColorOf,
type ContrastChoice,
type ThemeChoice,
} from "./appearance-core";
export { APPEARANCE_EVENT, APPEARANCE_KEYS, DEFAULT_THEME, resolveAppearance };
export type { ContrastChoice, ThemeChoice };
export interface Appearance {
theme: ThemeChoice;
contrast: ContrastChoice;
motion: MotionLevel;
resolvedTheme: "light" | "dark";
/** Sun is on: chosen, or implied by the device's "more contrast" setting. */
sunActive: boolean;
setTheme(value: ThemeChoice): void;
setContrast(value: ContrastChoice): void;
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 } = {};
function readKey(name: "theme" | "contrast"): string | null {
try {
return window.localStorage.getItem(APPEARANCE_KEYS[name]) ?? session[name] ?? null;
} catch {
return session[name] ?? null;
}
}
function writeKey(name: "theme" | "contrast", value: string): void {
session[name] = value;
try {
window.localStorage.setItem(APPEARANCE_KEYS[name], value);
} catch {
// Not persisted - still applies for this visit.
}
}
function matches(query: string): boolean {
return typeof window.matchMedia === "function" && window.matchMedia(query).matches;
}
interface Snapshot {
theme: string | null;
contrast: string | null;
prefersDark: boolean;
prefersMore: boolean;
}
function readSnapshot(): Snapshot {
return {
theme: readKey("theme"),
contrast: readKey("contrast"),
prefersDark: matches(DARK_QUERY),
prefersMore: matches(MORE_CONTRAST_QUERY),
};
}
/** 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 root = document.documentElement;
root.classList.toggle("dark", dark);
root.setAttribute("data-contrast", sun ? "sun" : "normal");
const color = themeColorOf({ dark, sun });
document
.querySelectorAll('meta[name="theme-color"]')
.forEach((meta) => meta.setAttribute("content", color));
}
/** Re-applies scheme, contrast and theme-color to the document (e.g. after a route change). */
export function syncAppearanceDocument(): void {
applyToDocument(readSnapshot());
}
// One set of window/media listeners for all mounted hooks: the document is
// synced once per change, not once per useAppearance instance.
const listeners = new Set<() => void>();
let removeGlobalListeners: (() => void) | null = null;
function notifyAll(): void {
listeners.forEach((listener) => listener());
}
function installGlobalListeners(): () => void {
const queries =
typeof window.matchMedia === "function"
? [window.matchMedia(DARK_QUERY), window.matchMedia(MORE_CONTRAST_QUERY)]
: [];
const onExternalChange = () => {
// The device or another tab changed something: follow it on the document too.
syncAppearanceDocument();
applyMotionLevel(readMotionLevel());
notifyAll();
};
const onStorage = (event: StorageEvent) => {
// key === null: storage was cleared.
const ours = Object.values<string>(APPEARANCE_KEYS);
if (event.key === null || ours.includes(event.key)) onExternalChange();
};
queries.forEach((query) => query.addEventListener("change", onExternalChange));
window.addEventListener("storage", onStorage);
window.addEventListener(APPEARANCE_EVENT, notifyAll);
window.addEventListener(MOTION_EVENT, notifyAll);
return () => {
queries.forEach((query) => query.removeEventListener("change", onExternalChange));
window.removeEventListener("storage", onStorage);
window.removeEventListener(APPEARANCE_EVENT, notifyAll);
window.removeEventListener(MOTION_EVENT, notifyAll);
};
}
function subscribe(onChange: () => void): () => void {
if (listeners.size === 0) removeGlobalListeners = installGlobalListeners();
listeners.add(onChange);
return () => {
listeners.delete(onChange);
if (listeners.size === 0) {
removeGlobalListeners?.();
removeGlobalListeners = null;
}
};
}
const SERVER_SNAPSHOT = JSON.stringify({
theme: null,
contrast: null,
prefersDark: false,
prefersMore: false,
motion: "full",
});
function getSnapshot(): string {
return JSON.stringify({ ...readSnapshot(), motion: readMotionLevel() });
}
function getServerSnapshot(): string {
return SERVER_SNAPSHOT;
}
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() });
return {
theme: isThemeChoice(parsed.theme) ? parsed.theme : DEFAULT_THEME,
contrast: isContrastChoice(parsed.contrast) ? parsed.contrast : ("normal" as const),
motion: parsed.motion,
resolvedTheme: resolved.dark ? ("dark" as const) : ("light" as const),
sunActive: resolved.sun,
};
}, [raw]);
const change = useCallback((name: "theme" | "contrast", value: string) => {
writeKey(name, value);
applyToDocument(readSnapshot());
window.dispatchEvent(new Event(APPEARANCE_EVENT));
}, []);
const setTheme = useCallback((value: ThemeChoice) => change("theme", value), [change]);
const setContrast = useCallback((value: ContrastChoice) => change("contrast", value), [change]);
return { ...state, setTheme, setContrast, setMotion: setMotionLevel };
}
@@ -0,0 +1,44 @@
import { vi } from "vitest";
type Listener = () => void;
/** matchMedia stand-in whose queries can be flipped, notifying their listeners. */
export function mockMedia(initial: Record<string, boolean> = {}) {
const state = new Map<string, boolean>(Object.entries(initial));
const listeners = new Map<string, Set<Listener>>();
const original = window.matchMedia;
window.matchMedia = ((query: string) => ({
get matches() {
return state.get(query) ?? false;
},
media: query,
addEventListener: (_: string, listener: Listener) => {
const set = listeners.get(query) ?? new Set<Listener>();
set.add(listener);
listeners.set(query, set);
},
removeEventListener: (_: string, listener: Listener) => listeners.get(query)?.delete(listener),
})) as unknown as typeof window.matchMedia;
return {
set(query: string, matches: boolean) {
state.set(query, matches);
listeners.get(query)?.forEach((listener) => listener());
},
restore() {
window.matchMedia = original;
},
};
}
export function resetDocument() {
const root = document.documentElement;
root.classList.remove("dark");
root.removeAttribute("data-contrast");
root.removeAttribute("data-motion");
document.head.querySelectorAll('meta[name="theme-color"]').forEach((node) => node.remove());
window.localStorage.clear();
vi.unstubAllEnvs();
}
export const DARK_QUERY = "(prefers-color-scheme: dark)";
export const MORE_QUERY = "(prefers-contrast: more)";
@@ -79,22 +79,28 @@ export function useEffectiveMotionLevel(): MotionLevel {
);
}
/** Applies, persists (per device) and announces a new level in this tab. */
export function setMotionLevel(level: MotionLevel): void {
applyMotionLevel(level);
try {
window.localStorage.setItem(STORAGE_KEY, level);
} catch {
// Not persisted - still applies for this visit.
}
window.dispatchEvent(new Event(MOTION_EVENT));
}
export function useMotionPreference(): [MotionLevel, (level: MotionLevel) => void] {
// Lazy read: the app shell renders only on the client after login, and the
// document attribute itself is applied once in Providers.
// document attribute itself is set before the first paint by the inline
// appearance script (components/appearance-script.tsx).
const [level, setLevelState] = useState<MotionLevel>(() =>
typeof window === "undefined" ? DEFAULT_MOTION_LEVEL : readMotionLevel(),
);
const setLevel = useCallback((next: MotionLevel) => {
setLevelState(next);
applyMotionLevel(next);
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
// Not persisted - still applies for this visit.
}
window.dispatchEvent(new Event(MOTION_EVENT));
setMotionLevel(next);
}, []);
return [level, setLevel];