diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index db01161..6a26cc5 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -339,6 +339,7 @@ Grundlage: OWASP API Security Top 10 (2023) und OWASP ASVS Level 2. Umsetzung un
| Ausgehende Aufrufe | Webhooks: nie aus der Anfrage heraus gesendet, sondern über die Zustelltabelle `WebhookDelivery` (Warteschlange und Zustellprotokoll), die der Worker abarbeitet; Ziel-IP beim Verbindungsaufbau geprüft (kein DNS-Rebinding), nur Port 80/443, https in Produktion, keine Weiterleitungen, 5 s Leerlauf-Timeout und 10 s Frist je Versuch (danach wird die Verbindung abgebrochen); nur eine `2xx`-Antwort gilt als zugestellt, sonst Wiederholung nach der Outbox-Regel (30 s bis 6 h, nach 8 Versuchen `FAILED`); stabile Delivery-ID (`X-ApiCore-Delivery` = `WebhookDelivery.id`, mit gleichem Body über alle Wiederholungen); Signatur über Zeitstempel+ID+Body; Antwortkörper wird nie gelesen, `lastError` nur über `loggableError()` |
| Wetter und Telegram | Wetter (Open-Meteo) nur aus der Worker-Schleife `phenology-weather` und der Wetter-Anzeige, über `src/weather/`: nur https-URLs aus der Konfiguration, keine Weiterleitungen (`redirect: 'error'`), 8 s Frist, Antwort höchstens 1 000 000 Byte, Fehlertexte fest (kein URL, keine Ursache, kein Schlüssel). Telegram (`api.telegram.org`) nur aus Worker-Schleifen über `TelegramClient` (`src/telegram/`): Klartext ohne `parse_mode`, Linkvorschau aus, Text höchstens 600 Zeichen ohne Steuer- und Richtungszeichen, keine Weiterleitungen, Antwort höchstens 256 kB; der Token steht nie in Logs oder Fehlern (`TelegramApiError` trägt Methode, Status, bereinigte Beschreibung, `retry_after` 1–3600 s, keine URL). Wächter `src/common/no-network-outside-loops.spec.ts`: Wetter-Hosts nur in `src/weather/`, `api.telegram.org` nur in `src/telegram/`, `fetch(` und Netzwerkpakete nur in den erlaubten Dateien (Graph-Client, Passwort-Prüfung, Webhook-Zustellung, Worker-Health, Wetter, Telegram), nie in Handlern oder Jobs; `handler-contract.spec.ts` verbietet den Import von `telegram-client` in Handlern |
| Transport/Antworten | Alle API-Antworten tragen `Cache-Control: no-store` und `Pragma: no-cache`; einzige Ausnahme ist die erfolgreiche (200) öffentliche Herkunftsantwort `GET /public/batches/:lot` mit `Cache-Control: public, max-age=300, s-maxage=3600` (ohne Pragma, am Cloudflare-Edge cachebar), Fehlerantworten bleiben `no-store`; `Cache-Control: no-store` (außer öffentliche Chargen-Seite), CSP `default-src 'none'`, `frame-ancestors 'none'`, keine ETags; Produktion startet nur mit https-`FRONTEND_ORIGIN` und sicheren Cookies |
+| Frontend-CSP | `src/proxy.ts` setzt für die Oberfläche `script-src 'self' 'unsafe-inline'` und `style-src 'self' 'unsafe-inline'` (dazu u. a. `media-src 'self' blob:` für die eigene Aufnahme). `'unsafe-inline'` ist ein getragenes Risiko: Next.js liefert eigene Inline-Bootstrap-Skripte, und sobald `script-src` einen Hash oder Nonce enthält, ignorieren Browser `'unsafe-inline'` und blockieren diese; ein Nonce würde jede Seite dynamisch rendern. Das Darstellungs-Skript im `
` (T5 A2) ist eine Konstante ohne Nutzerdaten. Wird die CSP verschärft, Hash für `appearanceScript()` ergänzen. |
| Rate-Limits | Throttler vor der Authentifizierung, Zähler in der UNLOGGED-Tabelle `ThrottlerHit` und damit über alle Pods geteilt (eine Upsert-Anweisung, ausschließlich DB-Uhr `now()`; 100/min, Login/MFA/Refresh/Logout 10/min, öffentliche Chargen 30/min); Client-IP über `TRUST_PROXY` (IPv6 je /64); bei DB-Fehler schlägt die Anfrage fehl (fail-closed) – nur die Liveness `GET /health` ist ausgenommen, damit ein DB-Ausfall nicht alle Pods neu startet; abgelaufene Zeilen räumt der Job `cleanup` auf |
| Daten | Backups verschlüsselt und signiert, Dateien 0600; CSV-Export ohne Formel-Injection; Logs ohne Query-Strings |
diff --git a/ApiCore-Frontend/package-lock.json b/ApiCore-Frontend/package-lock.json
index 95f330a..6d171cf 100644
--- a/ApiCore-Frontend/package-lock.json
+++ b/ApiCore-Frontend/package-lock.json
@@ -20,7 +20,6 @@
"lucide-react": "^1.33.0",
"motion": "13.4.6",
"next": "16.3.8",
- "next-themes": "^0.4.6",
"postprocessing": "6.39.5",
"react": "19.2.8",
"react-dom": "19.2.8",
@@ -9603,16 +9602,6 @@
}
}
},
- "node_modules/next-themes": {
- "version": "0.4.6",
- "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
- "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
- "license": "MIT",
- "peerDependencies": {
- "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
- }
- },
"node_modules/next/node_modules/postcss": {
"version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
diff --git a/ApiCore-Frontend/package.json b/ApiCore-Frontend/package.json
index c5c1ec3..088e049 100644
--- a/ApiCore-Frontend/package.json
+++ b/ApiCore-Frontend/package.json
@@ -25,7 +25,6 @@
"lucide-react": "^1.33.0",
"motion": "13.4.6",
"next": "16.3.8",
- "next-themes": "^0.4.6",
"postprocessing": "6.39.5",
"react": "19.2.8",
"react-dom": "19.2.8",
diff --git a/ApiCore-Frontend/src/app/(app)/layout.spec.tsx b/ApiCore-Frontend/src/app/(app)/layout.spec.tsx
index 5009dae..b195b5e 100644
--- a/ApiCore-Frontend/src/app/(app)/layout.spec.tsx
+++ b/ApiCore-Frontend/src/app/(app)/layout.spec.tsx
@@ -4,8 +4,11 @@ import userEvent from "@testing-library/user-event";
import { Card, CardContent } from "@/components/ui/card";
import { StickySubmit } from "@/components/form/sticky-submit";
import { finishHandOvers, purgeStaleDraftPhotos } from "@/lib/photos/store";
+import { toast } from "sonner";
import AppLayout from "./layout";
+vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }));
+
const pushMock = vi.fn();
let pathname = "/inspections/new";
vi.mock("next/navigation", () => ({
@@ -226,6 +229,29 @@ describe("AppLayout", () => {
);
});
+ it("the user menu offers Sonnenmodus as menuitemcheckbox and toasts Sonnenmodus an", async () => {
+ window.localStorage.clear();
+ render(
+
+
Seiteninhalt
+ ,
+ );
+
+ await userEvent.click(screen.getByRole("button", { name: "Konto-Menü" }));
+ const item = await screen.findByRole("menuitemcheckbox", { name: /Sonnenmodus/ });
+ expect(item).toHaveAttribute("aria-checked", "false");
+ await userEvent.click(item);
+
+ expect(document.documentElement.dataset.contrast).toBe("sun");
+ expect(window.localStorage.getItem("apicore.contrast")).toBe("sun");
+ expect(toast.success).toHaveBeenCalledWith("Sonnenmodus an");
+
+ // A checkbox item keeps the menu open: the second tap switches it off again.
+ await userEvent.click(await screen.findByRole("menuitemcheckbox", { name: /Sonnenmodus/ }));
+ expect(document.documentElement.dataset.contrast).toBe("normal");
+ expect(toast.success).toHaveBeenCalledWith("Sonnenmodus aus");
+ });
+
it("gives the band the page content wrapper as a stable focus target", () => {
render(
diff --git a/ApiCore-Frontend/src/app/(app)/layout.tsx b/ApiCore-Frontend/src/app/(app)/layout.tsx
index 18ea5fe..ec6574c 100644
--- a/ApiCore-Frontend/src/app/(app)/layout.tsx
+++ b/ApiCore-Frontend/src/app/(app)/layout.tsx
@@ -3,8 +3,10 @@
import { ReactNode, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
-import { Hexagon } from "lucide-react";
+import { Hexagon, Sun } from "lucide-react";
+import { toast } from "sonner";
import { useAuth } from "@/lib/auth/auth-context";
+import { useAppearance } from "@/lib/appearance/appearance";
import { finishHandOvers, purgeStaleDraftPhotos } from "@/lib/photos/store";
import { LogoutConfirmDialog } from "@/components/auth/logout-confirm-dialog";
import { NotificationBell } from "@/components/notification-bell";
@@ -12,6 +14,7 @@ import { OfflineBand } from "@/components/offline/offline-band";
import { UserAvatar } from "@/components/user-avatar";
import {
DropdownMenu,
+ DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
@@ -32,6 +35,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
const pathname = usePathname();
const { user, isLoading, sessionMode } = useAuth();
const [isLogoutOpen, setIsLogoutOpen] = useState(false);
+ const { sunActive, setContrast } = useAppearance();
// Focus lands here when the offline queue sheet closes and nothing else is left.
const pageContentRef = useRef(null);
@@ -113,6 +117,17 @@ export default function AppLayout({ children }: { children: ReactNode }) {
+ {
+ setContrast(checked ? "sun" : "normal");
+ toast.success(checked ? "Sonnenmodus an" : "Sonnenmodus aus");
+ }}
+ className="min-h-11"
+ >
+
+ Sonnenmodus
+ }>
Profil
diff --git a/ApiCore-Frontend/src/app/(app)/profile/appearance-section.spec.tsx b/ApiCore-Frontend/src/app/(app)/profile/appearance-section.spec.tsx
new file mode 100644
index 0000000..33c6248
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/profile/appearance-section.spec.tsx
@@ -0,0 +1,58 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { mockMedia, resetDocument } from "@/lib/appearance/test-helpers";
+import { AppearanceSection } from "./appearance-section";
+
+let media: ReturnType;
+beforeEach(() => {
+ resetDocument();
+ media = mockMedia();
+});
+afterEach(() => {
+ media.restore();
+ resetDocument();
+});
+
+describe("AppearanceSection", () => {
+ it("choosing Sonne sets data-contrast on the root and stores it", async () => {
+ render();
+
+ await userEvent.click(screen.getByRole("radio", { name: /^Sonne/ }));
+
+ expect(document.documentElement.dataset.contrast).toBe("sun");
+ expect(window.localStorage.getItem("apicore.contrast")).toBe("sun");
+ expect(screen.getByRole("radio", { name: /^Sonne/ })).toBeChecked();
+ });
+
+ it("says that the setting only applies to this device", () => {
+ render();
+ expect(screen.getByText("Gilt nur für dieses Gerät.")).toBeInTheDocument();
+ });
+
+ it("hides Dunkel and Wie Gerät without the dev flag", () => {
+ render();
+ expect(screen.getByRole("radio", { name: /^Hell/ })).toBeInTheDocument();
+ expect(screen.queryByRole("radio", { name: /^Dunkel/ })).not.toBeInTheDocument();
+ expect(screen.queryByRole("radio", { name: /^Wie Gerät/ })).not.toBeInTheDocument();
+ });
+
+ it("offers Dunkel and Wie Gerät with the dev flag", async () => {
+ vi.stubEnv("NEXT_PUBLIC_APPEARANCE_DARK", "1");
+ render();
+
+ await userEvent.click(screen.getByRole("radio", { name: /^Dunkel/ }));
+
+ expect(document.documentElement).toHaveClass("dark");
+ expect(screen.getByRole("radio", { name: /^Wie Gerät/ })).toBeInTheDocument();
+ });
+
+ it("changes the motion level to Dezent and applies it", async () => {
+ render();
+
+ await userEvent.click(screen.getByRole("radio", { name: "Dezent" }));
+
+ expect(document.documentElement.dataset.motion).toBe("subtle");
+ expect(window.localStorage.getItem("apicore.motion")).toBe("subtle");
+ });
+});
diff --git a/ApiCore-Frontend/src/app/(app)/profile/appearance-section.tsx b/ApiCore-Frontend/src/app/(app)/profile/appearance-section.tsx
new file mode 100644
index 0000000..8bee795
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/profile/appearance-section.tsx
@@ -0,0 +1,99 @@
+"use client";
+
+import { useId } from "react";
+import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ isDarkEnabled,
+ useAppearance,
+ type ContrastChoice,
+ type ThemeChoice,
+} from "@/lib/appearance/appearance";
+import { MOTION_LEVELS, type MotionLevel } from "@/lib/motion/motion-preference";
+
+const OPTION_CLASS = "h-auto justify-start px-3 py-2 text-left";
+
+const THEME_OPTIONS: readonly ChoiceOption[] = [
+ { value: "light", label: "Hell – am besten bei Tageslicht" },
+ { value: "dark", label: "Dunkel – für abends" },
+ { value: "system", label: "Wie Gerät" },
+];
+
+const CONTRAST_OPTIONS: readonly ChoiceOption[] = [
+ { value: "normal", label: "Normal" },
+ { value: "sun", label: "Sonne – maximaler Kontrast, größere Schrift, für draußen" },
+];
+
+const MOTION_LABELS: Record = { full: "Voll", subtle: "Dezent", off: "Aus" };
+const MOTION_DESCRIPTIONS: Record = {
+ full: "Rückmeldung beim Abhaken und Speichern, weiche Übergänge.",
+ subtle: "Nur kurzes Ein- und Ausblenden, nichts bewegt sich über den Bildschirm.",
+ off: "Alles ändert sich sofort. Zustände bleiben an Symbol und Text erkennbar.",
+};
+const MOTION_OPTIONS: readonly ChoiceOption[] = MOTION_LEVELS.map((value) => ({
+ value,
+ label: MOTION_LABELS[value],
+}));
+
+export function AppearanceSection() {
+ const { theme, contrast, motion, setTheme, setContrast, setMotion } = useAppearance();
+ const ids = { theme: useId(), contrast: useId(), motion: useId(), motionHint: useId() };
+ // Dark has unreadable old values until task A4; the dev flag shows it for work on A4.
+ const themeOptions = isDarkEnabled() ? THEME_OPTIONS : THEME_OPTIONS.slice(0, 1);
+
+ return (
+
+
+ Darstellung
+
+ Die Änderung wirkt sofort. Ist am Gerät „Bewegung reduzieren“ eingeschaltet, wird
+ „Voll“ automatisch zu „Dezent“.
+
+
+
+
+
+ Farbschema
+
+
+
+
+
+ Kontrast
+
+
+
+
+
+ Bewegung & Effekte
+
+
+
+ {MOTION_DESCRIPTIONS[motion]}
+
+
+
Gilt nur für dieses Gerät.
+
+
+ );
+}
diff --git a/ApiCore-Frontend/src/app/(app)/profile/motion-section.tsx b/ApiCore-Frontend/src/app/(app)/profile/motion-section.tsx
deleted file mode 100644
index f4c6732..0000000
--- a/ApiCore-Frontend/src/app/(app)/profile/motion-section.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-"use client";
-
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
-import { MOTION_LEVELS, MotionLevel, useMotionPreference } from "@/lib/motion/motion-preference";
-import { cn } from "@/lib/utils";
-
-const OPTIONS: Record = {
- full: {
- label: "Voll",
- description: "Rückmeldung beim Abhaken und Speichern, weiche Übergänge.",
- },
- subtle: {
- label: "Dezent",
- description: "Nur kurzes Ein- und Ausblenden, nichts bewegt sich über den Bildschirm.",
- },
- off: {
- label: "Aus",
- description: "Alles ändert sich sofort. Zustände bleiben an Symbol und Text erkennbar.",
- },
-};
-
-export function MotionSection() {
- const [level, setLevel] = useMotionPreference();
-
- return (
-
-
- Bewegung & Effekte
-
- Gilt für dieses Gerät. Ist am Gerät „Bewegung reduzieren“ eingeschaltet, wird „Voll“
- automatisch zu „Dezent“.
-
-
-
-
-
-
- );
-}
diff --git a/ApiCore-Frontend/src/app/(app)/profile/page.tsx b/ApiCore-Frontend/src/app/(app)/profile/page.tsx
index 8c92a0c..94b1340 100644
--- a/ApiCore-Frontend/src/app/(app)/profile/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/profile/page.tsx
@@ -27,7 +27,7 @@ import {
} from "@/components/ui/card";
import { MfaSection } from "./mfa-section";
import { DangerZoneSection } from "./danger-zone-section";
-import { MotionSection } from "./motion-section";
+import { AppearanceSection } from "./appearance-section";
import { NotificationSettingsCard } from "./notification-settings-card";
import { TelegramLinkCard } from "./telegram-link-card";
@@ -309,7 +309,7 @@ export default function ProfilePage() {
void refreshUser()} />
-
+
diff --git a/ApiCore-Frontend/src/app/layout.tsx b/ApiCore-Frontend/src/app/layout.tsx
index c281aff..0e63e96 100644
--- a/ApiCore-Frontend/src/app/layout.tsx
+++ b/ApiCore-Frontend/src/app/layout.tsx
@@ -4,6 +4,9 @@ import "./globals.css";
import { Providers } from "./providers";
import { Toaster } from "@/components/ui/sonner";
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
+import { AppearanceScript } from "@/components/appearance-script";
+import { AppearanceSync } from "@/components/appearance-sync";
+import { THEME_COLORS } from "@/lib/appearance/appearance-core";
// Self-hosted (OFL-1.1, see ./fonts/OFL.txt): no Google request at build or runtime.
const spaceGrotesk = localFont({
@@ -40,18 +43,31 @@ export const viewport: Viewport = {
// Without "cover" iOS reports env(safe-area-inset-*) as 0, and the bottom
// nav and save bar would sit under the home indicator.
viewportFit: "cover",
+ // The inline appearance script overwrites these when a manual choice differs
+ // from the device setting.
+ themeColor: [
+ { media: "(prefers-color-scheme: light)", color: THEME_COLORS.light },
+ { media: "(prefers-color-scheme: dark)", color: THEME_COLORS.dark },
+ ],
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
+
+
+
{children}
+
);
diff --git a/ApiCore-Frontend/src/app/providers.spec.tsx b/ApiCore-Frontend/src/app/providers.spec.tsx
index ca60246..f4dd677 100644
--- a/ApiCore-Frontend/src/app/providers.spec.tsx
+++ b/ApiCore-Frontend/src/app/providers.spec.tsx
@@ -2,6 +2,8 @@ import { useMutation } from "@tanstack/react-query";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
+import { readFileSync } from "node:fs";
+import path from "node:path";
import { Providers } from "./providers";
vi.mock("@/lib/auth/auth-context", () => ({
@@ -37,4 +39,26 @@ describe("Providers", () => {
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(mutationFn).toHaveBeenCalledTimes(1));
});
+
+ it("does not apply the motion level itself any more (the inline script does)", () => {
+ document.documentElement.removeAttribute("data-motion");
+ window.localStorage.setItem("apicore.motion", "off");
+ render(
+
+
x
+ ,
+ );
+ expect(document.documentElement).not.toHaveAttribute("data-motion");
+ window.localStorage.clear();
+ });
+});
+
+describe("theme handling", () => {
+ it("the toaster follows resolvedTheme and next-themes is not imported", () => {
+ const toaster = readFileSync(path.resolve(__dirname, "../components/ui/sonner.tsx"), "utf8");
+ expect(toaster).toContain("resolvedTheme");
+ expect(toaster).not.toContain("next-themes");
+ const pkg = JSON.parse(readFileSync(path.resolve(__dirname, "../../package.json"), "utf8"));
+ expect(pkg.dependencies["next-themes"]).toBeUndefined();
+ });
});
diff --git a/ApiCore-Frontend/src/app/providers.tsx b/ApiCore-Frontend/src/app/providers.tsx
index 97df89a..6b08270 100644
--- a/ApiCore-Frontend/src/app/providers.tsx
+++ b/ApiCore-Frontend/src/app/providers.tsx
@@ -1,10 +1,9 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { useEffect, useState } from "react";
+import { useState } from "react";
import { ApiError } from "@/lib/auth/api-client";
import { AuthProvider } from "@/lib/auth/auth-context";
-import { applyMotionLevel, readMotionLevel } from "@/lib/motion/motion-preference";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
@@ -23,10 +22,6 @@ export function Providers({ children }: { children: React.ReactNode }) {
}),
);
- useEffect(() => {
- applyMotionLevel(readMotionLevel());
- }, []);
-
return (
{children}
diff --git a/ApiCore-Frontend/src/components/appearance-script.spec.tsx b/ApiCore-Frontend/src/components/appearance-script.spec.tsx
new file mode 100644
index 0000000..b57cce9
--- /dev/null
+++ b/ApiCore-Frontend/src/components/appearance-script.spec.tsx
@@ -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();
+ const script = container.querySelector("script");
+ expect(script?.innerHTML).toContain(APPEARANCE_KEYS.theme);
+ });
+});
diff --git a/ApiCore-Frontend/src/components/appearance-script.tsx b/ApiCore-Frontend/src/components/appearance-script.tsx
new file mode 100644
index 0000000..892bacc
--- /dev/null
+++ b/ApiCore-Frontend/src/components/appearance-script.tsx
@@ -0,0 +1,22 @@
+import { appearanceScript } from "@/lib/appearance/appearance-core";
+
+/**
+ * Blocking inline script in the of the root layout: sets the colour
+ * scheme, the contrast and the motion level on 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 (
+
+ );
+}
diff --git a/ApiCore-Frontend/src/components/appearance-sync.spec.tsx b/ApiCore-Frontend/src/components/appearance-sync.spec.tsx
new file mode 100644
index 0000000..0cf7ef8
--- /dev/null
+++ b/ApiCore-Frontend/src/components/appearance-sync.spec.tsx
@@ -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();
+ expect(meta.content).toBe("#ffffff");
+
+ meta.content = "#f9f4ea"; // Next's metadata update
+ pathname = "/b";
+ rerender();
+
+ expect(meta.content).toBe("#ffffff");
+ });
+});
diff --git a/ApiCore-Frontend/src/components/appearance-sync.tsx b/ApiCore-Frontend/src/components/appearance-sync.tsx
new file mode 100644
index 0000000..ed549b6
--- /dev/null
+++ b/ApiCore-Frontend/src/components/appearance-sync.tsx
@@ -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;
+}
diff --git a/ApiCore-Frontend/src/components/ui/sonner.tsx b/ApiCore-Frontend/src/components/ui/sonner.tsx
index ea4700b..e7bd80c 100644
--- a/ApiCore-Frontend/src/components/ui/sonner.tsx
+++ b/ApiCore-Frontend/src/components/ui/sonner.tsx
@@ -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 (
): 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;
+
+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();
+ });
+});
diff --git a/ApiCore-Frontend/src/lib/appearance/appearance.ts b/ApiCore-Frontend/src/lib/appearance/appearance.ts
new file mode 100644
index 0000000..09a6c73
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/appearance/appearance.ts
@@ -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(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 };
+}
diff --git a/ApiCore-Frontend/src/lib/appearance/test-helpers.ts b/ApiCore-Frontend/src/lib/appearance/test-helpers.ts
new file mode 100644
index 0000000..482bf2a
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/appearance/test-helpers.ts
@@ -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 = {}) {
+ const state = new Map(Object.entries(initial));
+ const listeners = new Map>();
+ 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();
+ 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)";
diff --git a/ApiCore-Frontend/src/lib/motion/motion-preference.ts b/ApiCore-Frontend/src/lib/motion/motion-preference.ts
index a22f0a8..fa52c63 100644
--- a/ApiCore-Frontend/src/lib/motion/motion-preference.ts
+++ b/ApiCore-Frontend/src/lib/motion/motion-preference.ts
@@ -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(() =>
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];