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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user