From 427969d8b91b0e55839c027ba662eb3e0bcd713a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nick=20Rosenl=C3=B6cher?= Date: Mon, 19 Oct 2026 13:47:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(t5):=20W=C3=A4chter=20vollst=C3=A4ndig,=20?= =?UTF-8?q?Hinweis=20Sonnenmodus,=20Nacharbeiten=20(T5a=20A10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Baukasten-Wächter: jede Regel prüft eigene Beispiele (muss fangen, muss ignorieren), veraltete Ausnahmen und Ordner-Ausschlüsse schlagen fehl; neue Regeln für text-primary ohne -ink, dark:, Schrift unter 13 px (Tailwind, SVG, CSS, style, *_SIZE-Konstanten) und Verlaufs-Hintergründe. Aufgeteilt in baukasten-guard, touch-target-guard und einen Scan-Helfer. - Hinweis „Neu: Sonnenmodus für draußen“ auf dem Dashboard, einmal je Gerät, „Ausprobieren“ schaltet live um; Fokus geht danach zur Überschrift. - Varroa-Diagramm: Beschriftungen als HTML mit fester Größe statt SVG-Text. - Medikamente zeigen die Tabs auch leer; Zahlen in Tabs nicht beim Laden oder Fehler; inaktive Tabs mit Hover für die Maus; TabPicker (Auswahl unter 640 px) für Automatisierung und Statistik; Rollen-Dialoge breit genug für die Rechte-Matrix. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/app/(app)/apiaries/page.spec.tsx | 10 + .../src/app/(app)/apiaries/page.tsx | 6 +- .../colonies/[id]/colony-varroa.spec.tsx | 16 +- .../app/(app)/colonies/[id]/colony-varroa.tsx | 131 +++-- .../src/app/(app)/colonies/page.tsx | 6 +- .../src/app/(app)/dashboard/page.spec.tsx | 30 ++ .../src/app/(app)/dashboard/page.tsx | 5 +- .../src/app/(app)/honey-batches/page.tsx | 6 +- .../src/app/(app)/medicines/page.spec.tsx | 12 + .../src/app/(app)/medicines/page.tsx | 46 +- .../src/app/(app)/products/page.tsx | 6 +- .../src/app/(app)/queens/page.tsx | 6 +- .../app/(app)/settings/automation/page.tsx | 21 +- .../automation/task-templates-tab.tsx | 6 +- .../app/(app)/settings/settings-tabs.spec.tsx | 17 + .../users-roles/create-role-dialog.tsx | 2 +- .../settings/users-roles/edit-role-dialog.tsx | 2 +- .../users-roles/permission-matrix.spec.tsx | 11 + .../(app)/settings/users-roles/roles-tab.tsx | 6 +- .../src/app/(app)/statistics/page.spec.tsx | 7 + .../src/app/(app)/statistics/page.tsx | 33 +- .../src/app/(app)/storage/page.tsx | 6 +- ApiCore-Frontend/src/app/(app)/tasks/page.tsx | 6 +- .../src/app/herkunft/[lot]/page.tsx | 2 +- .../components/active-history-tabs.spec.tsx | 29 +- .../src/components/active-history-tabs.tsx | 8 + .../src/components/appearance-notice.spec.tsx | 157 ++++++ .../src/components/appearance-notice.tsx | 103 ++++ .../components/form/checkbox-field.spec.tsx | 8 + .../src/components/form/checkbox-field.tsx | 7 +- .../src/components/honeycomb-pattern.tsx | 5 +- .../src/components/notification-bell.tsx | 2 +- .../src/components/tab-picker.tsx | 49 ++ ApiCore-Frontend/src/components/ui/button.tsx | 2 +- .../src/components/ui/select.spec.tsx | 39 +- .../src/components/ui/tabs.spec.tsx | 14 + ApiCore-Frontend/src/components/ui/tabs.tsx | 3 +- .../src/components/ui/ui-colors.spec.tsx | 28 +- .../src/styles/baukasten-guard.spec.ts | 489 ++++++++++-------- ApiCore-Frontend/src/styles/contrast.spec.ts | 9 + .../src/styles/guard-files.spec-helper.ts | 25 + .../src/styles/touch-target-guard.spec.ts | 77 +++ 42 files changed, 1053 insertions(+), 400 deletions(-) create mode 100644 ApiCore-Frontend/src/components/appearance-notice.spec.tsx create mode 100644 ApiCore-Frontend/src/components/appearance-notice.tsx create mode 100644 ApiCore-Frontend/src/components/tab-picker.tsx create mode 100644 ApiCore-Frontend/src/styles/guard-files.spec-helper.ts create mode 100644 ApiCore-Frontend/src/styles/touch-target-guard.spec.ts diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/page.spec.tsx index 2214722..0679eda 100644 --- a/ApiCore-Frontend/src/app/(app)/apiaries/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/apiaries/page.spec.tsx @@ -43,4 +43,14 @@ describe("ApiariesPage", () => { expect(await screen.findByText("Heimstand")).toBeInTheDocument(); expect(screen.getByText("Garten")).toBeInTheDocument(); }); + + it("shows the tabs without a count when the first load failed", async () => { + vi.mocked(fetchApiaries).mockRejectedValueOnce(new Error("kaputt")); + renderWithClient(); + + expect(await screen.findByRole("alert")).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Aktiv" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Historie" })).toBeInTheDocument(); + expect(screen.queryByText(/\(0\)/)).toBeNull(); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/page.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/page.tsx index 1701293..6e75a05 100644 --- a/ApiCore-Frontend/src/app/(app)/apiaries/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/apiaries/page.tsx @@ -13,7 +13,7 @@ import { PageHeader } from "@/components/page-header"; import { EmptyState } from "@/components/empty-state"; import { EntityCard } from "@/components/entity-card"; import { QueryState } from "@/components/query-state"; -import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs"; +import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs"; import { DropdownMenu, DropdownMenuContent, @@ -84,8 +84,8 @@ export default function ApiariesPage() { { it("labels the date axis and explains the lines in a legend", async () => { renderCard(); const chart = await screen.findByRole("img"); - const axis = within(chart as unknown as HTMLElement); + // The dates sit in an HTML row under the chart (the img names them in its description). + const axis = within(chart.parentElement?.parentElement as HTMLElement); expect(axis.getByText("01.06.2026")).toBeInTheDocument(); expect(axis.getByText("15.06.2026")).toBeInTheDocument(); await screen.findByText("Rote Grenze"); @@ -217,4 +218,17 @@ describe("ColonyVarroaCard trend", () => { expect(within(legend).getByText("Rote Grenze")).toBeInTheDocument(); expect(within(legend).getByText("Gelbe Grenze")).toBeInTheDocument(); }); + + it("chart labels are HTML text at a fixed 13 px, not SVG text that shrinks with a narrow chart", async () => { + const { container } = renderCard(); + await screen.findByText("Grenze 10/Tag"); + // SVG font sizes are user units: in a 300-unit viewBox on a 360 px phone 13 units are ~12 px. + expect(container.querySelectorAll("svg text")).toHaveLength(0); + for (const text of ["Grenze 10/Tag", "01.06.2026", "15.06.2026"]) { + const label = screen.getByText(text); + expect(label.closest("svg"), text).toBeNull(); + expect(label.className.split(/\s+/), text).toContain("text-xs"); + expect(label.closest("[aria-hidden='true']"), text).not.toBeNull(); + } + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-varroa.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-varroa.tsx index 0beeb2e..080605c 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-varroa.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-varroa.tsx @@ -20,11 +20,12 @@ import { VarroaEditDialog } from "./varroa-edit-dialog"; const CHART_WIDTH = 300; const CHART_HEIGHT = 136; const PAD_X = 12; -const PAD_TOP = 12; -const PAD_BOTTOM = 28; +// Room for the HTML limit label (~22 px) above the highest point. +const PAD_TOP = 24; +const PAD_BOTTOM = 12; const MARKER_RADIUS = 4; -const LABEL_GAP = 4; -const AXIS_LABEL_SIZE = 10; +/** Horizontal inset of the HTML labels, matching PAD_X of the viewBox. */ +const PAD_X_PERCENT = `${(PAD_X / CHART_WIDTH) * 100}%`; const MAX_BAND_MONTHS = 24; const ONE_HOUR_MS = 60 * 60 * 1000; @@ -203,78 +204,70 @@ function VarroaTrendChart({ const monthly = monthLimitsText(limits, range); const axisY = CHART_HEIGHT - PAD_BOTTOM + MARKER_RADIUS * 2; - const labelY = CHART_HEIGHT - LABEL_GAP; return (
- - {`Milbenfall pro Tag. ${summary}`} - - {`${points.length} Messungen von ${formatDate(first.time)} bis ${formatDate(last.time)}. ${summary}.${ - monthly ? ` Rote Grenze je Monat: ${monthly}.` : "" - }`} - - {redPath || yellowPath ? ( - - {yellowPath ? ( - - ) : null} - {redPath ? ( - - ) : null} - - ) : null} + {/* Labels are HTML at text-xs: SVG text scales with the viewBox and drops below 13 px. */} +
+ + {`Milbenfall pro Tag. ${summary}`} + + {`${points.length} Messungen von ${formatDate(first.time)} bis ${formatDate(last.time)}. ${summary}.${ + monthly ? ` Rote Grenze je Monat: ${monthly}.` : "" + }`} + + {redPath || yellowPath ? ( + + {yellowPath ? ( + + ) : null} + {redPath ? ( + + ) : null} + + ) : null} + + + {points.map((p) => ( + + ))} + {lastLimit ? ( - + ) : null} - - - {formatDate(first.time)} - - - {formatDate(last.time)} - - - {points.map((p) => ( - - ))} - +
+
); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/page.tsx b/ApiCore-Frontend/src/app/(app)/colonies/page.tsx index 2074a56..02ea5b5 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/page.tsx @@ -22,7 +22,7 @@ import { PageHeader } from "@/components/page-header"; import { EmptyState } from "@/components/empty-state"; import { EntityCard } from "@/components/entity-card"; import { QueryState } from "@/components/query-state"; -import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs"; +import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs"; import { DropdownMenu, DropdownMenuContent, @@ -108,8 +108,8 @@ export default function ColoniesPage() { setTab(next); setStatus(""); }} - activeCount={activeColonies.length} - historyCount={historyColonies.length} + activeCount={countOf(coloniesQuery.data, activeColonies)} + historyCount={countOf(coloniesQuery.data, historyColonies)} toolbar={ { expect(header.getAttribute("style")).toBeNull(); expect(container.innerHTML).not.toMatch(/text-white|--gradient/); }); + + it("the dashboard shows no white text on honey", async () => { + vi.mocked(fetchAutopilotToday).mockResolvedValue( + todayFixture({ + nextStep: { ...stepFixture({ title: "Durchsicht A1" }), colonyId: "c1", colonyCode: "A1" }, + }), + ); + const { container } = renderPage(); + + expect(await screen.findByRole("link", { name: "Erfassen" })).toHaveClass("bg-primary"); + const honey = Array.from(container.querySelectorAll(".bg-primary")); + expect(honey.length).toBeGreaterThan(0); + for (const element of honey) { + // Text on honey is the dark ink of --primary-foreground, never white (spec 4.4, 9.4:1). + expect(element.className, element.textContent ?? "").toMatch(/(^|\s)text-primary-foreground(\s|$)/); + } + expect(container.innerHTML).not.toMatch(/(^|[\s"])text-white\b/); + }); + + it("shows the one-time sun notice on the start page (spec 15.4)", async () => { + vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture({})); + renderPage(); + const notice = await screen.findByRole("complementary", { + name: "Neu: Sonnenmodus für draußen – im Benutzermenü.", + }); + expect(within(notice).getByRole("button", { name: "Ausprobieren" })).toBeInTheDocument(); + // Closing hands the focus to the greeting, not to . + await userEvent.click(within(notice).getByRole("button", { name: "Schließen" })); + expect(document.activeElement).toBe(screen.getByRole("heading", { level: 1 })); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx b/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx index 79abb77..5616432 100644 --- a/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx @@ -24,6 +24,7 @@ import { DashboardAlert, DashboardAlertType } from "@/lib/dashboard/types"; import { fetchTasks } from "@/lib/tasks/api"; import { tasksForToday } from "@/lib/tasks/today"; import { Button } from "@/components/ui/button"; +import { AppearanceNotice } from "@/components/appearance-notice"; import { StandNote } from "@/components/offline/stand-note"; import { ComplianceLine } from "@/components/today/compliance-line"; import { TodayPanel } from "@/components/today/today-panel"; @@ -157,7 +158,8 @@ export default function DashboardPage() { return (
-

+ {/* tabIndex -1: the sun notice hands the focus here when it closes. */} +

{greeting(now)} {firstName ? `, ${firstName}` : ""}

@@ -167,6 +169,7 @@ export default function DashboardPage() {

+ {autopilotError && !isAutopilotSilent ? (
{{ active: listOf("active"), history: listOf("history") }} diff --git a/ApiCore-Frontend/src/app/(app)/medicines/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/medicines/page.spec.tsx index 79b834f..d667043 100644 --- a/ApiCore-Frontend/src/app/(app)/medicines/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/medicines/page.spec.tsx @@ -88,6 +88,18 @@ describe("MedicinesPage", () => { expect(screen.getByText("Oxalsäure")).toBeInTheDocument(); }); + it("an empty catalogue keeps the Aktiv (0) / Historie (0) tabs like every other list", async () => { + vi.mocked(fetchMedicines).mockResolvedValue([]); + vi.mocked(fetchMedicineSuggestions).mockResolvedValue([suggestion()]); + renderPage(); + + const active = await screen.findByRole("tab", { name: "Aktiv (0)" }); + expect(active).toHaveAttribute("aria-selected", "true"); + expect(await within(screen.getByRole("tabpanel")).findByText("Aus deinen Behandlungen übernehmen?")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("tab", { name: "Historie (0)" })); + expect(within(screen.getByRole("tabpanel")).getByText("Keine deaktivierten Mittel")).toBeInTheDocument(); + }); + it("applying a suggestion opens the form prefilled with the values of the latest treatment", async () => { vi.mocked(fetchMedicines).mockResolvedValue([]); vi.mocked(fetchMedicineSuggestions).mockResolvedValue([suggestion()]); diff --git a/ApiCore-Frontend/src/app/(app)/medicines/page.tsx b/ApiCore-Frontend/src/app/(app)/medicines/page.tsx index acbf69f..7bb249c 100644 --- a/ApiCore-Frontend/src/app/(app)/medicines/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/medicines/page.tsx @@ -6,7 +6,7 @@ import { useQuery } from "@tanstack/react-query"; import { useAuth } from "@/lib/auth/auth-context"; import { fetchMedicines } from "@/lib/medicines/api"; import type { MedicineView } from "@/lib/medicines/types"; -import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs"; +import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs"; import { EmptyState } from "@/components/empty-state"; import { EntityCard } from "@/components/entity-card"; import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note"; @@ -35,9 +35,19 @@ export default function MedicinesPage() { ? `${visible.length} ${tab === "active" ? (visible.length === 1 ? "aktives Mittel" : "aktive Mittel") : visible.length === 1 ? "deaktiviertes Mittel" : "deaktivierte Mittel"}` : ""; - const listOf = (bucket: EntityLifecycleTab) => { + const listOf = (bucket: EntityLifecycleTab) => ( + + {() => bucketList(bucket)} + + ); + + const bucketList = (bucket: EntityLifecycleTab) => { const shown = bucket === "active" ? active : inactive; if (shown.length > 0) return ; + // An empty catalogue starts from the beekeeper's own treatments. + if (bucket === "active" && medicines.length === 0) { + return ; + } return ( {canCreate ? : null} - - {() => - medicines.length === 0 ? ( - - ) : ( - - {countText} -

- } - > - {{ active: listOf("active"), history: listOf("history") }} -
- ) + + {countText} +

} -
+ > + {{ active: listOf("active"), history: listOf("history") }} +
); } diff --git a/ApiCore-Frontend/src/app/(app)/products/page.tsx b/ApiCore-Frontend/src/app/(app)/products/page.tsx index 436933b..ca3be37 100644 --- a/ApiCore-Frontend/src/app/(app)/products/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/products/page.tsx @@ -14,7 +14,7 @@ import { PageHeader } from "@/components/page-header"; import { EmptyState } from "@/components/empty-state"; import { EntityCard } from "@/components/entity-card"; import { QueryState } from "@/components/query-state"; -import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs"; +import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs"; export default function ProductsPage() { const [tab, setTab] = useState("active"); @@ -76,8 +76,8 @@ export default function ProductsPage() { {{ active: listOf("active"), history: listOf("history") }} diff --git a/ApiCore-Frontend/src/app/(app)/queens/page.tsx b/ApiCore-Frontend/src/app/(app)/queens/page.tsx index 00d8b23..6dbbb8f 100644 --- a/ApiCore-Frontend/src/app/(app)/queens/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/queens/page.tsx @@ -13,7 +13,7 @@ import { PageHeader } from "@/components/page-header"; import { EmptyState } from "@/components/empty-state"; import { EntityCard } from "@/components/entity-card"; import { QueryState } from "@/components/query-state"; -import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs"; +import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs"; import { EnumSelect } from "@/components/form/enum-select"; export default function QueensPage() { @@ -68,8 +68,8 @@ export default function QueensPage() { setTab(next); setStatus(""); }} - activeCount={activeQueens.length} - historyCount={historyQueens.length} + activeCount={countOf(queensQuery.data, activeQueens)} + historyCount={countOf(queensQuery.data, historyQueens)} toolbar={ entry.value); +const TAB_LABELS = Object.fromEntries(TABS.map((entry) => [entry.value, entry.label])) as Record; + function isTab(value: unknown): value is Tab { return TABS.some((tab) => tab.value === value); } @@ -60,13 +64,14 @@ function AutomationPageContent() { if (isTab(next)) setTab(next); }} > - - {TABS.map((entry) => ( - - {entry.label} - - ))} - + {TABS.map(({ value, Content }) => ( diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/task-templates-tab.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/task-templates-tab.tsx index 14ddb83..43032ef 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/automation/task-templates-tab.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/task-templates-tab.tsx @@ -11,7 +11,7 @@ import { Badge } from "@/components/ui/badge"; import { ConfirmDialog } from "@/components/confirm-dialog"; import { EmptyState } from "@/components/empty-state"; import { EntityCard } from "@/components/entity-card"; -import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs"; +import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs"; import { CreateTaskTemplateDialog } from "./create-task-template-dialog"; import { EditTaskTemplateDialog } from "./edit-task-template-dialog"; @@ -116,8 +116,8 @@ export function TaskTemplatesTab() { } toolbarAlign="end" > diff --git a/ApiCore-Frontend/src/app/(app)/settings/settings-tabs.spec.tsx b/ApiCore-Frontend/src/app/(app)/settings/settings-tabs.spec.tsx index c6b48ae..0fcda1b 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/settings-tabs.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/settings-tabs.spec.tsx @@ -43,6 +43,23 @@ describe("both settings pages use Tabs directly", () => { expect(replace).toHaveBeenLastCalledWith("/settings/automation?tab=webhooks"); }); + it("Automatisierung: on a phone a select replaces the seven tabs and keeps the ?tab= contract", async () => { + search = "tab=api-keys"; + const user = userEvent.setup(); + render(); + + // Below sm the tab bar would wrap into five rows; the same choice is a select there. + const picker = screen.getByRole("combobox", { name: "Bereich der Automatisierung wählen" }); + expect(picker.className.split(/\s+/)).toContain("sm:hidden"); + expect(picker).toHaveTextContent("API-Schlüssel"); + const list = screen.getByRole("tablist", { hidden: true }); + expect(list.className.split(/\s+/)).toEqual(expect.arrayContaining(["hidden", "sm:inline-flex"])); + + await user.click(picker); + await user.click(await screen.findByRole("option", { name: "Webhooks" })); + expect(replace).toHaveBeenLastCalledWith("/settings/automation?tab=webhooks"); + }); + it("Automatisierung: an unknown ?tab= falls back to Vorschläge", () => { search = "tab=unknown"; render(); diff --git a/ApiCore-Frontend/src/app/(app)/settings/users-roles/create-role-dialog.tsx b/ApiCore-Frontend/src/app/(app)/settings/users-roles/create-role-dialog.tsx index c29298b..004919b 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/users-roles/create-role-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/users-roles/create-role-dialog.tsx @@ -78,7 +78,7 @@ export function CreateRoleDialog() { }} > - + Neue Rolle diff --git a/ApiCore-Frontend/src/app/(app)/settings/users-roles/edit-role-dialog.tsx b/ApiCore-Frontend/src/app/(app)/settings/users-roles/edit-role-dialog.tsx index 8059afa..7638653 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/users-roles/edit-role-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/users-roles/edit-role-dialog.tsx @@ -115,7 +115,7 @@ interface EditRoleDialogProps { export function EditRoleDialog({ role, onClose }: EditRoleDialogProps) { return ( !next && onClose()}> - + Rolle bearbeiten{role ? `: ${role.name}` : ""} diff --git a/ApiCore-Frontend/src/app/(app)/settings/users-roles/permission-matrix.spec.tsx b/ApiCore-Frontend/src/app/(app)/settings/users-roles/permission-matrix.spec.tsx index 52cf8cc..9a54ecc 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/users-roles/permission-matrix.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/users-roles/permission-matrix.spec.tsx @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { useState } from "react"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -45,4 +47,13 @@ describe("PermissionMatrix", () => { expect(box).toHaveAccessibleDescription(expect.stringContaining(hint.textContent ?? "x")); } }); + + it.each(["create-role-dialog.tsx", "edit-role-dialog.tsx"])( + "%s gives the matrix the wide dialog on a wide screen (sm:max-w-2xl beats the 384 px default)", + (file) => { + const text = readFileSync(join(__dirname, file), "utf8"); + const content = /} toolbarAlign="end" > diff --git a/ApiCore-Frontend/src/app/(app)/statistics/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/statistics/page.spec.tsx index bb4cc3b..0952154 100644 --- a/ApiCore-Frontend/src/app/(app)/statistics/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/statistics/page.spec.tsx @@ -63,4 +63,11 @@ describe("StatisticsPage", () => { expect(list).toHaveAttribute("data-variant", "segment"); expect(screen.getByRole("tab", { name: "Völker", hidden: true })).toHaveAttribute("aria-selected", "true"); }); + + it("on a phone the select says what it picks", () => { + vi.mocked(fetchColonyStats).mockReturnValue(new Promise(() => {})); + renderWithClient(); + expect(screen.getByRole("combobox", { name: "Auswertung wählen" })).toHaveTextContent("Völker"); + expect(screen.getByRole("tablist", { hidden: true })).toHaveAccessibleName("Auswertungen"); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/statistics/page.tsx b/ApiCore-Frontend/src/app/(app)/statistics/page.tsx index 93daac4..6373683 100644 --- a/ApiCore-Frontend/src/app/(app)/statistics/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/statistics/page.tsx @@ -18,8 +18,8 @@ import { StatBarList } from "@/components/stat-bar-list"; import { PageHeader } from "@/components/page-header"; import { QueryState } from "@/components/query-state"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { EnumSelect } from "@/components/form/enum-select"; +import { Tabs, TabsContent } from "@/components/ui/tabs"; +import { TabPicker } from "@/components/tab-picker"; import { keysOf } from "@/lib/keys-of"; const TAB_LABELS = { @@ -345,19 +345,6 @@ function FeedingStatsPanel({ colonyLabels }: { colonyLabels: Map ); } -/** On phones the tab bar does not fit - the same choice as a select. */ -function TabPicker({ value, onChange }: { value: StatsTab; onChange: (value: StatsTab) => void }) { - return ( - - ); -} - export default function StatisticsPage() { const [tab, setTab] = useState("colonies"); const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() }); @@ -375,14 +362,14 @@ export default function StatisticsPage() { /> setTab(toStatsTab(next) ?? tab)}> - - - {TABS.map((entry) => ( - - {TAB_LABELS[entry]} - - ))} - + diff --git a/ApiCore-Frontend/src/app/(app)/storage/page.tsx b/ApiCore-Frontend/src/app/(app)/storage/page.tsx index 69f745b..7983f6b 100644 --- a/ApiCore-Frontend/src/app/(app)/storage/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/storage/page.tsx @@ -12,7 +12,7 @@ import { PageHeader } from "@/components/page-header"; import { EmptyState } from "@/components/empty-state"; import { EntityCard } from "@/components/entity-card"; import { QueryState } from "@/components/query-state"; -import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs"; +import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs"; import { Badge } from "@/components/ui/badge"; import { CreateStorageContainerDialog } from "./create-storage-container-dialog"; import { CreateStockItemDialog } from "./create-stock-item-dialog"; @@ -65,8 +65,8 @@ export default function StoragePage() { heading={

Lagerbehälter

} value={containerTab} onValueChange={setContainerTab} - activeCount={activeContainers.length} - historyCount={historyContainers.length} + activeCount={countOf(containersQuery.data, activeContainers)} + historyCount={countOf(containersQuery.data, historyContainers)} > {{ active: containersOf("active"), history: containersOf("history") }}
diff --git a/ApiCore-Frontend/src/app/(app)/tasks/page.tsx b/ApiCore-Frontend/src/app/(app)/tasks/page.tsx index 3a73066..711246f 100644 --- a/ApiCore-Frontend/src/app/(app)/tasks/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/tasks/page.tsx @@ -14,7 +14,7 @@ import { TaskSourceBadge, TaskStatusPill } from "@/components/task-status"; import { PageHeader } from "@/components/page-header"; import { EmptyState } from "@/components/empty-state"; import { EntityCard } from "@/components/entity-card"; -import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs"; +import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs"; import { EnumSelect } from "@/components/form/enum-select"; import { TaskEditDialog } from "./task-edit-dialog"; @@ -136,8 +136,8 @@ export default function TasksPage() { {value} - {label} + {label} ); } diff --git a/ApiCore-Frontend/src/components/active-history-tabs.spec.tsx b/ApiCore-Frontend/src/components/active-history-tabs.spec.tsx index bd7280a..80ea530 100644 --- a/ApiCore-Frontend/src/components/active-history-tabs.spec.tsx +++ b/ApiCore-Frontend/src/components/active-history-tabs.spec.tsx @@ -2,7 +2,9 @@ import { useState } from "react"; import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; -import { ActiveHistoryTabs, type EntityLifecycleTab } from "./active-history-tabs"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "./active-history-tabs"; function Harness({ onValueChange }: { onValueChange?: (value: EntityLifecycleTab) => void }) { const [value, setValue] = useState("active"); @@ -112,4 +114,29 @@ describe("ActiveHistoryTabs (spec 10.4)", () => { expect(classes).toContain("pointer-fine:h-9"); } }); + + it("countOf counts only loaded data: no number while loading or after a failed first load", () => { + expect(countOf(undefined, [])).toBeUndefined(); + expect(countOf([], [])).toBe(0); + expect(countOf([1, 2, 3], [1, 3])).toBe(2); + }); + + it("every list passes its counts through countOf, so a failed load never says (0)", () => { + const src = join(__dirname, ".."); + const files = (function walk(dir: string): string[] { + return readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + if (statSync(path).isDirectory()) return walk(path); + return /\.tsx$/.test(name) && !/\.spec\./.test(name) ? [path] : []; + }); + })(src); + const users = files.filter((file) => / { + const text = readFileSync(file, "utf8"); + const counts = [...text.matchAll(/(?:active|history)Count=\{([^}]*)\}/g)].map((match) => match[1]); + return counts.length !== 2 || counts.some((expression) => !/^countOf\(/.test(expression.trim())); + }); + expect(wrong).toEqual([]); + }); }); diff --git a/ApiCore-Frontend/src/components/active-history-tabs.tsx b/ApiCore-Frontend/src/components/active-history-tabs.tsx index 3512b7b..01f618d 100644 --- a/ApiCore-Frontend/src/components/active-history-tabs.tsx +++ b/ApiCore-Frontend/src/components/active-history-tabs.tsx @@ -19,6 +19,14 @@ export interface ActiveHistoryTabsProps { children: { active: ReactNode; history: ReactNode }; } +/** + * The count for a tab: unknown (no number) until the list has loaded, so a failed or pending + * first load never reads „Aktiv (0)“. Cached data still counts while a refetch fails. + */ +export function countOf(data: readonly unknown[] | undefined, items: readonly unknown[]): number | undefined { + return data === undefined ? undefined : items.length; +} + const withCount = (label: string, count: number | undefined) => count === undefined ? label : `${label} (${count})`; diff --git a/ApiCore-Frontend/src/components/appearance-notice.spec.tsx b/ApiCore-Frontend/src/components/appearance-notice.spec.tsx new file mode 100644 index 0000000..aeebd76 --- /dev/null +++ b/ApiCore-Frontend/src/components/appearance-notice.spec.tsx @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { mockMedia, resetDocument } from "@/lib/appearance/test-helpers"; + +vi.mock("sonner", () => ({ toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }) })); + +const TEXT = "Neu: Sonnenmodus für draußen – im Benutzermenü."; +const KEY = "apicore.notice.sun"; + +/** Fresh modules per test: the notice and the appearance hook keep a per-visit memory. */ +async function loadNotice() { + vi.resetModules(); + const { AppearanceNotice, SUN_NOTICE_KEY } = await import("./appearance-notice"); + const { toast } = await import("sonner"); + return { AppearanceNotice, SUN_NOTICE_KEY, toast }; +} + +describe("AppearanceNotice (spec 15.4)", () => { + let media: ReturnType; + + beforeEach(() => { + resetDocument(); + media = mockMedia(); + }); + + afterEach(() => { + media.restore(); + vi.restoreAllMocks(); + }); + + it("the notice shows once, Ausprobieren enables sun and Schließen remembers it", async () => { + const user = userEvent.setup(); + const { AppearanceNotice, SUN_NOTICE_KEY } = await loadNotice(); + expect(SUN_NOTICE_KEY).toBe(KEY); + + const first = render(); + expect(await screen.findByText(TEXT)).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Schließen" })); + expect(screen.queryByText(TEXT)).toBeNull(); + expect(window.localStorage.getItem(KEY)).toBe("seen"); + expect(document.documentElement.getAttribute("data-contrast")).not.toBe("sun"); + first.unmount(); + + // Another visit on this device: not again. + const { AppearanceNotice: Again } = await loadNotice(); + render(); + expect(screen.queryByText(TEXT)).toBeNull(); + }); + + it("Ausprobieren switches sun on at once, says so and does not come back", async () => { + const user = userEvent.setup(); + const { AppearanceNotice, toast } = await loadNotice(); + render(); + + await user.click(await screen.findByRole("button", { name: "Ausprobieren" })); + expect(document.documentElement.getAttribute("data-contrast")).toBe("sun"); + expect(window.localStorage.getItem("apicore.contrast")).toBe("sun"); + expect(window.localStorage.getItem(KEY)).toBe("seen"); + expect(screen.queryByText(TEXT)).toBeNull(); + expect(toast.success).toHaveBeenCalledWith("Sonnenmodus an"); + }); + + it("is a labelled, quiet hint: no honey button, both actions 44 px", async () => { + const { AppearanceNotice } = await loadNotice(); + render(); + const region = await screen.findByRole("complementary", { name: TEXT }); + for (const name of ["Ausprobieren", "Schließen"]) { + const button = screen.getByRole("button", { name }); + expect(region).toContainElement(button); + expect(button.className).not.toMatch(/(^|\s)bg-primary(\s|$)/); + expect(button.className).toMatch(/(^|\s)h-11(\s|$)/); + } + }); + + it("stays away while sun is already on", async () => { + window.localStorage.setItem("apicore.contrast", "sun"); + const { AppearanceNotice } = await loadNotice(); + render(); + expect(screen.queryByText(TEXT)).toBeNull(); + }); + + it("the notice works when storage is blocked", async () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("blocked"); + }); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("blocked"); + }); + const user = userEvent.setup(); + const { AppearanceNotice } = await loadNotice(); + + const first = render(); + await user.click(await screen.findByRole("button", { name: "Ausprobieren" })); + expect(document.documentElement.getAttribute("data-contrast")).toBe("sun"); + expect(screen.queryByText(TEXT)).toBeNull(); + first.unmount(); + + // Same visit, back on the start page: still gone, without storage. + render(); + expect(screen.queryByText(TEXT)).toBeNull(); + }); + + it("Schließen also works with blocked storage", async () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("blocked"); + }); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("blocked"); + }); + const user = userEvent.setup(); + const { AppearanceNotice } = await loadNotice(); + render(); + await user.click(await screen.findByRole("button", { name: "Schließen" })); + expect(screen.queryByText(TEXT)).toBeNull(); + expect(document.documentElement.getAttribute("data-contrast")).not.toBe("sun"); + }); + + it.each(["Schließen", "Ausprobieren"])( + "%s hands the focus to the page heading instead of dropping it to the body", + async (name) => { + const user = userEvent.setup(); + const { AppearanceNotice } = await loadNotice(); + render( +
+

Guten Tag

+ +
, + ); + await user.click(await screen.findByRole("button", { name })); + expect(screen.queryByText(TEXT)).toBeNull(); + expect(document.activeElement).toBe(screen.getByRole("heading", { level: 1 })); + }, + ); + + it("sun switched on elsewhere (user menu) counts as seen: the notice does not come back after sun is off", async () => { + const { AppearanceNotice } = await loadNotice(); + const { useAppearance } = await import("@/lib/appearance/appearance"); + let api: ReturnType | null = null; + function Menu() { + api = useAppearance(); + return null; + } + render( + <> + + + , + ); + await screen.findByText(TEXT); + act(() => api?.setContrast("sun")); + expect(screen.queryByText(TEXT)).toBeNull(); + expect(window.localStorage.getItem(KEY)).toBe("seen"); + act(() => api?.setContrast("normal")); + expect(screen.queryByText(TEXT)).toBeNull(); + }); +}); diff --git a/ApiCore-Frontend/src/components/appearance-notice.tsx b/ApiCore-Frontend/src/components/appearance-notice.tsx new file mode 100644 index 0000000..dc6635e --- /dev/null +++ b/ApiCore-Frontend/src/components/appearance-notice.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useEffect, useId, useSyncExternalStore } from "react"; +import { Sun } from "lucide-react"; +import { toast } from "sonner"; +import { useAppearance } from "@/lib/appearance/appearance"; +import { Button } from "@/components/ui/button"; + +/** Remembered per device (spec 15.4): the notice is shown until it is closed or tried once. */ +export const SUN_NOTICE_KEY = "apicore.notice.sun"; +const SEEN = "seen"; +const CHANGE_EVENT = "apicore:notice"; + +/** Closed in this visit; covers blocked storage (private mode). */ +let seenThisVisit = false; + +function isSeen(): boolean { + if (seenThisVisit) return true; + try { + return window.localStorage.getItem(SUN_NOTICE_KEY) === SEEN; + } catch { + return false; + } +} + +function markSeen(): void { + seenThisVisit = true; + try { + window.localStorage.setItem(SUN_NOTICE_KEY, SEEN); + } catch { + // Not persisted - gone for this visit only. + } + window.dispatchEvent(new Event(CHANGE_EVENT)); +} + +function subscribe(onChange: () => void): () => void { + window.addEventListener(CHANGE_EVENT, onChange); + window.addEventListener("storage", onChange); + return () => { + window.removeEventListener(CHANGE_EVENT, onChange); + window.removeEventListener("storage", onChange); + }; +} + +/** + * The notice unmounts on close; focus inside it would drop to (WCAG 2.4.3). It goes to + * the page heading (h1 with tabIndex -1) instead. + */ +function keepFocus(): void { + const heading = document.querySelector("main h1, h1"); + heading?.focus(); +} + +const TEXT = "Neu: Sonnenmodus für draußen – im Benutzermenü."; + +/** One-time hint on the start page: try the sun mode or close the hint. */ +export function AppearanceNotice() { + const textId = useId(); + const { sunActive, setContrast } = useAppearance(); + // The server never shows it, so a device that has seen it gets no flash after hydration. + const seen = useSyncExternalStore(subscribe, isSeen, () => true); + // Sun switched on elsewhere (user menu, device setting) means the hint has done its job. + useEffect(() => { + if (sunActive && !seen) markSeen(); + }, [sunActive, seen]); + if (seen || sunActive) return null; + + return ( + + ); +} diff --git a/ApiCore-Frontend/src/components/form/checkbox-field.spec.tsx b/ApiCore-Frontend/src/components/form/checkbox-field.spec.tsx index a51c9c0..8472747 100644 --- a/ApiCore-Frontend/src/components/form/checkbox-field.spec.tsx +++ b/ApiCore-Frontend/src/components/form/checkbox-field.spec.tsx @@ -48,6 +48,14 @@ describe("CheckboxField (spec 10.3)", () => { expect(onChange.mock.calls).toEqual([[true], [false]]); }); + it("a long label without spaces wraps instead of being clipped (Umsetzen/Teilen/Vereinigen/Auflösen)", () => { + const label = "Umsetzen/Teilen/Vereinigen/Auflösen"; + render( {}} />); + for (const text of [label, "Lebenszyklus"]) { + expect(classesOf(screen.getByText(text)), text).toEqual(expect.arrayContaining(["wrap-break-word", "hyphens-auto"])); + } + }); + it("the row is at least 44 px high (min-h-11) and in a sheet 56 px (min-h-14)", () => { render( <> diff --git a/ApiCore-Frontend/src/components/form/checkbox-field.tsx b/ApiCore-Frontend/src/components/form/checkbox-field.tsx index 803fbe7..f865b24 100644 --- a/ApiCore-Frontend/src/components/form/checkbox-field.tsx +++ b/ApiCore-Frontend/src/components/form/checkbox-field.tsx @@ -44,6 +44,9 @@ function tileClass(isOn: boolean): string { : "items-center border border-input bg-card px-3 enabled:hover:bg-muted"; } +/** Long German words and slash-joined labels (Umsetzen/Teilen/…) break instead of being clipped. */ +const WRAP = "wrap-break-word hyphens-auto"; + const HINT_CLASS = "text-sm font-medium text-muted-foreground"; function joinIds(...ids: (string | false | undefined)[]): string | undefined { @@ -130,11 +133,11 @@ export function CheckboxField(props: CheckboxFieldProps) { ) : null; const text = ( - + {label} {description ? ( - + {description} ) : null} diff --git a/ApiCore-Frontend/src/components/honeycomb-pattern.tsx b/ApiCore-Frontend/src/components/honeycomb-pattern.tsx index 4dc4f08..fb1440a 100644 --- a/ApiCore-Frontend/src/components/honeycomb-pattern.tsx +++ b/ApiCore-Frontend/src/components/honeycomb-pattern.tsx @@ -7,7 +7,7 @@ export function HoneycombPattern({ className, id = "honeycomb" }: HoneycombPatte return ( ); } diff --git a/ApiCore-Frontend/src/components/notification-bell.tsx b/ApiCore-Frontend/src/components/notification-bell.tsx index b103aff..47fd2db 100644 --- a/ApiCore-Frontend/src/components/notification-bell.tsx +++ b/ApiCore-Frontend/src/components/notification-bell.tsx @@ -115,7 +115,7 @@ export function NotificationBell() { > {unreadCount > 0 ? ( - + diff --git a/ApiCore-Frontend/src/components/tab-picker.tsx b/ApiCore-Frontend/src/components/tab-picker.tsx new file mode 100644 index 0000000..6455ec9 --- /dev/null +++ b/ApiCore-Frontend/src/components/tab-picker.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { EnumSelect } from "@/components/form/enum-select"; +import { TabsList, TabsTrigger } from "@/components/ui/tabs"; + +interface TabPickerProps { + value: T; + onChange: (value: T) => void; + /** Label of each tab, in this order. */ + labels: Record; + values: readonly NoInfer[]; + /** Name of the tab bar. */ + listLabel?: string; + /** Name of the phone select; say what is chosen. */ + selectLabel?: string; +} + +/** + * The tab bar of a page with many areas, inside . On a phone the bar would wrap into + * several rows, so below `sm` the same choice is a select; the panels stay labelled by the tabs. + */ +export function TabPicker({ + value, + onChange, + labels, + values, + listLabel, + selectLabel = "Bereich wählen", +}: TabPickerProps) { + return ( + <> + + + {values.map((entry) => ( + + {labels[entry]} + + ))} + + + ); +} diff --git a/ApiCore-Frontend/src/components/ui/button.tsx b/ApiCore-Frontend/src/components/ui/button.tsx index 27ac34c..2dbcc4f 100644 --- a/ApiCore-Frontend/src/components/ui/button.tsx +++ b/ApiCore-Frontend/src/components/ui/button.tsx @@ -30,7 +30,7 @@ const buttonVariants = cva( default: "h-11 pointer-fine:h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-9 pointer-fine:h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + sm: "h-9 pointer-fine:h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", // 44 px: reliable with gloves or sticky fingers at the apiary. touch: "h-11 gap-2 px-4 text-base [&_svg:not([class*='size-'])]:size-5", diff --git a/ApiCore-Frontend/src/components/ui/select.spec.tsx b/ApiCore-Frontend/src/components/ui/select.spec.tsx index ab1ca14..4cbbf64 100644 --- a/ApiCore-Frontend/src/components/ui/select.spec.tsx +++ b/ApiCore-Frontend/src/components/ui/select.spec.tsx @@ -1,5 +1,7 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select"; @@ -17,6 +19,10 @@ function StatusSelect() { ); } +/** A display class with !important (`!flex`, `flex!`, variants too) would beat preflight's [hidden]. */ +const IMPORTANT_DISPLAY = + /(^|\s)(?:[^\s:]+:)*(?:!(?:block|flex|grid|inline|inline-block|inline-flex|inline-grid|contents|table)|(?:block|flex|grid|inline|inline-block|inline-flex|inline-grid|contents|table)!)(\s|$)/; + /** Listboxes a screen reader could still reach: not inside a hidden or inert ancestor. */ function reachableListboxes(): Element[] { return Array.from(document.querySelectorAll('[role="listbox"]')).filter( @@ -43,4 +49,35 @@ describe("SelectContent (spec 17.4)", () => { await userEvent.click(await screen.findByRole("option", { name: "Aktiv" })); await waitFor(() => expect(reachableListboxes()).toEqual([])); }); + + it("a popup Base UI keeps mounted after an autofill (forceMount) is hidden, not a 0x0 listbox for AT", async () => { + const { container } = render(); + const input = container.querySelector("input") as HTMLInputElement; + expect(input).not.toBeNull(); + // Browser autofill writes the hidden input; Base UI then force-mounts the popup closed. + await act(async () => { + fireEvent.change(input, { target: { value: "active" } }); + }); + // The closed popup is in the DOM now: the 0x0 listbox seen in the browser. + await waitFor(() => expect(document.querySelectorAll('[role="listbox"]').length).toBeGreaterThan(0)); + const mounted = Array.from(document.querySelectorAll('[role="listbox"]')); + for (const listbox of mounted) { + // The closed popup's positioner carries `hidden`. Preflight hides [hidden] with + // `display: none !important`, so only an important display class could bring it back. + const positioner = listbox.closest("[data-base-ui-portal] > *") as HTMLElement; + expect(positioner.hasAttribute("hidden")).toBe(true); + expect(positioner.className).not.toMatch(IMPORTANT_DISPLAY); + expect(listbox.closest("[hidden]")).toBe(positioner); + } + expect(readFileSync(join(__dirname, "..", "..", "app", "globals.css"), "utf8")).toMatch(/@import\s+["']tailwindcss["']/); + expect(reachableListboxes()).toEqual([]); + expect(screen.queryByRole("listbox")).toBeNull(); + }); + + it("the important-display check catches !flex and flex! and leaves plain classes alone", () => { + expect(IMPORTANT_DISPLAY.test("isolate z-50 !flex")).toBe(true); + expect(IMPORTANT_DISPLAY.test("md:flex! z-50")).toBe(true); + expect(IMPORTANT_DISPLAY.test("isolate z-50 flex")).toBe(false); + expect(IMPORTANT_DISPLAY.test("isolate z-50")).toBe(false); + }); }); diff --git a/ApiCore-Frontend/src/components/ui/tabs.spec.tsx b/ApiCore-Frontend/src/components/ui/tabs.spec.tsx index 8254469..963a095 100644 --- a/ApiCore-Frontend/src/components/ui/tabs.spec.tsx +++ b/ApiCore-Frontend/src/components/ui/tabs.spec.tsx @@ -71,6 +71,20 @@ describe("Tabs (spec 10.4)", () => { expect(has(screen.getByRole("tablist"), "bg-muted")).toBe(true); }); + it("an inactive tab answers a mouse hover with surface, text and a neutral bar, never a touch", () => { + render(); + const inactive = screen.getByRole("tab", { name: "Völker" }); + for (const token of [ + "pointer-fine:hover:not-data-active:bg-card/60", + "pointer-fine:hover:not-data-active:border-b-input", + "pointer-fine:hover:text-foreground", + ]) { + expect(has(inactive, token), token).toBe(true); + } + // Same alpha as TAB_HOVER_ALPHA in contrast.spec.ts. A bare hover: would stick after a tap. + expect(inactive.className).not.toMatch(/(^|\s)hover:/); + }); + it("tabs are 44 px for a thumb, compact only with a fine pointer, 16 px text, 4 px apart", () => { render(); const list = screen.getByRole("tablist"); diff --git a/ApiCore-Frontend/src/components/ui/tabs.tsx b/ApiCore-Frontend/src/components/ui/tabs.tsx index 866f96b..8ca93e9 100644 --- a/ApiCore-Frontend/src/components/ui/tabs.tsx +++ b/ApiCore-Frontend/src/components/ui/tabs.tsx @@ -43,6 +43,7 @@ function TabsList({ className, ...props }: TabsPrimitive.List.Props) { * --muted-foreground on --muted (≥ 4.5:1 in all four sets). Active on --card, --foreground, * semibold, with a 3 px --primary-ink bar at the bottom: surface, weight and bar, never colour * alone. The top border keeps the label centred against the bar. Focus is the global ring. + * A mouse hover (pointer-fine only, no sticky tap) previews it: card/60, --foreground, --input bar. */ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) { return ( @@ -50,7 +51,7 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) { data-slot="tabs-trigger" className={cn( "inline-flex h-11 items-center justify-center gap-1.5 rounded-lg border-t-3 border-b-3 border-transparent px-4 text-base font-medium whitespace-nowrap text-muted-foreground pointer-fine:h-9", - "hover:text-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50", + "pointer-fine:hover:text-foreground pointer-fine:hover:not-data-active:bg-card/60 pointer-fine:hover:not-data-active:border-b-input disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50", "data-active:border-b-primary-ink data-active:bg-card data-active:font-semibold data-active:text-foreground", "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className diff --git a/ApiCore-Frontend/src/components/ui/ui-colors.spec.tsx b/ApiCore-Frontend/src/components/ui/ui-colors.spec.tsx index 30347be..2f94aa7 100644 --- a/ApiCore-Frontend/src/components/ui/ui-colors.spec.tsx +++ b/ApiCore-Frontend/src/components/ui/ui-colors.spec.tsx @@ -1,23 +1,8 @@ -import { readdirSync, readFileSync, statSync } from "node:fs"; -import { join } from "node:path"; import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { Badge } from "./badge"; import { Button } from "./button"; -const SRC = join(__dirname, "..", ".."); - -function sourceFiles(dir: string): string[] { - return readdirSync(dir).flatMap((name) => { - const path = join(dir, name); - if (statSync(path).isDirectory()) return sourceFiles(path); - return /\.tsx?$/.test(name) && !/\.spec\./.test(name) ? [path] : []; - }); -} - -/** A Tailwind class with the dark variant, e.g. `dark:bg-input/30` (not an object key `dark: x`). */ -const DARK_CLASS = /(^|[\s"'`])dark:[^\s"'`]/m; - describe("ui colours after the value switch (spec 10.8 step 4)", () => { it("the default button uses solid primary with primary-foreground and no gradient", () => { render(); @@ -39,16 +24,5 @@ describe("ui colours after the value switch (spec 10.8 step 4)", () => { expect(screen.getByText("Link").className).toMatch(/(^|\s)text-primary-ink(\s|$)/); expect(screen.getByText("Achtung").className).toMatch(/(^|\s)text-warning-ink(\s|$)/); }); - - it("ui components contain no dark: classes", () => { - const offenders = sourceFiles(join(SRC, "components", "ui")).filter((file) => - DARK_CLASS.test(readFileSync(file, "utf8")), - ); - expect(offenders).toEqual([]); - }); - - it("no source file uses dark: classes - a mode is only another token set", () => { - const offenders = sourceFiles(SRC).filter((file) => DARK_CLASS.test(readFileSync(file, "utf8"))); - expect(offenders).toEqual([]); - }); + // `dark:` classes: the baukasten guard (src/styles/baukasten-guard.spec.ts) bans them in all of src. }); diff --git a/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts b/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts index d44d806..3aaa73a 100644 --- a/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts +++ b/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts @@ -1,6 +1,7 @@ -import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; -import { join, relative } from "node:path"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { FILES, FRONTEND, SRC } from "./guard-files.spec-helper"; /** * Baukasten guard (spec 10.7). Each T5 task adds the rule it fulfils, with every exception and @@ -16,11 +17,10 @@ interface GuardRule { allow: { file: string; reason: string }[]; /** Directories the rule does not cover at all (path prefix relative to ApiCore-Frontend). */ outside?: { prefix: string; reason: string }[]; + /** Self-test: snippets the pattern must catch and snippets it must leave alone. */ + examples: { catches: string[]; ignores: string[] }; } -const FRONTEND = join(__dirname, "..", ".."); -const SRC = join(FRONTEND, "src"); - /** * `keys` (role, type) set to `value` by hand: JSX (role="x", role={on ? "x" : y}), object or * spread form (role: "x") and setAttribute("role", "x"). A CSS selector ([role="x"]) only finds @@ -35,28 +35,50 @@ function ownRole(keys: string, value: string): RegExp { return new RegExp(String.raw`(?]*)(?:${attribute}|${setAttribute})`); } -/** Spec 17.8: the hive stage has its own motion (Beute §9.8): flights, lifting, idle motion. */ -const HIVE_STAGE: NonNullable = [ - { - prefix: "src/components/hive/", - reason: "The hive stage moves on purpose (Beute §9.4, §9.8): a part lifts on hover and flies into focus.", - }, - { - prefix: "src/app/(app)/colonies/[id]/hive/", - reason: "The full-screen hive editor is the same stage (spec 17.8).", - }, -]; - +/** + * Spec 17.8 lets the hive stage move on its own (Beute §9.4, §9.8: lift on hover, flight into + * focus). It does so in three.js, not with CSS classes, so no motion rule leaves it out today + * (the stale-exception test would fail). Should the stage ever need a CSS exception, add an + * `outside` entry with that reason to the one rule it needs. + */ /** A duration above --motion-moment (420 ms): Tailwind steps and arbitrary ms values. */ const OVER_MOMENT_MS = String.raw`(?:42[1-9]|4[3-9]\d|[5-9]\d\d|[1-9]\d{3,})`; +/** A px value below 13 (0–12.99…). */ +const UNDER_13_PX = String.raw`(?:\d|1[0-2])(?:\.\d+)?px`; +/** A rem/em value below 0.8125 (13 px at a 16 px root): the digits after the point sort below 8125. */ +const UNDER_13_REM = String.raw`0?\.(?:[0-7]\d*|8(?:0\d*|1(?:[01]\d*|2(?:[0-4]\d*)?)?)?)r?em`; + const RULES: GuardRule[] = [ - // Task A3/A4: the area tone replaced the gradients (spec 11); the gradient tokens are gone. + // Task A3/A4/A10: the area tone replaced the gradients (spec 11); the gradient tokens are gone. + // PageHeader, DetailHeader and EntityCard have no `gradient` prop any more (also a type error). + // A canvas gradient that paints a 3D texture (createLinearGradient) is no CSS background. { - id: "no gradient prop or gradient token", - pattern: /gradient=|--gradient-|\[background-image:var\(--gradient/, + id: "no gradient prop, gradient token or gradient background", + pattern: new RegExp( + [ + String.raw`gradient=`, + String.raw`--gradient-`, + String.raw`(?', + "", + '', + '"[background-image:var(--gradient-honey)]"', + '"bg-gradient-to-r from-primary to-warning"', + '"bg-linear-to-b from-card"', + '"bg-radial"', + "background: linear-gradient(90deg, red, blue);", + ], + ignores: ["context.createLinearGradient(0, 0, 0, 512)", '"bg-primary text-primary-foreground"', ''], + }, }, // Task A3/A4: white text fails on honey and on the light sets. The hive stage uses // --holo-label-fg instead, so it needs no exception either. @@ -65,6 +87,89 @@ const RULES: GuardRule[] = [ pattern: /(?", + '', + "font-size: 11px;", + "font-size: 0.75rem;", + "style={{ fontSize: 11 }}", + 'style={{ lineHeight: 1, fontSize: "12px" }}', + "const AXIS_LABEL_SIZE = 10;", + "const FONT_SIZE_PX = 12;", + ], + ignores: [ + '"text-[13px]"', + '"text-[0.8125rem]"', + '"text-[1rem]"', + '"text-xs"', + "", + "", + '"forced-colors:text-[CanvasText]"', + "fontSize: 10,", + "--text-xs: 0.8125rem;", + "style={{ fontSize: 14 }}", + "const LABEL_FONT_SIZE = 16;", + "const MAX_SIZE = 10;", + "const LABEL_GAP = 4;", + ], + }, }, // Task A5: one focus style, drawn by the base rule :where(:focus-visible) (spec 4.6). { @@ -72,12 +177,20 @@ const RULES: GuardRule[] = [ pattern: /ring-ring\/50/, scope: "src/**/*.{ts,tsx}", allow: [], + examples: { + catches: ['"focus-visible:ring-3 focus-visible:ring-ring/50"'], + ignores: ['"ring-ring"', '"focus:inset-ring-2 focus:inset-ring-ring"'], + }, }, { id: "no outline-none", pattern: /(?', + '
', + "