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

@@ -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>