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,122 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
import { APPEARANCE_KEYS, appearanceScript, resolveAppearance } from "@/lib/appearance/appearance-core";
import { resetDocument } from "@/lib/appearance/test-helpers";
import { AppearanceScript } from "./appearance-script";
function run(source: string) {
new Function(source)();
}
function stubMedia(dark: boolean, more: boolean) {
window.matchMedia = ((query: string) => ({
matches: query.includes("color-scheme") ? dark : query.includes("contrast") ? more : false,
})) as unknown as typeof window.matchMedia;
}
const original = window.matchMedia;
beforeEach(resetDocument);
afterEach(() => {
window.matchMedia = original;
resetDocument();
});
describe("appearanceScript", () => {
it("uses the same storage keys as the hook", () => {
const source = appearanceScript(true);
for (const key of Object.values(APPEARANCE_KEYS)) expect(source).toContain(key);
});
it("the inline script sets class, data-contrast and data-motion before first paint", () => {
window.localStorage.setItem(APPEARANCE_KEYS.theme, "dark");
window.localStorage.setItem(APPEARANCE_KEYS.contrast, "sun");
window.localStorage.setItem(APPEARANCE_KEYS.motion, "subtle");
run(appearanceScript(true));
const root = document.documentElement;
expect(root).toHaveClass("dark");
expect(root.dataset.contrast).toBe("sun");
expect(root.dataset.motion).toBe("subtle");
});
it("agrees with resolveAppearance for every combination", () => {
const themes = [null, "light", "dark", "system", "bogus"];
const contrasts = [null, "normal", "sun", "bogus"];
const motions = [null, "full", "subtle", "off", "bogus"];
for (const darkEnabled of [true, false])
for (const prefersDark of [true, false])
for (const prefersMore of [true, false])
for (const theme of themes)
for (const contrast of contrasts)
for (const motion of motions) {
resetDocument();
stubMedia(prefersDark, prefersMore);
if (theme) window.localStorage.setItem(APPEARANCE_KEYS.theme, theme);
if (contrast) window.localStorage.setItem(APPEARANCE_KEYS.contrast, contrast);
if (motion) window.localStorage.setItem(APPEARANCE_KEYS.motion, motion);
run(appearanceScript(darkEnabled));
const expected = resolveAppearance(
{ theme, contrast, motion },
{ prefersDark, prefersMore, darkEnabled },
);
const root = document.documentElement;
expect(root.classList.contains("dark")).toBe(expected.dark);
expect(root.dataset.contrast).toBe(expected.sun ? "sun" : "normal");
expect(root.dataset.motion).toBe(expected.motion);
}
});
it("blocked storage falls back to the default", () => {
const real = Object.getOwnPropertyDescriptor(window, "localStorage")!;
Object.defineProperty(window, "localStorage", {
configurable: true,
get() {
throw new Error("blocked");
},
});
try {
run(appearanceScript(true));
} finally {
Object.defineProperty(window, "localStorage", real);
}
const root = document.documentElement;
expect(root).not.toHaveClass("dark");
expect(root.dataset.contrast).toBe("normal");
expect(root.dataset.motion).toBe("full");
});
it("sets the theme-color meta to the page colour of the active set", () => {
const meta = document.createElement("meta");
meta.name = "theme-color";
meta.content = "#000000";
document.head.append(meta);
const cases: [string, string, string][] = [
["light", "normal", "#f9f4ea"],
["dark", "normal", "#1a120d"],
["light", "sun", "#ffffff"],
["dark", "sun", "#040302"],
];
for (const [theme, contrast, color] of cases) {
window.localStorage.setItem(APPEARANCE_KEYS.theme, theme);
window.localStorage.setItem(APPEARANCE_KEYS.contrast, contrast);
run(appearanceScript(true));
expect(meta.content).toBe(color);
}
});
it("contains no stored or user data, only constants", () => {
window.localStorage.setItem(APPEARANCE_KEYS.theme, "secret-value");
expect(appearanceScript(true)).not.toContain("secret-value");
});
});
describe("AppearanceScript", () => {
it("renders the script source as an inline script", () => {
const { container } = render(<AppearanceScript />);
const script = container.querySelector("script");
expect(script?.innerHTML).toContain(APPEARANCE_KEYS.theme);
});
});
@@ -0,0 +1,22 @@
import { appearanceScript } from "@/lib/appearance/appearance-core";
/**
* Blocking inline script in the <head> of the root layout: sets the colour
* scheme, the contrast and the motion level on <html> before the first paint,
* so a stored choice never flashes the wrong look (Next.js guide "Preventing
* Flash"). A Server Component on purpose - the script must be part of the HTML.
*
* CSP: script-src 'self' 'unsafe-inline' (src/proxy.ts) already allows it; the
* content is a constant. No hash goes into the CSP: as soon as script-src lists
* a hash or nonce, browsers ignore 'unsafe-inline' and Next.js' own inline
* bootstrap scripts would be blocked.
*/
export function AppearanceScript() {
return (
<script
dangerouslySetInnerHTML={{
__html: appearanceScript(process.env.NEXT_PUBLIC_APPEARANCE_DARK === "1"),
}}
/>
);
}
@@ -0,0 +1,27 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { resetDocument } from "@/lib/appearance/test-helpers";
import { AppearanceSync } from "./appearance-sync";
let pathname = "/a";
vi.mock("next/navigation", () => ({ usePathname: () => pathname }));
beforeEach(resetDocument);
afterEach(resetDocument);
describe("AppearanceSync", () => {
it("puts the chosen theme-color back after a route change", () => {
const meta = document.createElement("meta");
meta.name = "theme-color";
document.head.append(meta);
window.localStorage.setItem("apicore.contrast", "sun");
const { rerender } = render(<AppearanceSync />);
expect(meta.content).toBe("#ffffff");
meta.content = "#f9f4ea"; // Next's metadata update
pathname = "/b";
rerender(<AppearanceSync />);
expect(meta.content).toBe("#ffffff");
});
});
@@ -0,0 +1,18 @@
"use client";
import { useEffect } from "react";
import { usePathname } from "next/navigation";
import { syncAppearanceDocument } from "@/lib/appearance/appearance";
/**
* Re-applies the appearance after every route change. Next.js updates the
* viewport metadata (theme-color) on client navigations and could put the
* build-time colours back over the ones the inline script chose.
*/
export function AppearanceSync() {
const pathname = usePathname();
useEffect(() => {
syncAppearanceDocument();
}, [pathname]);
return null;
}
@@ -1,6 +1,6 @@
"use client"
import { useTheme } from "next-themes"
import { useAppearance } from "@/lib/appearance/appearance"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
@@ -12,11 +12,11 @@ import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon
const TOAST_OFFSET = { bottom: "var(--toast-offset-bottom)", left: 16, right: 16 };
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
const { resolvedTheme } = useAppearance()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
theme={resolvedTheme}
className="toaster group"
position="bottom-right"
offset={TOAST_OFFSET}