feat(t5): Darstellung hell/dunkel/Sonne ohne Flackern (T5a A2)
Blockierendes Skript im <head> setzt Darstellung, Kontrast und Bewegung vor dem ersten Zeichnen (gleiche Logik wie resolveAppearance, für alle Eingaben getestet), theme-color je Satz, Wahl je Gerät in localStorage mit Rückfall bei gesperrtem Speicher; Abgleich über Tabs, Systemeinstellungen und Seitenwechsel an einer Stelle. Profil-Karte „Darstellung“, Sonnenmodus im Benutzermenü; Dunkel bleibt bis A4 hinter NEXT_PUBLIC_APPEARANCE_DARK. next-themes entfernt. ARCHITECTURE §9: 'unsafe-inline' der Frontend-CSP als getragenes Risiko mit Begründung. Refs #26 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
947a46f435
commit
cf53cf35ac
22 files changed
+980
-97
No files matched your search
Generated
-11
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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 & 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>
|
||||
|
||||
@@ -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,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];
|
||||
|
||||
Reference in new issue
Block a user