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

+1
View File
@@ -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 `<head>` (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 |
-11
View File
@@ -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",
-1
View File
@@ -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",
@@ -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(
<AppLayout>
<p>Seiteninhalt</p>
</AppLayout>,
);
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(
<AppLayout>
+16 -1
View File
@@ -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<HTMLDivElement>(null);
@@ -113,6 +117,17 @@ export default function AppLayout({ children }: { children: ReactNode }) {
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={sunActive}
onCheckedChange={(checked) => {
setContrast(checked ? "sun" : "normal");
toast.success(checked ? "Sonnenmodus an" : "Sonnenmodus aus");
}}
className="min-h-11"
>
<Sun aria-hidden="true" />
Sonnenmodus
</DropdownMenuCheckboxItem>
<DropdownMenuItem render={<Link href="/profile" />}>
Profil
</DropdownMenuItem>
@@ -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<typeof mockMedia>;
beforeEach(() => {
resetDocument();
media = mockMedia();
});
afterEach(() => {
media.restore();
resetDocument();
});
describe("AppearanceSection", () => {
it("choosing Sonne sets data-contrast on the root and stores it", async () => {
render(<AppearanceSection />);
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(<AppearanceSection />);
expect(screen.getByText("Gilt nur für dieses Gerät.")).toBeInTheDocument();
});
it("hides Dunkel and Wie Gerät without the dev flag", () => {
render(<AppearanceSection />);
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(<AppearanceSection />);
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(<AppearanceSection />);
await userEvent.click(screen.getByRole("radio", { name: "Dezent" }));
expect(document.documentElement.dataset.motion).toBe("subtle");
expect(window.localStorage.getItem("apicore.motion")).toBe("subtle");
});
});
@@ -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<ThemeChoice>[] = [
{ 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<ContrastChoice>[] = [
{ value: "normal", label: "Normal" },
{ value: "sun", label: "Sonne – maximaler Kontrast, größere Schrift, für draußen" },
];
const MOTION_LABELS: Record<MotionLevel, string> = { full: "Voll", subtle: "Dezent", off: "Aus" };
const MOTION_DESCRIPTIONS: Record<MotionLevel, string> = {
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<MotionLevel>[] = 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 (
<Card>
<CardHeader>
<CardTitle>Darstellung</CardTitle>
<CardDescription>
Die Änderung wirkt sofort. Ist am Gerät „Bewegung reduzieren“ eingeschaltet, wird
„Voll“ automatisch zu „Dezent“.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<p id={ids.theme} className="font-medium">
Farbschema
</p>
<ChoiceGroup
options={themeOptions}
value={theme}
onChange={setTheme}
labelledBy={ids.theme}
optionClassName={OPTION_CLASS}
/>
</div>
<div className="flex flex-col gap-2">
<p id={ids.contrast} className="font-medium">
Kontrast
</p>
<ChoiceGroup
options={CONTRAST_OPTIONS}
value={contrast}
onChange={setContrast}
labelledBy={ids.contrast}
optionClassName={OPTION_CLASS}
/>
</div>
<div className="flex flex-col gap-2">
<p id={ids.motion} className="font-medium">
Bewegung &amp; Effekte
</p>
<ChoiceGroup
options={MOTION_OPTIONS}
value={motion}
onChange={setMotion}
labelledBy={ids.motion}
describedBy={ids.motionHint}
className="grid-cols-3"
optionClassName="px-3 py-2"
/>
<p id={ids.motionHint} className="text-sm text-muted-foreground">
{MOTION_DESCRIPTIONS[motion]}
</p>
</div>
<p className="text-sm text-muted-foreground">Gilt nur für dieses Gerät.</p>
</CardContent>
</Card>
);
}
@@ -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<MotionLevel, { label: string; description: string }> = {
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 (
<Card>
<CardHeader>
<CardTitle>Bewegung &amp; Effekte</CardTitle>
<CardDescription>
Gilt für dieses Gerät. Ist am Gerät „Bewegung reduzieren“ eingeschaltet, wird „Voll“
automatisch zu „Dezent“.
</CardDescription>
</CardHeader>
<CardContent>
<fieldset className="flex flex-col gap-2">
<legend className="sr-only">Bewegung und Effekte</legend>
{MOTION_LEVELS.map((option) => (
<label
key={option}
className={cn(
"flex min-h-11 cursor-pointer items-start gap-3 rounded-xl border p-3 transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-ring",
level === option
? "border-primary/60 bg-primary/8"
: "border-border hover:bg-muted",
)}
>
<input
type="radio"
name="motion-level"
value={option}
checked={level === option}
onChange={() => setLevel(option)}
className="mt-1 size-4 accent-[var(--primary)]"
/>
<span className="flex flex-col">
<span className="font-medium">{OPTIONS[option].label}</span>
<span className="text-sm text-muted-foreground">{OPTIONS[option].description}</span>
</span>
</label>
))}
</fieldset>
</CardContent>
</Card>
);
}
@@ -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() {
<MfaSection mfaEnabled={user.mfaEnabled} onChanged={() => void refreshUser()} />
<NotificationSettingsCard />
<TelegramLinkCard />
<MotionSection />
<AppearanceSection />
<DangerZoneSection />
</div>
</main>
+16
View File
@@ -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 (
<html
lang="de"
// The inline script sets class="dark", data-contrast and data-motion
// before hydration; React must keep the document's values.
suppressHydrationWarning
className={`${spaceGrotesk.variable} ${inter.variable} ${plexMono.variable} h-full antialiased`}
>
<head>
<AppearanceScript />
</head>
<body className="min-h-full flex flex-col">
<Providers>{children}</Providers>
<Toaster richColors />
<ServiceWorkerRegistration />
<AppearanceSync />
</body>
</html>
);
@@ -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(
<Providers>
<p>x</p>
</Providers>,
);
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();
});
});
+1 -6
View File
@@ -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 (
<QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
@@ -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}
@@ -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];