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:
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}
|
||||
|
||||
Reference in new issue
Block a user