From 6ac9eef93b1145058532f250fd121e2deb3fd8ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nick=20Rosenl=C3=B6cher?= Date: Fri, 16 Oct 2026 18:20:28 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Beute-Editor=20f=C3=A4ngt=20den=20Fokus,?= =?UTF-8?q?=20SVG-Icon=20im=20Kopf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Auf /colonies/[id]/hive bekommen Sprunglink, Seitenleiste, Kopf, Offline-Band, untere Navigation und „+ Neu“ das Attribut inert; der erste Tab landet auf „Zurück“ im Editor (vorher Stopp 34). - metadata.icons verlinkt /icon.svg neben favicon.ico und apple-icon. - Tests: alle THEME_COLORS gleich dem jeweiligen --background, ausdrücklich gewähltes Dunkel färbt die theme-color. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/app/(app)/layout.spec.tsx | 54 +++++++++++++++++- ApiCore-Frontend/src/app/(app)/layout.tsx | 24 ++++++-- .../src/app/(app)/nav-config.spec.ts | 10 ++++ ApiCore-Frontend/src/app/(app)/nav-config.ts | 12 ++++ .../src/app/(app)/quick-add-sheet.tsx | 3 + ApiCore-Frontend/src/app/layout.spec.ts | 55 +++++++++++++++++++ ApiCore-Frontend/src/app/layout.tsx | 7 +++ .../src/components/appearance-script.spec.tsx | 22 ++++++++ .../components/offline/offline-band.spec.tsx | 19 ++++++- .../src/components/offline/offline-band.tsx | 5 +- .../src/lib/appearance/appearance.spec.ts | 19 +++++++ 11 files changed, 221 insertions(+), 9 deletions(-) create mode 100644 ApiCore-Frontend/src/app/layout.spec.ts diff --git a/ApiCore-Frontend/src/app/(app)/layout.spec.tsx b/ApiCore-Frontend/src/app/(app)/layout.spec.tsx index b195b5e..76c320d 100644 --- a/ApiCore-Frontend/src/app/(app)/layout.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/layout.spec.tsx @@ -63,12 +63,14 @@ vi.mock("@/components/offline/offline-band", () => ({ userId, pageContentRef, syncEnabled, + inert, }: { userId: string; pageContentRef: { current: HTMLElement | null }; syncEnabled?: boolean; + inert?: boolean; }) => ( -
+
{userId} +

Umbauen

+ + , + ); + + const back = screen.getByRole("button", { name: "Zurück" }); + // The first Tab lands in the editor, not on 33 links of the sidebar and header behind it. + expect(tabStops()[0]).toBe(back); + expect(back.closest("[inert]")).toBeNull(); + for (const shellPart of [ + screen.getByText("Zum Inhalt springen"), + screen.getAllByRole("link", { name: /Dashboard|Start/ })[0], + screen.getByRole("button", { name: "Konto-Menü" }), + screen.getByTestId("offline-band"), + ...document.querySelectorAll("aside, header, nav"), + ]) { + expect(shellPart.closest("[inert]"), shellPart.outerHTML.slice(0, 60)).not.toBeNull(); + } + // The phone's „+ Neu" would float over the editor: it is not there at all. + expect(screen.queryByRole("button", { name: "Neu" })).not.toBeInTheDocument(); + }); + + it("keeps the shell reachable on every other page", () => { + pathname = "/colonies/c1"; + render( + +

Seiteninhalt

+
, + ); + + expect(document.querySelector("[inert]")).toBeNull(); + expect(tabStops()[0]).toBe(screen.getByRole("link", { name: "Zum Inhalt springen" })); + }); + it("leaves room under the page for the quick-add button on a phone, so it covers no action", () => { pathname = "/suggestions"; permissions = ["inspections:create"]; diff --git a/ApiCore-Frontend/src/app/(app)/layout.tsx b/ApiCore-Frontend/src/app/(app)/layout.tsx index 9b90b8a..e7b8275 100644 --- a/ApiCore-Frontend/src/app/(app)/layout.tsx +++ b/ApiCore-Frontend/src/app/(app)/layout.tsx @@ -25,6 +25,7 @@ import { } from "@/components/ui/dropdown-menu"; import { Sidebar } from "./sidebar"; import { BottomNav } from "./bottom-nav"; +import { isFullScreenPage } from "./nav-config"; import { hasQuickAddButton, QuickAddSheet } from "./quick-add-sheet"; import { PageTransition } from "./page-transition"; @@ -72,6 +73,13 @@ export default function AppLayout({ children }: { children: ReactNode }) { ? "pb-[calc(var(--bottom-nav-height)+--spacing(18))]" : "pb-(--bottom-nav-height)"; + // A full-screen page (the Beute editor) covers the shell: everything of the + // shell turns inert, so the first Tab lands in the page and screen readers + // skip what cannot be seen. Decided by route, like the quick-add button: + // known on the first render and the same for the page's notice and loading + // states. `contents` wrappers keep the boxes where they are in the layout. + const isShellInert = isFullScreenPage(pathname); + // The page scrolls on the window. No ancestor of the page content may be a // scroll container (overflow auto/scroll/hidden): it would capture // `position: sticky` and the StickySubmit save bar would never stick. @@ -79,6 +87,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
{ // The hash alone would not move the focus into a Next.js page reliably. event.preventDefault(); @@ -89,9 +98,13 @@ export default function AppLayout({ children }: { children: ReactNode }) { > Zum Inhalt springen - +
+ +
-
+
{children}
- - +
+ + +
{ expect(group?.label).toBe("Betrieb"); expect(group?.items.find((item) => item.href === "/compliance")?.label).toBe("Prüfbereitschaft"); }); + + it("knows the Beute editor („Umbauen“) as the one full-screen page", () => { + expect(isFullScreenPage("/colonies/c1/hive")).toBe(true); + expect(isFullScreenPage("/colonies/c%201/hive/")).toBe(true); + expect(isFullScreenPage("/colonies/c1")).toBe(false); + expect(isFullScreenPage("/colonies")).toBe(false); + expect(isFullScreenPage("/colonies/c1/hive/history")).toBe(false); + expect(isFullScreenPage("/apiaries/a1/hive")).toBe(false); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/nav-config.ts b/ApiCore-Frontend/src/app/(app)/nav-config.ts index 861b03d..52c569c 100644 --- a/ApiCore-Frontend/src/app/(app)/nav-config.ts +++ b/ApiCore-Frontend/src/app/(app)/nav-config.ts @@ -226,3 +226,15 @@ export function bottomNavItems(): NavItem[] { export function isNavActive(pathname: string, href: string): boolean { return pathname === href || pathname.startsWith(`${href}/`); } + +// „Umbauen" (spec 9.5): its own route, drawn over the whole screen inside the shell. +const FULL_SCREEN_PAGE = /^\/colonies\/[^/]+\/hive\/?$/; + +/** + * Whether the page covers the whole screen. The shell behind it (skip link, + * sidebar, header, offline band, bottom nav) then turns inert, so the first + * Tab lands in the page and screen readers do not read what cannot be seen. + */ +export function isFullScreenPage(pathname: string): boolean { + return FULL_SCREEN_PAGE.test(pathname); +} diff --git a/ApiCore-Frontend/src/app/(app)/quick-add-sheet.tsx b/ApiCore-Frontend/src/app/(app)/quick-add-sheet.tsx index 61bcf16..a80e4a4 100644 --- a/ApiCore-Frontend/src/app/(app)/quick-add-sheet.tsx +++ b/ApiCore-Frontend/src/app/(app)/quick-add-sheet.tsx @@ -10,6 +10,7 @@ import { SheetTrigger } from "@/components/ui/sheet"; import type { AuthUser } from "@/lib/auth/auth-context"; import { NAV_SHEET_ROW_CLASS, NavSheet } from "@/components/nav/nav-sheet"; import { offersRecording, useVoiceRecordingSwitch } from "@/lib/voice/use-voice-recording-switch"; +import { isFullScreenPage } from "./nav-config"; import { QUICK_ACTIONS } from "./quick-actions"; // Create and edit forms end in a save bar (StickySubmit) right where the @@ -24,6 +25,8 @@ const FORM_ROUTE = /\/(new|edit)(\/|$)/; export function hasQuickAddButton(pathname: string, permissions: readonly string[]): boolean { return ( !FORM_ROUTE.test(pathname) && + // A full-screen page (the Beute editor) would sit under the floating button. + !isFullScreenPage(pathname) && QUICK_ACTIONS.some((action) => permissions.includes(action.permission)) ); } diff --git a/ApiCore-Frontend/src/app/layout.spec.ts b/ApiCore-Frontend/src/app/layout.spec.ts new file mode 100644 index 0000000..052f794 --- /dev/null +++ b/ApiCore-Frontend/src/app/layout.spec.ts @@ -0,0 +1,55 @@ +// @vitest-environment node +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import type { Icon, IconDescriptor } from "next/dist/lib/metadata/types/metadata-types"; +import { THEME_COLORS } from "@/lib/appearance/appearance-core"; +import { hexOf, oklchOf, tokenSet } from "@/styles/css-tokens"; +import { metadata, viewport } from "./layout"; + +// next/font only works inside the Next.js compiler. +vi.mock("next/font/local", () => ({ default: () => ({ variable: "font" }) })); + +// The of every page: icons (spec 12.2) and the browser chrome colour (task A2/A4/A9). + +const ROOT = process.cwd(); +const css = readFileSync(path.join(ROOT, "src/app/globals.css"), "utf8"); + +function descriptors(icons: Icon | Icon[] | undefined): IconDescriptor[] { + return [icons ?? []].flat().map((icon) => (typeof icon === "object" && "url" in icon ? icon : { url: String(icon) })); +} + +describe("root layout head", () => { + it("links the SVG icon next to favicon.ico and keeps the apple-touch-icon", () => { + const icons = metadata.icons as { icon?: Icon[]; apple?: Icon[] }; + + expect(descriptors(icons.icon)).toContainEqual(expect.objectContaining({ url: "/icon.svg", type: "image/svg+xml" })); + expect(existsSync(path.join(ROOT, "public/icon.svg"))).toBe(true); + // As soon as metadata.icons is set, Next.js drops the file-based apple-icon from + // the head (favicon.ico stays): it has to be listed here. + expect(descriptors(icons.apple)).toContainEqual( + expect.objectContaining({ url: "/apple-icon.png", sizes: "180x180", type: "image/png" }), + ); + expect(existsSync(path.join(ROOT, "src/app/apple-icon.png"))).toBe(true); + expect(existsSync(path.join(ROOT, "src/app/favicon.ico"))).toBe(true); + }); + + it("THEME_COLORS are the --background of the four colour sets in globals.css", () => { + const background = (set: Parameters[1]) => hexOf(oklchOf(tokenSet(css, set).background)); + + expect(THEME_COLORS).toEqual({ + light: background("light"), + dark: background("dark"), + sunLight: background("sun-light"), + sunDark: background("sun-dark"), + }); + expect(THEME_COLORS.dark).toBe("#1a120d"); + }); + + it("before the inline script runs, the dark device gets the dark page colour", () => { + expect(viewport.themeColor).toEqual([ + { media: "(prefers-color-scheme: light)", color: THEME_COLORS.light }, + { media: "(prefers-color-scheme: dark)", color: THEME_COLORS.dark }, + ]); + }); +}); diff --git a/ApiCore-Frontend/src/app/layout.tsx b/ApiCore-Frontend/src/app/layout.tsx index 0e63e96..e984052 100644 --- a/ApiCore-Frontend/src/app/layout.tsx +++ b/ApiCore-Frontend/src/app/layout.tsx @@ -35,6 +35,13 @@ const plexMono = localFont({ export const metadata: Metadata = { title: "ApiCore – Imkereiverwaltung", description: "Digitale Imkereiverwaltung für Wabenhain", + // favicon.ico (src/app) stays first by file convention. The SVG lives in + // public/ for the manifest and the service worker. Setting icons here drops + // the file-based apple-icon from the head, so it is listed again. + icons: { + icon: [{ url: "/icon.svg", type: "image/svg+xml" }], + apple: [{ url: "/apple-icon.png", sizes: "180x180", type: "image/png" }], + }, }; export const viewport: Viewport = { diff --git a/ApiCore-Frontend/src/components/appearance-script.spec.tsx b/ApiCore-Frontend/src/components/appearance-script.spec.tsx index d958f01..2de4306 100644 --- a/ApiCore-Frontend/src/components/appearance-script.spec.tsx +++ b/ApiCore-Frontend/src/components/appearance-script.spec.tsx @@ -103,6 +103,28 @@ describe("appearanceScript", () => { } }); + it("an explicit dark choice paints both media theme-color metas dark, on a light device too", () => { + stubMedia(false, false); + const metas = (["light", "dark"] as const).map((scheme) => { + const meta = document.createElement("meta"); + meta.name = "theme-color"; + meta.media = `(prefers-color-scheme: ${scheme})`; + meta.content = scheme === "light" ? "#f9f4ea" : "#1a120d"; + document.head.append(meta); + return meta; + }); + + window.localStorage.setItem(APPEARANCE_KEYS.theme, "dark"); + run(appearanceScript()); + expect(metas.map((meta) => meta.content)).toEqual(["#1a120d", "#1a120d"]); + + // Light is the default (dark only on request): a dark device without a choice stays light. + window.localStorage.clear(); + stubMedia(true, false); + run(appearanceScript()); + expect(metas.map((meta) => meta.content)).toEqual(["#f9f4ea", "#f9f4ea"]); + }); + it("has no build flag for dark any more (task A4)", () => { expect(appearanceScript()).not.toMatch(/DARK=/); }); diff --git a/ApiCore-Frontend/src/components/offline/offline-band.spec.tsx b/ApiCore-Frontend/src/components/offline/offline-band.spec.tsx index d877aa6..7639bdf 100644 --- a/ApiCore-Frontend/src/components/offline/offline-band.spec.tsx +++ b/ApiCore-Frontend/src/components/offline/offline-band.spec.tsx @@ -34,11 +34,11 @@ function setOnLine(value: boolean) { } /** The band as the layout places it: above the page content wrapper. */ -function BandOnPage({ syncEnabled }: { syncEnabled?: boolean }) { +function BandOnPage({ syncEnabled, inert }: { syncEnabled?: boolean; inert?: boolean }) { const pageContentRef = useRef(null); return ( <> - +
Seiteninhalt
@@ -46,7 +46,7 @@ function BandOnPage({ syncEnabled }: { syncEnabled?: boolean }) { ); } -function renderBand(props: { syncEnabled?: boolean } = {}) { +function renderBand(props: { syncEnabled?: boolean; inert?: boolean } = {}) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render( @@ -85,6 +85,19 @@ describe("OfflineBand", () => { vi.restoreAllMocks(); }); + it("leaves the tab order and the accessibility tree while a full-screen page covers it", async () => { + setOnLine(false); + vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 }); + + const { unmount } = renderBand({ inert: true }); + await waitFor(() => expect(summarizeQueue).toHaveBeenCalled()); + expect(document.querySelector('[aria-live="polite"]')).toHaveAttribute("inert"); + unmount(); + + renderBand(); + expect(screen.getByRole("status")).not.toHaveAttribute("inert"); + }); + it("announces „Offline – 2 Einträge warten\" in a status region", async () => { setOnLine(false); vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 }); diff --git a/ApiCore-Frontend/src/components/offline/offline-band.tsx b/ApiCore-Frontend/src/components/offline/offline-band.tsx index 6bd16cd..cf97eb3 100644 --- a/ApiCore-Frontend/src/components/offline/offline-band.tsx +++ b/ApiCore-Frontend/src/components/offline/offline-band.tsx @@ -38,6 +38,8 @@ interface OfflineBandProps { * nor sends - the server would only answer 401. */ syncEnabled?: boolean; + /** True while a full-screen page covers the shell: out of the tab order and the accessibility tree. */ + inert?: boolean; } /** @@ -45,7 +47,7 @@ interface OfflineBandProps { * device, how much still waits to be sent and what the server rejected. * Sends by itself as soon as the connection is back. */ -export function OfflineBand({ userId, pageContentRef, syncEnabled = true }: OfflineBandProps) { +export function OfflineBand({ userId, pageContentRef, syncEnabled = true, inert }: OfflineBandProps) { const queryClient = useQueryClient(); const connectivity = useConnectivity(); const statusRef = useRef(null); @@ -136,6 +138,7 @@ export function OfflineBand({ userId, pageContentRef, syncEnabled = true }: Offl role="status" aria-live="polite" tabIndex={-1} + inert={inert} className={cn( hasContent && "flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-border/60 px-4 py-2 text-sm sm:px-6", diff --git a/ApiCore-Frontend/src/lib/appearance/appearance.spec.ts b/ApiCore-Frontend/src/lib/appearance/appearance.spec.ts index b19db31..d1bd406 100644 --- a/ApiCore-Frontend/src/lib/appearance/appearance.spec.ts +++ b/ApiCore-Frontend/src/lib/appearance/appearance.spec.ts @@ -140,6 +140,25 @@ describe("useAppearance", () => { expect(document.documentElement).toHaveClass("dark"); }); + it("picking dark sets every theme-color meta to the dark page colour, and back", () => { + const metas = ["light", "dark"].map((scheme) => { + const meta = document.createElement("meta"); + meta.name = "theme-color"; + meta.media = `(prefers-color-scheme: ${scheme})`; + document.head.append(meta); + return meta; + }); + const { result } = renderHook(() => useAppearance()); + // Earlier tests leave a sun choice in the module's per-visit fallback. + act(() => result.current.setContrast("normal")); + + act(() => result.current.setTheme("dark")); + expect(metas.map((meta) => meta.content)).toEqual(["#1a120d", "#1a120d"]); + + act(() => result.current.setTheme("light")); + expect(metas.map((meta) => meta.content)).toEqual(["#f9f4ea", "#f9f4ea"]); + }); + it("a motion change reaches useEffectiveMotionLevel in the same tab", () => { const effective = renderHook(() => useEffectiveMotionLevel()); const appearance = renderHook(() => useAppearance());