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 (
-
);
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 (
);
}
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 ? (
-
+
{unreadCount > 9 ? "9+" : unreadCount}
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: /(?',
+ '',
+ "