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" + > + }> 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“. - - - -
- Bewegung und Effekte - {MOTION_LEVELS.map((option) => ( - - ))} -
-
-
- ); -} 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 ( +