feat(t5): Wächter vollständig, Hinweis Sonnenmodus, Nacharbeiten (T5a A10)
Backend Image / build-and-push (push) Successful in 15m27s
Frontend Image / build-and-push (push) Successful in 23m9s

- 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) <[email protected]>
This commit is contained in:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-19 13:47:59 +02:00
1 parent 355a353671
commit 427969d8b9
42 files changed
+1002 -349

No files matched your search

@@ -43,4 +43,14 @@ describe("ApiariesPage", () => {
expect(await screen.findByText("Heimstand")).toBeInTheDocument(); expect(await screen.findByText("Heimstand")).toBeInTheDocument();
expect(screen.getByText("Garten")).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(<ApiariesPage />);
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();
});
}); });
@@ -13,7 +13,7 @@ import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card"; import { EntityCard } from "@/components/entity-card";
import { QueryState } from "@/components/query-state"; 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 { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -84,8 +84,8 @@ export default function ApiariesPage() {
<ActiveHistoryTabs <ActiveHistoryTabs
value={tab} value={tab}
onValueChange={setTab} onValueChange={setTab}
activeCount={activeApiaries.length} activeCount={countOf(apiariesQuery.data, activeApiaries)}
historyCount={historyApiaries.length} historyCount={countOf(apiariesQuery.data, historyApiaries)}
toolbar={ toolbar={
<Input <Input
className="w-48" className="w-48"
@@ -208,7 +208,8 @@ describe("ColonyVarroaCard trend", () => {
it("labels the date axis and explains the lines in a legend", async () => { it("labels the date axis and explains the lines in a legend", async () => {
renderCard(); renderCard();
const chart = await screen.findByRole("img"); 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("01.06.2026")).toBeInTheDocument();
expect(axis.getByText("15.06.2026")).toBeInTheDocument(); expect(axis.getByText("15.06.2026")).toBeInTheDocument();
await screen.findByText("Rote Grenze"); await screen.findByText("Rote Grenze");
@@ -217,4 +218,17 @@ describe("ColonyVarroaCard trend", () => {
expect(within(legend).getByText("Rote Grenze")).toBeInTheDocument(); expect(within(legend).getByText("Rote Grenze")).toBeInTheDocument();
expect(within(legend).getByText("Gelbe 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();
}
});
}); });
@@ -20,11 +20,12 @@ import { VarroaEditDialog } from "./varroa-edit-dialog";
const CHART_WIDTH = 300; const CHART_WIDTH = 300;
const CHART_HEIGHT = 136; const CHART_HEIGHT = 136;
const PAD_X = 12; const PAD_X = 12;
const PAD_TOP = 12; // Room for the HTML limit label (~22 px) above the highest point.
const PAD_BOTTOM = 28; const PAD_TOP = 24;
const PAD_BOTTOM = 12;
const MARKER_RADIUS = 4; const MARKER_RADIUS = 4;
const LABEL_GAP = 4; /** Horizontal inset of the HTML labels, matching PAD_X of the viewBox. */
const AXIS_LABEL_SIZE = 10; const PAD_X_PERCENT = `${(PAD_X / CHART_WIDTH) * 100}%`;
const MAX_BAND_MONTHS = 24; const MAX_BAND_MONTHS = 24;
const ONE_HOUR_MS = 60 * 60 * 1000; const ONE_HOUR_MS = 60 * 60 * 1000;
@@ -203,10 +204,11 @@ function VarroaTrendChart({
const monthly = monthLimitsText(limits, range); const monthly = monthLimitsText(limits, range);
const axisY = CHART_HEIGHT - PAD_BOTTOM + MARKER_RADIUS * 2; const axisY = CHART_HEIGHT - PAD_BOTTOM + MARKER_RADIUS * 2;
const labelY = CHART_HEIGHT - LABEL_GAP;
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{/* Labels are HTML at text-xs: SVG text scales with the viewBox and drops below 13 px. */}
<div className="relative">
<svg <svg
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`} viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
className="w-full text-primary-ink" className="w-full text-primary-ink"
@@ -230,17 +232,6 @@ function VarroaTrendChart({
) : null} ) : null}
</g> </g>
) : null} ) : null}
{lastLimit ? (
<text
x={CHART_WIDTH - PAD_X}
y={yOf(lastLimit.redAbove) - LABEL_GAP}
textAnchor="end"
fontSize={10}
className="fill-muted-foreground"
>
{`Grenze ${formatThreshold(lastLimit.redAbove)}/Tag`}
</text>
) : null}
<line <line
x1={PAD_X} x1={PAD_X}
x2={CHART_WIDTH - PAD_X} x2={CHART_WIDTH - PAD_X}
@@ -250,18 +241,6 @@ function VarroaTrendChart({
strokeWidth={0.5} strokeWidth={0.5}
className="text-muted-foreground" className="text-muted-foreground"
/> />
<text x={PAD_X} y={labelY} fontSize={AXIS_LABEL_SIZE} className="fill-muted-foreground">
{formatDate(first.time)}
</text>
<text
x={CHART_WIDTH - PAD_X}
y={labelY}
textAnchor="end"
fontSize={AXIS_LABEL_SIZE}
className="fill-muted-foreground"
>
{formatDate(last.time)}
</text>
<polyline points={line} fill="none" stroke="currentColor" strokeWidth={2} /> <polyline points={line} fill="none" stroke="currentColor" strokeWidth={2} />
{points.map((p) => ( {points.map((p) => (
<circle <circle
@@ -275,6 +254,20 @@ function VarroaTrendChart({
/> />
))} ))}
</svg> </svg>
{lastLimit ? (
<span
aria-hidden="true"
className="absolute -translate-y-full pb-1 text-xs text-muted-foreground"
style={{ top: `${(yOf(lastLimit.redAbove) / CHART_HEIGHT) * 100}%`, right: PAD_X_PERCENT }}
>
{`Grenze ${formatThreshold(lastLimit.redAbove)}/Tag`}
</span>
) : null}
</div>
<div aria-hidden="true" className="flex justify-between gap-2" style={{ paddingInline: PAD_X_PERCENT }}>
<span className="text-xs text-muted-foreground">{formatDate(first.time)}</span>
<span className="text-xs text-muted-foreground">{formatDate(last.time)}</span>
</div>
<ChartLegend hasRed={redPath !== ""} hasYellow={yellowPath !== ""} /> <ChartLegend hasRed={redPath !== ""} hasYellow={yellowPath !== ""} />
</div> </div>
); );
@@ -22,7 +22,7 @@ import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card"; import { EntityCard } from "@/components/entity-card";
import { QueryState } from "@/components/query-state"; 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 { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -108,8 +108,8 @@ export default function ColoniesPage() {
setTab(next); setTab(next);
setStatus(""); setStatus("");
}} }}
activeCount={activeColonies.length} activeCount={countOf(coloniesQuery.data, activeColonies)}
historyCount={historyColonies.length} historyCount={countOf(coloniesQuery.data, historyColonies)}
toolbar={ toolbar={
<EnumSelect <EnumSelect
ariaLabel="Nach Status filtern" ariaLabel="Nach Status filtern"
@@ -288,4 +288,34 @@ describe("DashboardPage – Heute", () => {
expect(header.getAttribute("style")).toBeNull(); expect(header.getAttribute("style")).toBeNull();
expect(container.innerHTML).not.toMatch(/text-white|--gradient/); 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<HTMLElement>(".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 <body>.
await userEvent.click(within(notice).getByRole("button", { name: "Schließen" }));
expect(document.activeElement).toBe(screen.getByRole("heading", { level: 1 }));
});
}); });
@@ -24,6 +24,7 @@ import { DashboardAlert, DashboardAlertType } from "@/lib/dashboard/types";
import { fetchTasks } from "@/lib/tasks/api"; import { fetchTasks } from "@/lib/tasks/api";
import { tasksForToday } from "@/lib/tasks/today"; import { tasksForToday } from "@/lib/tasks/today";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { AppearanceNotice } from "@/components/appearance-notice";
import { StandNote } from "@/components/offline/stand-note"; import { StandNote } from "@/components/offline/stand-note";
import { ComplianceLine } from "@/components/today/compliance-line"; import { ComplianceLine } from "@/components/today/compliance-line";
import { TodayPanel } from "@/components/today/today-panel"; import { TodayPanel } from "@/components/today/today-panel";
@@ -157,7 +158,8 @@ export default function DashboardPage() {
return ( return (
<main className="mx-auto flex w-full max-w-3xl flex-col gap-8 p-4 sm:p-6"> <main className="mx-auto flex w-full max-w-3xl flex-col gap-8 p-4 sm:p-6">
<header className="flex flex-col gap-1"> <header className="flex flex-col gap-1">
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl"> {/* tabIndex -1: the sun notice hands the focus here when it closes. */}
<h1 tabIndex={-1} className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
{greeting(now)} {greeting(now)}
{firstName ? `, ${firstName}` : ""} {firstName ? `, ${firstName}` : ""}
</h1> </h1>
@@ -167,6 +169,7 @@ export default function DashboardPage() {
</p> </p>
</header> </header>
<StandNote updatedAt={standAt} isStale={hasStaleData} className="-mt-5" /> <StandNote updatedAt={standAt} isStale={hasStaleData} className="-mt-5" />
<AppearanceNotice />
{autopilotError && !isAutopilotSilent ? ( {autopilotError && !isAutopilotSilent ? (
<div <div
@@ -15,7 +15,7 @@ import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card"; import { EntityCard } from "@/components/entity-card";
import { QueryState } from "@/components/query-state"; 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 { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -102,8 +102,8 @@ export default function HoneyBatchesPage() {
<ActiveHistoryTabs <ActiveHistoryTabs
value={tab} value={tab}
onValueChange={setTab} onValueChange={setTab}
activeCount={activeBatches.length} activeCount={countOf(honeyBatchesQuery.data, activeBatches)}
historyCount={historyBatches.length} historyCount={countOf(honeyBatchesQuery.data, historyBatches)}
> >
{{ active: listOf("active"), history: listOf("history") }} {{ active: listOf("active"), history: listOf("history") }}
</ActiveHistoryTabs> </ActiveHistoryTabs>
@@ -88,6 +88,18 @@ describe("MedicinesPage", () => {
expect(screen.getByText("Oxalsäure")).toBeInTheDocument(); 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 () => { it("applying a suggestion opens the form prefilled with the values of the latest treatment", async () => {
vi.mocked(fetchMedicines).mockResolvedValue([]); vi.mocked(fetchMedicines).mockResolvedValue([]);
vi.mocked(fetchMedicineSuggestions).mockResolvedValue([suggestion()]); vi.mocked(fetchMedicineSuggestions).mockResolvedValue([suggestion()]);
@@ -6,7 +6,7 @@ import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/lib/auth/auth-context"; import { useAuth } from "@/lib/auth/auth-context";
import { fetchMedicines } from "@/lib/medicines/api"; import { fetchMedicines } from "@/lib/medicines/api";
import type { MedicineView } from "@/lib/medicines/types"; 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 { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card"; import { EntityCard } from "@/components/entity-card";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note"; 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"}` ? `${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) => (
<QueryState query={medicinesQuery} what="Tierarzneimittel">
{() => bucketList(bucket)}
</QueryState>
);
const bucketList = (bucket: EntityLifecycleTab) => {
const shown = bucket === "active" ? active : inactive; const shown = bucket === "active" ? active : inactive;
if (shown.length > 0) return <MedicineGrid medicines={shown} />; if (shown.length > 0) return <MedicineGrid medicines={shown} />;
// An empty catalogue starts from the beekeeper's own treatments.
if (bucket === "active" && medicines.length === 0) {
return <MedicineSuggestions canCreate={canCreate} disabled={isOffline} describedBy={describedBy} />;
}
return ( return (
<EmptyState <EmptyState
icon={Pill} icon={Pill}
@@ -63,16 +73,11 @@ export default function MedicinesPage() {
/> />
{canCreate ? <OnlineOnlyHint id={hintId} /> : null} {canCreate ? <OnlineOnlyHint id={hintId} /> : null}
<QueryState query={medicinesQuery} what="Tierarzneimittel">
{() =>
medicines.length === 0 ? (
<MedicineSuggestions canCreate={canCreate} disabled={isOffline} describedBy={describedBy} />
) : (
<ActiveHistoryTabs <ActiveHistoryTabs
value={tab} value={tab}
onValueChange={setTab} onValueChange={setTab}
activeCount={active.length} activeCount={countOf(medicinesQuery.data, active)}
historyCount={inactive.length} historyCount={countOf(medicinesQuery.data, inactive)}
toolbar={ toolbar={
<p aria-live="polite" className="text-sm text-muted-foreground"> <p aria-live="polite" className="text-sm text-muted-foreground">
{countText} {countText}
@@ -81,9 +86,6 @@ export default function MedicinesPage() {
> >
{{ active: listOf("active"), history: listOf("history") }} {{ active: listOf("active"), history: listOf("history") }}
</ActiveHistoryTabs> </ActiveHistoryTabs>
)
}
</QueryState>
</main> </main>
); );
} }
@@ -14,7 +14,7 @@ import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card"; import { EntityCard } from "@/components/entity-card";
import { QueryState } from "@/components/query-state"; 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() { export default function ProductsPage() {
const [tab, setTab] = useState<EntityLifecycleTab>("active"); const [tab, setTab] = useState<EntityLifecycleTab>("active");
@@ -76,8 +76,8 @@ export default function ProductsPage() {
<ActiveHistoryTabs <ActiveHistoryTabs
value={tab} value={tab}
onValueChange={setTab} onValueChange={setTab}
activeCount={activeProducts.length} activeCount={countOf(productsQuery.data, activeProducts)}
historyCount={historyProducts.length} historyCount={countOf(productsQuery.data, historyProducts)}
> >
{{ active: listOf("active"), history: listOf("history") }} {{ active: listOf("active"), history: listOf("history") }}
</ActiveHistoryTabs> </ActiveHistoryTabs>
@@ -13,7 +13,7 @@ import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card"; import { EntityCard } from "@/components/entity-card";
import { QueryState } from "@/components/query-state"; 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"; import { EnumSelect } from "@/components/form/enum-select";
export default function QueensPage() { export default function QueensPage() {
@@ -68,8 +68,8 @@ export default function QueensPage() {
setTab(next); setTab(next);
setStatus(""); setStatus("");
}} }}
activeCount={activeQueens.length} activeCount={countOf(queensQuery.data, activeQueens)}
historyCount={historyQueens.length} historyCount={countOf(queensQuery.data, historyQueens)}
toolbar={ toolbar={
<EnumSelect <EnumSelect
ariaLabel="Nach Status filtern" ariaLabel="Nach Status filtern"
@@ -3,7 +3,8 @@
import { Suspense, type ComponentType } from "react"; import { Suspense, type ComponentType } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent } from "@/components/ui/tabs";
import { TabPicker } from "@/components/tab-picker";
import { WebhooksTab } from "./webhooks-tab"; import { WebhooksTab } from "./webhooks-tab";
import { TaskTemplatesTab } from "./task-templates-tab"; import { TaskTemplatesTab } from "./task-templates-tab";
import { LabelTemplatesTab } from "./label-templates-tab"; import { LabelTemplatesTab } from "./label-templates-tab";
@@ -32,6 +33,9 @@ const TABS: { value: Tab; label: string; Content: ComponentType }[] = [
{ value: "webhooks", label: "Webhooks", Content: WebhooksTab }, { value: "webhooks", label: "Webhooks", Content: WebhooksTab },
]; ];
const TAB_VALUES = TABS.map((entry) => entry.value);
const TAB_LABELS = Object.fromEntries(TABS.map((entry) => [entry.value, entry.label])) as Record<Tab, string>;
function isTab(value: unknown): value is Tab { function isTab(value: unknown): value is Tab {
return TABS.some((tab) => tab.value === value); return TABS.some((tab) => tab.value === value);
} }
@@ -60,13 +64,14 @@ function AutomationPageContent() {
if (isTab(next)) setTab(next); if (isTab(next)) setTab(next);
}} }}
> >
<TabsList aria-label="Bereiche der Automatisierung"> <TabPicker
{TABS.map((entry) => ( value={tab}
<TabsTrigger key={entry.value} value={entry.value}> onChange={setTab}
{entry.label} labels={TAB_LABELS}
</TabsTrigger> values={TAB_VALUES}
))} listLabel="Bereiche der Automatisierung"
</TabsList> selectLabel="Bereich der Automatisierung wählen"
/>
{TABS.map(({ value, Content }) => ( {TABS.map(({ value, Content }) => (
<TabsContent key={value} value={value}> <TabsContent key={value} value={value}>
<Content /> <Content />
@@ -11,7 +11,7 @@ import { Badge } from "@/components/ui/badge";
import { ConfirmDialog } from "@/components/confirm-dialog"; import { ConfirmDialog } from "@/components/confirm-dialog";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card"; 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 { CreateTaskTemplateDialog } from "./create-task-template-dialog";
import { EditTaskTemplateDialog } from "./edit-task-template-dialog"; import { EditTaskTemplateDialog } from "./edit-task-template-dialog";
@@ -116,8 +116,8 @@ export function TaskTemplatesTab() {
<ActiveHistoryTabs <ActiveHistoryTabs
value={tab} value={tab}
onValueChange={setTab} onValueChange={setTab}
activeCount={activeTemplates.length} activeCount={countOf(templates, activeTemplates)}
historyCount={historyTemplates.length} historyCount={countOf(templates, historyTemplates)}
toolbar={<CreateTaskTemplateDialog />} toolbar={<CreateTaskTemplateDialog />}
toolbarAlign="end" toolbarAlign="end"
> >
@@ -43,6 +43,23 @@ describe("both settings pages use Tabs directly", () => {
expect(replace).toHaveBeenLastCalledWith("/settings/automation?tab=webhooks"); 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(<AutomationPage />);
// 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", () => { it("Automatisierung: an unknown ?tab= falls back to Vorschläge", () => {
search = "tab=unknown"; search = "tab=unknown";
render(<AutomationPage />); render(<AutomationPage />);
@@ -78,7 +78,7 @@ export function CreateRoleDialog() {
}} }}
> >
<Button onClick={() => setOpen(true)}>+ Neue Rolle</Button> <Button onClick={() => setOpen(true)}>+ Neue Rolle</Button>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto"> <DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Neue Rolle</DialogTitle> <DialogTitle>Neue Rolle</DialogTitle>
</DialogHeader> </DialogHeader>
@@ -115,7 +115,7 @@ interface EditRoleDialogProps {
export function EditRoleDialog({ role, onClose }: EditRoleDialogProps) { export function EditRoleDialog({ role, onClose }: EditRoleDialogProps) {
return ( return (
<Dialog open={role !== null} onOpenChange={(next) => !next && onClose()}> <Dialog open={role !== null} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto"> <DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Rolle bearbeiten{role ? `: ${role.name}` : ""}</DialogTitle> <DialogTitle>Rolle bearbeiten{role ? `: ${role.name}` : ""}</DialogTitle>
</DialogHeader> </DialogHeader>
@@ -1,3 +1,5 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { useState } from "react"; import { useState } from "react";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
@@ -45,4 +47,13 @@ describe("PermissionMatrix", () => {
expect(box).toHaveAccessibleDescription(expect.stringContaining(hint.textContent ?? "x")); 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 = /<DialogContent className="([^"]*)"/.exec(text)?.[1] ?? "";
expect(content.split(/\s+/)).toContain("sm:max-w-2xl");
},
);
}); });
@@ -16,7 +16,7 @@ import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/confirm-dialog"; import { ConfirmDialog } from "@/components/confirm-dialog";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card"; 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 { CreateRoleDialog } from "./create-role-dialog"; import { CreateRoleDialog } from "./create-role-dialog";
import { EditRoleDialog } from "./edit-role-dialog"; import { EditRoleDialog } from "./edit-role-dialog";
@@ -160,8 +160,8 @@ export function RolesTab() {
<ActiveHistoryTabs <ActiveHistoryTabs
value={tab} value={tab}
onValueChange={setTab} onValueChange={setTab}
activeCount={activeRoles.length} activeCount={countOf(roles, activeRoles)}
historyCount={historyRoles.length} historyCount={countOf(roles, historyRoles)}
toolbar={<CreateRoleDialog />} toolbar={<CreateRoleDialog />}
toolbarAlign="end" toolbarAlign="end"
> >
@@ -63,4 +63,11 @@ describe("StatisticsPage", () => {
expect(list).toHaveAttribute("data-variant", "segment"); expect(list).toHaveAttribute("data-variant", "segment");
expect(screen.getByRole("tab", { name: "Völker", hidden: true })).toHaveAttribute("aria-selected", "true"); 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(<StatisticsPage />);
expect(screen.getByRole("combobox", { name: "Auswertung wählen" })).toHaveTextContent("Völker");
expect(screen.getByRole("tablist", { hidden: true })).toHaveAccessibleName("Auswertungen");
});
}); });
@@ -18,8 +18,8 @@ import { StatBarList } from "@/components/stat-bar-list";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { QueryState } from "@/components/query-state"; import { QueryState } from "@/components/query-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent } from "@/components/ui/tabs";
import { EnumSelect } from "@/components/form/enum-select"; import { TabPicker } from "@/components/tab-picker";
import { keysOf } from "@/lib/keys-of"; import { keysOf } from "@/lib/keys-of";
const TAB_LABELS = { const TAB_LABELS = {
@@ -345,19 +345,6 @@ function FeedingStatsPanel({ colonyLabels }: { colonyLabels: Map<string, string>
); );
} }
/** 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 (
<EnumSelect
ariaLabel="Bereich wählen"
labels={TAB_LABELS}
value={value}
onChange={onChange}
className="sm:hidden"
/>
);
}
export default function StatisticsPage() { export default function StatisticsPage() {
const [tab, setTab] = useState<StatsTab>("colonies"); const [tab, setTab] = useState<StatsTab>("colonies");
const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() }); const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
@@ -375,14 +362,14 @@ export default function StatisticsPage() {
/> />
<Tabs value={tab} onValueChange={(next) => setTab(toStatsTab(next) ?? tab)}> <Tabs value={tab} onValueChange={(next) => setTab(toStatsTab(next) ?? tab)}>
<TabPicker value={tab} onChange={setTab} /> <TabPicker
<TabsList className="hidden sm:inline-flex"> value={tab}
{TABS.map((entry) => ( onChange={setTab}
<TabsTrigger key={entry} value={entry}> labels={TAB_LABELS}
{TAB_LABELS[entry]} values={TABS}
</TabsTrigger> listLabel="Auswertungen"
))} selectLabel="Auswertung wählen"
</TabsList> />
<TabsContent value="colonies"> <TabsContent value="colonies">
<ColonyStatsPanel /> <ColonyStatsPanel />
</TabsContent> </TabsContent>
@@ -12,7 +12,7 @@ import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card"; import { EntityCard } from "@/components/entity-card";
import { QueryState } from "@/components/query-state"; 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 { Badge } from "@/components/ui/badge";
import { CreateStorageContainerDialog } from "./create-storage-container-dialog"; import { CreateStorageContainerDialog } from "./create-storage-container-dialog";
import { CreateStockItemDialog } from "./create-stock-item-dialog"; import { CreateStockItemDialog } from "./create-stock-item-dialog";
@@ -65,8 +65,8 @@ export default function StoragePage() {
heading={<h2 className="text-lg font-semibold tracking-tight">Lagerbehälter</h2>} heading={<h2 className="text-lg font-semibold tracking-tight">Lagerbehälter</h2>}
value={containerTab} value={containerTab}
onValueChange={setContainerTab} onValueChange={setContainerTab}
activeCount={activeContainers.length} activeCount={countOf(containersQuery.data, activeContainers)}
historyCount={historyContainers.length} historyCount={countOf(containersQuery.data, historyContainers)}
> >
{{ active: containersOf("active"), history: containersOf("history") }} {{ active: containersOf("active"), history: containersOf("history") }}
</ActiveHistoryTabs> </ActiveHistoryTabs>
@@ -14,7 +14,7 @@ import { TaskSourceBadge, TaskStatusPill } from "@/components/task-status";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card"; 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 { EnumSelect } from "@/components/form/enum-select";
import { TaskEditDialog } from "./task-edit-dialog"; import { TaskEditDialog } from "./task-edit-dialog";
@@ -136,8 +136,8 @@ export default function TasksPage() {
<ActiveHistoryTabs <ActiveHistoryTabs
value={tab} value={tab}
onValueChange={setTab} onValueChange={setTab}
activeCount={activeTasks.length} activeCount={countOf(tasks, activeTasks)}
historyCount={historyTasks.length} historyCount={countOf(tasks, historyTasks)}
toolbar={ toolbar={
<> <>
<EnumSelect <EnumSelect
@@ -82,7 +82,7 @@ function Stat({ icon: Icon, label, value }: StatProps) {
<div className="flex flex-col items-center gap-1 rounded-xl bg-accent/40 px-3 py-3 text-center"> <div className="flex flex-col items-center gap-1 rounded-xl bg-accent/40 px-3 py-3 text-center">
<Icon className="size-4 text-primary-ink" /> <Icon className="size-4 text-primary-ink" />
<span className="text-sm font-semibold">{value}</span> <span className="text-sm font-semibold">{value}</span>
<span className="text-[0.7rem] text-muted-foreground">{label}</span> <span className="text-xs text-muted-foreground">{label}</span>
</div> </div>
); );
} }
@@ -2,7 +2,9 @@ import { useState } from "react";
import { render, screen, within } from "@testing-library/react"; import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest"; 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 }) { function Harness({ onValueChange }: { onValueChange?: (value: EntityLifecycleTab) => void }) {
const [value, setValue] = useState<EntityLifecycleTab>("active"); const [value, setValue] = useState<EntityLifecycleTab>("active");
@@ -112,4 +114,29 @@ describe("ActiveHistoryTabs (spec 10.4)", () => {
expect(classes).toContain("pointer-fine:h-9"); 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) => /<ActiveHistoryTabs\b/.test(readFileSync(file, "utf8")));
expect(users.length).toBeGreaterThanOrEqual(10);
const wrong = users.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([]);
});
}); });
@@ -19,6 +19,14 @@ export interface ActiveHistoryTabsProps {
children: { active: ReactNode; history: ReactNode }; 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) => const withCount = (label: string, count: number | undefined) =>
count === undefined ? label : `${label} (${count})`; count === undefined ? label : `${label} (${count})`;
@@ -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<typeof mockMedia>;
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(<AppearanceNotice />);
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(<Again />);
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(<AppearanceNotice />);
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(<AppearanceNotice />);
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(<AppearanceNotice />);
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(<AppearanceNotice />);
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(<AppearanceNotice />);
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(<AppearanceNotice />);
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(
<main>
<h1 tabIndex={-1}>Guten Tag</h1>
<AppearanceNotice />
</main>,
);
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<typeof useAppearance> | null = null;
function Menu() {
api = useAppearance();
return null;
}
render(
<>
<Menu />
<AppearanceNotice />
</>,
);
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();
});
});
@@ -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 <body> (WCAG 2.4.3). It goes to
* the page heading (h1 with tabIndex -1) instead.
*/
function keepFocus(): void {
const heading = document.querySelector<HTMLElement>("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 (
<aside
aria-labelledby={textId}
className="flex flex-wrap items-center gap-3 rounded-xl border border-border bg-card p-3"
>
<Sun className="size-5 shrink-0 text-primary-ink" aria-hidden />
<p id={textId} className="min-w-0 flex-1 text-base">
{TEXT}
</p>
<div className="flex flex-wrap gap-2">
<Button
size="touch"
variant="outline"
onClick={() => {
keepFocus();
setContrast("sun");
markSeen();
toast.success("Sonnenmodus an");
}}
>
Ausprobieren
</Button>
<Button
size="touch"
variant="ghost"
onClick={() => {
keepFocus();
markSeen();
}}
>
Schließen
</Button>
</div>
</aside>
);
}
@@ -48,6 +48,14 @@ describe("CheckboxField (spec 10.3)", () => {
expect(onChange.mock.calls).toEqual([[true], [false]]); 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(<CheckboxField label={label} description="Lebenszyklus" checked={false} onCheckedChange={() => {}} />);
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)", () => { it("the row is at least 44 px high (min-h-11) and in a sheet 56 px (min-h-14)", () => {
render( render(
<> <>
@@ -44,6 +44,9 @@ function tileClass(isOn: boolean): string {
: "items-center border border-input bg-card px-3 enabled:hover:bg-muted"; : "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"; const HINT_CLASS = "text-sm font-medium text-muted-foreground";
function joinIds(...ids: (string | false | undefined)[]): string | undefined { function joinIds(...ids: (string | false | undefined)[]): string | undefined {
@@ -130,11 +133,11 @@ export function CheckboxField(props: CheckboxFieldProps) {
) : null; ) : null;
const text = ( const text = (
<span className="flex min-w-0 flex-1 flex-col gap-0.5"> <span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span id={labelId} className={cn(dimmed)}> <span id={labelId} className={cn(WRAP, dimmed)}>
{label} {label}
</span> </span>
{description ? ( {description ? (
<span id={descriptionId} className={cn("text-sm font-normal text-muted-foreground", dimmed)}> <span id={descriptionId} className={cn(WRAP, "text-sm font-normal text-muted-foreground", dimmed)}>
{description} {description}
</span> </span>
) : null} ) : null}
@@ -7,7 +7,7 @@ export function HoneycombPattern({ className, id = "honeycomb" }: HoneycombPatte
return ( return (
<svg <svg
aria-hidden="true" aria-hidden="true"
className={className ?? "pointer-events-none absolute inset-0 h-full w-full text-primary/[0.07]"} className={className ?? "pointer-events-none absolute inset-0 h-full w-full stroke-primary/[0.07]"}
> >
<defs> <defs>
<pattern <pattern
@@ -19,13 +19,12 @@ export function HoneycombPattern({ className, id = "honeycomb" }: HoneycombPatte
> >
<path <path
fill="none" fill="none"
stroke="currentColor"
strokeWidth="1.5" strokeWidth="1.5"
d="M28 0 56 16.2 56 48.5 28 64.7 0 48.5 0 16.2Z M28 64.7 56 80.8 56 97 28 97 0 97 0 80.8Z" d="M28 0 56 16.2 56 48.5 28 64.7 0 48.5 0 16.2Z M28 64.7 56 80.8 56 97 28 97 0 97 0 80.8Z"
/> />
</pattern> </pattern>
</defs> </defs>
<rect width="100%" height="100%" fill={`url(#${id})`} /> <rect width="100%" height="100%" fill={`url(#${id})`} stroke="none" />
</svg> </svg>
); );
} }
@@ -115,7 +115,7 @@ export function NotificationBell() {
> >
<Bell className="size-4.5" /> <Bell className="size-4.5" />
{unreadCount > 0 ? ( {unreadCount > 0 ? (
<span className="absolute -top-0.5 -right-0.5 flex size-4 items-center justify-center rounded-full bg-destructive text-[0.6rem] font-medium text-destructive-foreground"> <span className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-xs leading-none font-medium text-destructive-foreground">
<span aria-hidden="true"> <span aria-hidden="true">
{unreadCount > 9 ? "9+" : unreadCount} {unreadCount > 9 ? "9+" : unreadCount}
</span> </span>
@@ -0,0 +1,49 @@
"use client";
import { EnumSelect } from "@/components/form/enum-select";
import { TabsList, TabsTrigger } from "@/components/ui/tabs";
interface TabPickerProps<T extends string> {
value: T;
onChange: (value: T) => void;
/** Label of each tab, in this order. */
labels: Record<T, string>;
values: readonly NoInfer<T>[];
/** 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 <Tabs>. 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<T extends string>({
value,
onChange,
labels,
values,
listLabel,
selectLabel = "Bereich wählen",
}: TabPickerProps<T>) {
return (
<>
<EnumSelect
ariaLabel={selectLabel}
labels={labels}
values={values}
value={value}
onChange={onChange}
className="sm:hidden"
/>
<TabsList aria-label={listLabel} className="hidden sm:inline-flex">
{values.map((entry) => (
<TabsTrigger key={entry} value={entry}>
{labels[entry]}
</TabsTrigger>
))}
</TabsList>
</>
);
}
@@ -30,7 +30,7 @@ const buttonVariants = cva(
default: 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", "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", 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", 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. // 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", touch: "h-11 gap-2 px-4 text-base [&_svg:not([class*='size-'])]:size-5",
@@ -1,5 +1,7 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest"; 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 userEvent from "@testing-library/user-event";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select"; 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. */ /** Listboxes a screen reader could still reach: not inside a hidden or inert ancestor. */
function reachableListboxes(): Element[] { function reachableListboxes(): Element[] {
return Array.from(document.querySelectorAll('[role="listbox"]')).filter( 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 userEvent.click(await screen.findByRole("option", { name: "Aktiv" }));
await waitFor(() => expect(reachableListboxes()).toEqual([])); 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(<StatusSelect />);
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);
});
}); });
@@ -71,6 +71,20 @@ describe("Tabs (spec 10.4)", () => {
expect(has(screen.getByRole("tablist"), "bg-muted")).toBe(true); 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(<Harness initial="b" />);
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", () => { it("tabs are 44 px for a thumb, compact only with a fine pointer, 16 px text, 4 px apart", () => {
render(<Harness />); render(<Harness />);
const list = screen.getByRole("tablist"); const list = screen.getByRole("tablist");
+2 -1
View File
@@ -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, * --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 * 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. * 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) { function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return ( return (
@@ -50,7 +51,7 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
data-slot="tabs-trigger" data-slot="tabs-trigger"
className={cn( 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", "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", "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", "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
@@ -1,23 +1,8 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { Badge } from "./badge"; import { Badge } from "./badge";
import { Button } from "./button"; 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)", () => { 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", () => { it("the default button uses solid primary with primary-foreground and no gradient", () => {
render(<Button>Speichern</Button>); render(<Button>Speichern</Button>);
@@ -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("Link").className).toMatch(/(^|\s)text-primary-ink(\s|$)/);
expect(screen.getByText("Achtung").className).toMatch(/(^|\s)text-warning-ink(\s|$)/); expect(screen.getByText("Achtung").className).toMatch(/(^|\s)text-warning-ink(\s|$)/);
}); });
// `dark:` classes: the baukasten guard (src/styles/baukasten-guard.spec.ts) bans them in all of src.
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([]);
});
}); });
@@ -1,6 +1,7 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { join, relative } from "node:path"; import { join } from "node:path";
import { describe, expect, it } from "vitest"; 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 * 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 }[]; allow: { file: string; reason: string }[];
/** Directories the rule does not cover at all (path prefix relative to ApiCore-Frontend). */ /** Directories the rule does not cover at all (path prefix relative to ApiCore-Frontend). */
outside?: { prefix: string; reason: string }[]; 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 * `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 * 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`(?<!<CheckboxField\b[^<>]*)(?:${attribute}|${setAttribute})`); return new RegExp(String.raw`(?<!<CheckboxField\b[^<>]*)(?:${attribute}|${setAttribute})`);
} }
/** Spec 17.8: the hive stage has its own motion (Beute §9.8): flights, lifting, idle motion. */ /**
const HIVE_STAGE: NonNullable<GuardRule["outside"]> = [ * 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
prefix: "src/components/hive/", * (the stale-exception test would fail). Should the stage ever need a CSS exception, add an
reason: "The hive stage moves on purpose (Beute §9.4, §9.8): a part lifts on hover and flies into focus.", * `outside` entry with that reason to the one rule it needs.
}, */
{
prefix: "src/app/(app)/colonies/[id]/hive/",
reason: "The full-screen hive editor is the same stage (spec 17.8).",
},
];
/** A duration above --motion-moment (420 ms): Tailwind steps and arbitrary ms values. */ /** 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,})`; 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[] = [ 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", id: "no gradient prop, gradient token or gradient background",
pattern: /gradient=|--gradient-|\[background-image:var\(--gradient/, pattern: new RegExp(
[
String.raw`gradient=`,
String.raw`--gradient-`,
String.raw`(?<![\w-])bg-gradient-`,
String.raw`(?<![\w-])bg-(?:linear|radial|conic)(?!\w)`,
String.raw`(?<![\w-])(?:repeating-)?(?:linear|radial|conic)-gradient\(`,
].join("|"),
),
scope: "src/**/*.{ts,tsx,css}", scope: "src/**/*.{ts,tsx,css}",
allow: [], allow: [],
examples: {
catches: [
'<PageHeader gradient="honey" />',
"<DetailHeader gradient={tone} />",
'<EntityCard title="A1" gradient="sage" />',
'"[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"', '<PageHeader area="apiaries" />'],
},
}, },
// Task A3/A4: white text fails on honey and on the light sets. The hive stage uses // 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. // --holo-label-fg instead, so it needs no exception either.
@@ -65,6 +87,89 @@ const RULES: GuardRule[] = [
pattern: /(?<![\w-])text-white\b/, pattern: /(?<![\w-])text-white\b/,
scope: "src/**/*.{ts,tsx,css}", scope: "src/**/*.{ts,tsx,css}",
allow: [], allow: [],
examples: {
catches: ['"text-white"', '"text-white/80"', '"hover:text-white"'],
ignores: ['"text-holo-label-fg"', '"bg-white"', '"text-foreground"'],
},
},
// Task A10: text-primary is the honey fill; as a text colour it fails on the card (spec 4.4).
// Text uses --primary-ink, text on honey --primary-foreground.
{
id: "no text-primary without -foreground or -ink",
pattern: /(?<![\w-])text-primary(?![\w-])/,
scope: "src/**/*.{ts,tsx,css}",
allow: [],
examples: {
catches: ['"text-primary"', '"hover:text-primary"', '"text-primary/[0.07]"', '"[&_svg]:text-primary"'],
ignores: ['"text-primary-ink"', '"text-primary-foreground"', '"bg-primary"', '"stroke-primary/[0.07]"', "--color-text-primary"],
},
},
// Task A4/A10: a mode is only another token set (spec 4.1); no component paints its own dark
// look. A CSS `.dark` selector and the object key `dark: x` are no Tailwind class.
{
id: "no dark: classes",
pattern: /(?<=^|[\s"'`:])dark:[^\s"'`]/m,
scope: "src/**/*.{ts,tsx}",
allow: [],
examples: {
catches: ['"bg-card dark:bg-input/30"', '"dark:text-foreground"', "`dark:${tone}`", '"hover:dark:bg-muted"'],
ignores: ['{ dark: "#1a120d" }', "{dark:true}", "(dark?C.dark:C.light)", "@custom-variant dark (&:where(.dark, .dark *));"],
},
},
// Task A10: no text below 13 px (spec 4.5: text-xs = 13 px is the smallest step). Arbitrary
// Tailwind sizes, SVG `fontSize` in JSX, inline `style={{ fontSize }}`, CSS `font-size` and
// size constants named *FONT*/*TEXT*/*LABEL*_SIZE. Print sizes of the label designer
// (`fontSize: 10` in pt, plain object outside `style`) are no screen text. SVG text scales with
// its viewBox, so chart labels belong in HTML with text-xs (colony-varroa).
{
id: "no text below 13 px",
pattern: new RegExp(
[
String.raw`(?<![\w-])text-\[(?:${UNDER_13_PX}|${UNDER_13_REM})\]`,
String.raw`\bfontSize=\{?\s*["']?(?:\d|1[0-2])(?:\.\d+)?(?:px)?["']?\s*\}?(?![\d.])`,
String.raw`font-size:\s*(?:${UNDER_13_PX}|${UNDER_13_REM})`,
String.raw`style=\{\{[^}]*\bfontSize:\s*["'\x60]?(?:\d|1[0-2])(?:\.\d+)?(?:px)?["'\x60]?\s*[,}]`,
String.raw`\b(?:[A-Z][A-Z0-9_]*_)?(?:FONT|TEXT|LABEL)[A-Z0-9_]*_SIZE(?:_PX)?\s*=\s*(?:\d|1[0-2])(?:\.\d+)?(?![\d.])`,
].join("|"),
),
scope: "src/**/*.{ts,tsx,css}",
allow: [],
examples: {
catches: [
'"text-[10px]"',
'"text-[11px] uppercase"',
'"text-[12px]"',
'"text-[12.5px]"',
'"text-[0.7rem]"',
'"text-[0.6rem] font-medium"',
'"text-[0.8rem]"',
'"text-[.75rem]"',
'"text-[0.812rem]"',
"<text fontSize={10}>",
'<text fontSize="11">',
"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"',
"<text fontSize={13}>",
"<text fontSize={AXIS_LABEL_SIZE}>",
'"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). // 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/, pattern: /ring-ring\/50/,
scope: "src/**/*.{ts,tsx}", scope: "src/**/*.{ts,tsx}",
allow: [], 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", id: "no outline-none",
pattern: /(?<![\w-])outline-none\b/, pattern: /(?<![\w-])outline-none\b/,
scope: "src/**/*.{ts,tsx}", scope: "src/**/*.{ts,tsx}",
allow: [], allow: [],
examples: {
catches: ['"px-2 focus-visible:outline-none"', '"outline-none px-2"'],
ignores: ['"outline-hidden focus:bg-accent"', '"outline-2"'],
},
}, },
// Task A5: no own focus outline next to the global rule. // Task A5: no own focus outline next to the global rule.
{ {
@@ -85,6 +198,10 @@ const RULES: GuardRule[] = [
// An offset alone is no own ring (calendar days pull the global ring inside the cell). // An offset alone is no own ring (calendar days pull the global ring inside the cell).
pattern: /(?<![\w-])focus(?:-visible)?:outline-(?!hidden|offset)/, pattern: /(?<![\w-])focus(?:-visible)?:outline-(?!hidden|offset)/,
scope: "src/**/*.{ts,tsx}", scope: "src/**/*.{ts,tsx}",
examples: {
catches: ['"p-3 focus:outline-2 focus:outline-ring"', '"focus-visible:outline-2"'],
ignores: ['"outline-hidden focus:bg-accent"', '"focus-visible:outline-offset-[calc(-2px_-_var(--focus-width))]"'],
},
allow: [ allow: [
{ {
file: "src/app/(app)/bottling/[id]/print-labels-card.tsx", file: "src/app/(app)/bottling/[id]/print-labels-card.tsx",
@@ -104,6 +221,10 @@ const RULES: GuardRule[] = [
pattern: /(?<![\w-])sm:(?:[^\s:"'`]+:)*(?:(?:min-)?h-|size-)/, pattern: /(?<![\w-])sm:(?:[^\s:"'`]+:)*(?:(?:min-)?h-|size-)/,
scope: "src/**/*.{ts,tsx}", scope: "src/**/*.{ts,tsx}",
allow: [], allow: [],
examples: {
catches: ['"h-11 sm:h-9"', '"sm:data-[size=default]:h-9"', '"sm:min-h-9"', '"size-11 sm:size-8"'],
ignores: ['"sm:hidden pointer-fine:h-9 sm:max-h-96"', '"h-11 pointer-fine:h-9"'],
},
}, },
// Task A5: inputs stay at 16 px (readable, and iOS does not zoom into them). // Task A5: inputs stay at 16 px (readable, and iOS does not zoom into them).
{ {
@@ -111,6 +232,10 @@ const RULES: GuardRule[] = [
pattern: /(?<![\w-])md:text-sm\b/, pattern: /(?<![\w-])md:text-sm\b/,
scope: "src/**/*.{ts,tsx}", scope: "src/**/*.{ts,tsx}",
allow: [], allow: [],
examples: {
catches: ['"text-base md:text-sm"'],
ignores: ['"md:text-base"', '"text-sm"'],
},
}, },
// Task A6: one radio group for the whole app (spec 10.2, 10.7). // Task A6: one radio group for the whole app (spec 10.2, 10.7).
{ {
@@ -125,6 +250,17 @@ const RULES: GuardRule[] = [
reason: "ChoiceGroup is the radio group of the kit: role radiogroup with one role radio per option.", reason: "ChoiceGroup is the radio group of the kit: role radiogroup with one role radio per option.",
}, },
], ],
examples: {
catches: [
'<input type="radio" name="x" />',
'<div role="radiogroup">',
"<button role={'radio'}>",
'{ role: "radio" }',
"{ role: 'radiogroup' }",
'{ type: "radio" }',
],
ignores: ['<DropdownMenuRadioItem value="a">', '{ role: "radioactive" }', `querySelector('[role="radio"][tabindex="0"]')`],
},
}, },
// Task A7: one checkbox for the whole app (spec 10.3, 10.7). Base UI draws role checkbox and // Task A7: one checkbox for the whole app (spec 10.3, 10.7). Base UI draws role checkbox and
// its hidden input inside ui/checkbox.tsx, so no source file writes them by hand and the list // its hidden input inside ui/checkbox.tsx, so no source file writes them by hand and the list
@@ -135,6 +271,27 @@ const RULES: GuardRule[] = [
pattern: ownRole("role|type", "checkbox"), pattern: ownRole("role|type", "checkbox"),
scope: "src/**/*.{ts,tsx}", scope: "src/**/*.{ts,tsx}",
allow: [], allow: [],
examples: {
catches: [
'<input type="checkbox" checked={on} />',
'<button type="button" role="checkbox" aria-checked={on}>',
"<span role={'checkbox'}>",
'{ type: "checkbox" }',
'<span role={isTask ? "checkbox" : undefined}>',
'<span role={isTask ? undefined : "checkbox"}>',
'element.setAttribute("role", "checkbox")',
"element.setAttribute('type','checkbox')",
'{ role: on ? "checkbox" : "button" }',
// The CheckboxField exemption ends with its own tag: a later element is caught again.
'<CheckboxField label="A" /><span role="checkbox" />',
],
ignores: [
`querySelector('[role="checkbox"]')`,
'<CheckboxField label="Aktiv" role="checkbox" checked={on} />',
'role?: "checkbox" | "switch"',
'role === "checkbox"',
],
},
}, },
// Task A7: a yes/no setting is <CheckboxField role="switch"> on Base UI Switch (ui/switch.tsx). // Task A7: a yes/no setting is <CheckboxField role="switch"> on Base UI Switch (ui/switch.tsx).
{ {
@@ -142,6 +299,16 @@ const RULES: GuardRule[] = [
pattern: ownRole("role", "switch"), pattern: ownRole("role", "switch"),
scope: "src/**/*.{ts,tsx}", scope: "src/**/*.{ts,tsx}",
allow: [], allow: [],
examples: {
catches: [
'<input type="checkbox" role="switch" />',
'{ role: "switch" }',
'<button role={on ? "switch" : "button"}>',
'node.setAttribute("role", "switch")',
'<CheckboxField label="A" />\n<input role="switch" />',
],
ignores: ['<CheckboxField\n role="switch"\n label="Mit Details"\n/>', 'role === "switch"'],
},
}, },
// Task A6: a toggle button sets aria-pressed through Button `pressed` (spec 10.2, 10.7). // Task A6: a toggle button sets aria-pressed through Button `pressed` (spec 10.2, 10.7).
{ {
@@ -160,6 +327,10 @@ const RULES: GuardRule[] = [
"The month grid is a grid of day buttons; the chosen day is pressed. It keeps the global focus and the selection ring (spec 10.2).", "The month grid is a grid of day buttons; the chosen day is pressed. It keeps the global focus and the selection ring (spec 10.2).",
}, },
], ],
examples: {
catches: ["<button aria-pressed={on}>", '{ "aria-pressed": on }', "{ 'aria-pressed': on }"],
ignores: ['"aria-pressed:bg-holo-edge/25"', "<Button pressed={on}>"],
},
}, },
// Task A8: one tabs kit for the whole app (spec 10.4, 10.7). Base UI draws role tablist, tab // Task A8: one tabs kit for the whole app (spec 10.4, 10.7). Base UI draws role tablist, tab
// and tabpanel inside ui/tabs.tsx, so no source file writes them by hand and the list of // and tabpanel inside ui/tabs.tsx, so no source file writes them by hand and the list of
@@ -169,6 +340,17 @@ const RULES: GuardRule[] = [
pattern: ownRole("role", "tab(?:list|panel)?"), pattern: ownRole("role", "tab(?:list|panel)?"),
scope: "src/**/*.{ts,tsx}", scope: "src/**/*.{ts,tsx}",
allow: [], allow: [],
examples: {
catches: [
'<div role="tablist" className="flex">',
'<button type="button" role="tab" aria-selected={on}>',
"<section role={'tabpanel'}>",
'{ role: "tab" }',
'<div role={wide ? "tablist" : undefined}>',
'node.setAttribute("role", "tab")',
],
ignores: ['<Table role="table">', `querySelector('[role="tab"]')`, 'getAllByRole("tab")'],
},
}, },
// Task A11: motion language (spec 17.2, 17.6, 17.7). motion-safe: only asks the system and // Task A11: motion language (spec 17.2, 17.6, 17.7). motion-safe: only asks the system and
// skips the level chosen in the app, so movement sits behind motion-full:. // skips the level chosen in the app, so movement sits behind motion-full:.
@@ -177,41 +359,72 @@ const RULES: GuardRule[] = [
pattern: /(?<![\w-])motion-safe:(?:[^\s"'`:]+:)*-?(?:translate|scale|rotate|slide-|zoom-|animate-|transition-transform)/, pattern: /(?<![\w-])motion-safe:(?:[^\s"'`:]+:)*-?(?:translate|scale|rotate|slide-|zoom-|animate-|transition-transform)/,
scope: "src/**/*.{ts,tsx}", scope: "src/**/*.{ts,tsx}",
allow: [], allow: [],
examples: {
catches: [
'"motion-safe:transition-transform"',
'"motion-safe:data-open:zoom-in-95"',
'"motion-safe:-translate-y-0.5"',
'"motion-safe:animate-pulse"',
],
ignores: ['"motion-full:animate-pulse"', '"motion-safe:transition-colors"'],
},
}, },
{ {
id: "pulse and spin only behind motion-full:", id: "pulse and spin only behind motion-full:",
pattern: /(?<!motion-full:)(?<![\w-])animate-(?:pulse|spin)\b/, pattern: /(?<!motion-full:)(?<![\w-])animate-(?:pulse|spin)\b/,
scope: "src/**/*.{ts,tsx}", scope: "src/**/*.{ts,tsx}",
allow: [], allow: [],
examples: {
catches: ['"size-4 animate-spin"', '"motion-safe:animate-pulse"'],
ignores: ['"motion-full:animate-spin"', '"animate-page-in"'],
},
}, },
{ {
id: "no transition-all", id: "no transition-all",
pattern: /(?<![\w-])transition-all\b|transition:\s*all\b/, pattern: /(?<![\w-])transition-all\b|transition:\s*all\b/,
scope: "src/**/*.{ts,tsx,css}", scope: "src/**/*.{ts,tsx,css}",
allow: [], allow: [],
examples: {
catches: ['"transition-all duration-150"', "transition: all 200ms;"],
ignores: ['"transition-colors"', "transition: opacity var(--motion-fade);"],
},
}, },
{ {
id: "no transition-[width|height] and no duration above 420 ms outside the hive stage", id: "no transition-[width|height] and no duration above 420 ms",
pattern: new RegExp( pattern: new RegExp(
String.raw`transition-\[[^\]]*(?:width|height)|(?<![\w-])duration-(?:${OVER_MOMENT_MS}(?!\d)|\[${OVER_MOMENT_MS}ms\]|\[[1-9][\d.]*s\])`, String.raw`transition-\[[^\]]*(?:width|height)|(?<![\w-])duration-(?:${OVER_MOMENT_MS}(?!\d)|\[${OVER_MOMENT_MS}ms\]|\[[1-9][\d.]*s\])`,
), ),
scope: "src/**/*.{ts,tsx}", scope: "src/**/*.{ts,tsx}",
allow: [], allow: [],
outside: HIVE_STAGE, examples: {
catches: [
'"motion-full:transition-[width] motion-full:duration-700"',
'"duration-500"',
'"duration-[600ms]"',
'"duration-[1s]"',
],
ignores: ['"duration-420 duration-300 duration-[0.2s] duration-(--motion-enter)"'],
},
}, },
{ {
id: "hover translate or scale only on the hive stage", id: "no hover translate or scale",
pattern: /(?<![\w-])(?:group-|peer-)?hover:(?:[^\s"'`:]+:)*-?(?:translate|scale)-/, pattern: /(?<![\w-])(?:group-|peer-)?hover:(?:[^\s"'`:]+:)*-?(?:translate|scale)-/,
scope: "src/**/*.{ts,tsx}", scope: "src/**/*.{ts,tsx}",
allow: [], allow: [],
outside: HIVE_STAGE, examples: {
catches: ['"motion-full:group-hover:scale-110"', '"motion-safe:hover:-translate-y-0.5"'],
ignores: ['"hover:bg-muted"', '"motion-full:active:scale-[0.97]"'],
},
}, },
{ {
id: "motion transitions come from the tokens (transitionFor), not inline", id: "motion transitions come from the tokens (transitionFor), not inline",
pattern: /\btransition=\{\{/, pattern: /\btransition=\{\{/,
scope: "src/**/*.tsx", scope: "src/**/*.tsx",
allow: [], allow: [],
outside: HIVE_STAGE, examples: {
catches: ["<m.li transition={{ duration: 0.3 }} />"],
ignores: ['<m.li transition={transitionFor(level, "exit")} />'],
},
}, },
]; ];
@@ -221,19 +434,6 @@ const SCOPE_FILE: Record<GuardScope, RegExp> = {
"src/**/*.{ts,tsx,css}": /\.(tsx?|css)$/, "src/**/*.{ts,tsx,css}": /\.(tsx?|css)$/,
}; };
function sourceFiles(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const path = join(dir, name);
if (statSync(path).isDirectory()) return sourceFiles(path);
return /\.spec\./.test(name) ? [] : [path];
});
}
const FILES = sourceFiles(SRC).map((path) => ({
file: relative(FRONTEND, path).replaceAll("\\", "/"),
text: readFileSync(path, "utf8"),
}));
function offenders(rule: GuardRule): string[] { function offenders(rule: GuardRule): string[] {
const isOutside = (file: string) => (rule.outside ?? []).some(({ prefix }) => file.startsWith(prefix)); const isOutside = (file: string) => (rule.outside ?? []).some(({ prefix }) => file.startsWith(prefix));
return FILES.filter( return FILES.filter(
@@ -253,21 +453,59 @@ describe("baukasten guard (spec 10.7)", () => {
expect(FILES.filter(({ file }) => file.endsWith(".tsx")).length).toBeGreaterThan(200); expect(FILES.filter(({ file }) => file.endsWith(".tsx")).length).toBeGreaterThan(200);
}); });
it("baukasten guard passes with the documented exceptions only", () => {
const failing = RULES.flatMap((rule) => violations(rule).map((file) => `${rule.id}: ${file}`));
expect(failing).toEqual([]);
});
it.each(RULES.map((rule) => [rule.id, rule] as const))("%s", (_id, rule) => { it.each(RULES.map((rule) => [rule.id, rule] as const))("%s", (_id, rule) => {
expect(violations(rule)).toEqual([]); expect(violations(rule)).toEqual([]);
}); });
it("every exception names an existing file that still needs it, with a reason", () => { it("every guard exception has a reason", () => {
for (const rule of RULES) {
for (const entry of [...rule.allow, ...(rule.outside ?? [])]) {
const name = "file" in entry ? entry.file : entry.prefix;
expect(entry.reason.trim().length, `${rule.id}: ${name}`).toBeGreaterThan(20);
}
}
});
it("every exception names an existing file that still needs it", () => {
for (const rule of RULES) { for (const rule of RULES) {
const hits = new Set(offenders(rule)); const hits = new Set(offenders(rule));
for (const entry of rule.allow) { for (const entry of rule.allow) {
expect(entry.reason.trim(), `${rule.id}: ${entry.file}`).not.toBe("");
expect(existsSync(join(FRONTEND, entry.file)), `${rule.id}: ${entry.file} exists`).toBe(true); expect(existsSync(join(FRONTEND, entry.file)), `${rule.id}: ${entry.file} exists`).toBe(true);
expect(hits.has(entry.file), `${rule.id}: ${entry.file} still needs the exception`).toBe(true); expect(hits.has(entry.file), `${rule.id}: ${entry.file} still needs the exception`).toBe(true);
} }
} }
}); });
it("every directory a rule leaves out exists and still holds a hit (else the exception is stale)", () => {
for (const rule of RULES) {
for (const { prefix } of rule.outside ?? []) {
expect(existsSync(join(FRONTEND, prefix)), `${rule.id}: ${prefix} exists`).toBe(true);
const hits = FILES.filter(
({ file, text }) => file.startsWith(prefix) && SCOPE_FILE[rule.scope].test(file) && rule.pattern.test(text),
);
expect(hits.length, `${rule.id}: ${prefix} still needs to be left out`).toBeGreaterThan(0);
}
}
});
it("every rule has positive and negative self-tests", () => {
for (const rule of RULES) {
expect(rule.examples.catches.length, `${rule.id}: catches`).toBeGreaterThan(0);
expect(rule.examples.ignores.length, `${rule.id}: ignores`).toBeGreaterThan(0);
}
});
it.each(RULES.map((rule) => [rule.id, rule] as const))("self-test: %s", (_id, rule) => {
const missed = rule.examples.catches.filter((snippet) => !rule.pattern.test(snippet));
const wronglyCaught = rule.examples.ignores.filter((snippet) => rule.pattern.test(snippet));
expect({ missed, wronglyCaught }).toEqual({ missed: [], wronglyCaught: [] });
});
it("ToggleChip and SwitchRow are gone (CheckboxField tile and switch replace them)", () => { it("ToggleChip and SwitchRow are gone (CheckboxField tile and switch replace them)", () => {
for (const name of ["toggle-chip", "switch-row"]) { for (const name of ["toggle-chip", "switch-row"]) {
expect(existsSync(join(SRC, "components", "form", `${name}.tsx`)), name).toBe(false); expect(existsSync(join(SRC, "components", "form", `${name}.tsx`)), name).toBe(false);
@@ -317,148 +555,6 @@ describe("baukasten guard (spec 10.7)", () => {
expect(GLOBALS).not.toMatch(/outline:\s*none/); expect(GLOBALS).not.toMatch(/outline:\s*none/);
expect(GLOBALS).not.toMatch(/outline-ring\/50/); expect(GLOBALS).not.toMatch(/outline-ring\/50/);
}); });
it("the patterns catch what they are meant to catch", () => {
const rule = (id: string) => RULES.find((entry) => entry.id === id) as GuardRule;
expect(rule("no outline-none").pattern.test('"px-2 focus-visible:outline-none"')).toBe(true);
expect(rule("no outline-none").pattern.test('"outline-none px-2"')).toBe(true);
const sizes = rule("no sm:h-, sm:min-h- or sm:size-").pattern;
expect(sizes.test('"h-11 sm:h-9"')).toBe(true);
expect(sizes.test('"sm:data-[size=default]:h-9"')).toBe(true);
expect(sizes.test('"sm:min-h-9"')).toBe(true);
expect(sizes.test('"size-11 sm:size-8"')).toBe(true);
expect(sizes.test('"sm:hidden pointer-fine:h-9 sm:max-h-96"')).toBe(false);
const ownOutline = rule("no own focus outline").pattern;
expect(ownOutline.test('"p-3 focus:outline-2 focus:outline-ring"')).toBe(true);
expect(ownOutline.test('"focus-visible:outline-2"')).toBe(true);
expect(ownOutline.test('"outline-hidden focus:bg-accent"')).toBe(false);
const radio = rule("no role radio, radiogroup or type radio outside choice-group.tsx").pattern;
expect(radio.test('<input type="radio" name="x" />')).toBe(true);
expect(radio.test('<div role="radiogroup">')).toBe(true);
expect(radio.test("<button role={'radio'}>")).toBe(true);
expect(radio.test('<DropdownMenuRadioItem value="a">')).toBe(false);
const pressed = rule("aria-pressed only through Button pressed").pattern;
expect(pressed.test("<button aria-pressed={on}>")).toBe(true);
expect(pressed.test('"aria-pressed:bg-holo-edge/25"')).toBe(false);
expect(pressed.test('{ "aria-pressed": on }')).toBe(true);
expect(pressed.test("{ 'aria-pressed': on }")).toBe(true);
expect(radio.test('{ role: "radio" }')).toBe(true);
expect(radio.test("{ role: 'radiogroup' }")).toBe(true);
expect(radio.test('{ type: "radio" }')).toBe(true);
expect(radio.test('{ role: "radioactive" }')).toBe(false);
expect(radio.test(`querySelector('[role="radio"][tabindex="0"]')`)).toBe(false);
const checkbox = rule("no role checkbox or type checkbox outside ui/checkbox.tsx").pattern;
expect(checkbox.test('<input type="checkbox" checked={on} />')).toBe(true);
expect(checkbox.test('<button type="button" role="checkbox" aria-checked={on}>')).toBe(true);
expect(checkbox.test("<span role={'checkbox'}>")).toBe(true);
expect(checkbox.test('{ type: "checkbox" }')).toBe(true);
expect(checkbox.test(`querySelector('[role="checkbox"]')`)).toBe(false);
expect(checkbox.test('<CheckboxField label="Aktiv" role="checkbox" checked={on} />')).toBe(false);
expect(checkbox.test('role?: "checkbox" | "switch"')).toBe(false);
const toggle = rule("no role switch outside ui/checkbox.tsx and ui/switch.tsx").pattern;
expect(toggle.test('<input type="checkbox" role="switch" />')).toBe(true);
expect(toggle.test('{ role: "switch" }')).toBe(true);
expect(toggle.test('<CheckboxField\n role="switch"\n label="Mit Details"\n/>')).toBe(false);
expect(toggle.test('role === "switch"')).toBe(false);
expect(checkbox.test('<span role={isTask ? "checkbox" : undefined}>')).toBe(true);
expect(checkbox.test('<span role={isTask ? undefined : "checkbox"}>')).toBe(true);
expect(checkbox.test('element.setAttribute("role", "checkbox")')).toBe(true);
expect(checkbox.test("element.setAttribute('type','checkbox')")).toBe(true);
expect(checkbox.test('{ role: on ? "checkbox" : "button" }')).toBe(true);
expect(checkbox.test('role === "checkbox"')).toBe(false);
expect(toggle.test('<button role={on ? "switch" : "button"}>')).toBe(true);
expect(toggle.test('node.setAttribute("role", "switch")')).toBe(true);
// The CheckboxField exemption ends with its own tag: a later element is caught again.
expect(toggle.test('<CheckboxField label="A" />\n<input role="switch" />')).toBe(true);
expect(checkbox.test('<CheckboxField label="A" /><span role="checkbox" />')).toBe(true);
const tabs = rule("no role tablist, tab or tabpanel outside ui/tabs.tsx").pattern;
expect(tabs.test('<div role="tablist" className="flex">')).toBe(true);
expect(tabs.test('<button type="button" role="tab" aria-selected={on}>')).toBe(true);
expect(tabs.test("<section role={'tabpanel'}>")).toBe(true);
expect(tabs.test('{ role: "tab" }')).toBe(true);
expect(tabs.test('<div role={wide ? "tablist" : undefined}>')).toBe(true);
expect(tabs.test('node.setAttribute("role", "tab")')).toBe(true);
expect(tabs.test('<Table role="table">')).toBe(false);
expect(tabs.test(`querySelector('[role="tab"]')`)).toBe(false);
expect(tabs.test('getAllByRole("tab")')).toBe(false);
expect(rule("no text-white").pattern.test('"text-white/80"')).toBe(true);
expect(rule("no gradient prop or gradient token").pattern.test("<PageHeader gradient=\"honey\" />")).toBe(true);
});
});
/**
* Touch targets (spec 4.5, Global Constraints): icon buttons get a 44 px hit area under a coarse
* pointer. Two icon buttons side by side need a gap of at least 44 px minus their size there,
* or the hit areas overlap and a tap on the edge of one triggers the other. The scan is a
* heuristic over the JSX source: `<Button>` siblings with only whitespace between them, inside a
* `<div>`/`<span>` that opens right before the first of them.
*/
const ICON_SIZE_PX: Record<string, number> = { "icon-xs": 24, "icon-sm": 28, icon: 32, "icon-lg": 36 };
const HIT_AREA_PX = 44;
interface ButtonTag {
start: number;
end: number;
size: number;
}
/** `<Button …>…</Button>` and `<Button … />` of a file with their size (44 px unless an icon size). */
function buttonsOf(text: string): ButtonTag[] {
return [...text.matchAll(/<Button\b/g)].map((match) => {
const start = match.index;
let depth = 0;
let i = start;
for (; i < text.length; i += 1) {
if (text[i] === "{") depth += 1;
else if (text[i] === "}") depth -= 1;
else if (text[i] === ">" && depth === 0) break;
}
const tag = text.slice(start, i + 1);
const end = tag.endsWith("/>") ? i + 1 : text.indexOf("</Button>", i) + "</Button>".length;
const size = /\bsize="(icon(?:-xs|-sm|-lg)?)"/.exec(tag)?.[1];
const isLarge = /\bsize-1[1-9]\b/.test(tag);
return { start, end, size: size && !isLarge ? ICON_SIZE_PX[size] : HIT_AREA_PX };
});
}
/** Gap under a coarse pointer of the row that opens right before `index`; null without one. */
function rowGapPx(text: string, index: number): number | null {
const open = /<(?:div|span)\b[^>]*className="([^"]*)"[^>]*>\s*$/.exec(text.slice(Math.max(0, index - 400), index));
if (!open) return null;
const coarse = /(?:^|\s)pointer-coarse:gap-(?:x-)?(\d+(?:\.5)?)(?=\s|$)/.exec(open[1]);
const plain = /(?:^|\s)gap-(?:x-)?(\d+(?:\.5)?)(?=\s|$)/.exec(open[1]);
const step = coarse?.[1] ?? plain?.[1];
return step === undefined ? 0 : Number(step) * 4;
}
const isBlank = (text: string) => /^\s*$/.test(text);
function adjacentIconPairs(): { where: string; gap: number | null; need: number }[] {
return FILES.filter(({ file }) => file.endsWith(".tsx")).flatMap(({ file, text }) => {
const buttons = buttonsOf(text);
return buttons.slice(1).flatMap((button, index) => {
const previous = buttons[index];
const smallest = Math.min(previous.size, button.size);
if (!isBlank(text.slice(previous.end, button.start)) || smallest >= HIT_AREA_PX) return [];
let head = index;
while (head > 0 && isBlank(text.slice(buttons[head - 1].end, buttons[head].start))) head -= 1;
const line = text.slice(0, previous.start).split("\n").length;
return [{ where: `${file}:${line}`, gap: rowGapPx(text, buttons[head].start), need: HIT_AREA_PX - smallest }];
});
});
}
describe("icon buttons side by side (spec 4.5)", () => {
it("finds the icon button rows (the scan is not empty)", () => {
expect(adjacentIconPairs().length).toBeGreaterThanOrEqual(2);
});
it("their 44 px hit areas never overlap under a coarse pointer", () => {
const overlapping = adjacentIconPairs()
.filter(({ gap, need }) => gap === null || gap < need)
.map(({ where, gap, need }) => `${where}: gap ${gap ?? "?"} px < ${need} px`);
expect(overlapping).toEqual([]);
});
}); });
/** /**
@@ -632,33 +728,8 @@ describe("motion language (spec 17.7)", () => {
expect(source("src/app/(app)/sidebar.tsx")).not.toMatch(/scale-110/); expect(source("src/app/(app)/sidebar.tsx")).not.toMatch(/scale-110/);
}); });
it("the motion patterns catch what they are meant to catch", () => { // The rule patterns test themselves (rule.examples); here only the scan helpers.
const rule = (id: string) => RULES.find((entry) => entry.id.startsWith(id)) as GuardRule; it("the motion scan helpers read imports and animated properties", () => {
const safe = rule("no motion-safe:").pattern;
expect(safe.test('"motion-safe:transition-transform"')).toBe(true);
expect(safe.test('"motion-safe:data-open:zoom-in-95"')).toBe(true);
expect(safe.test('"motion-safe:-translate-y-0.5"')).toBe(true);
expect(safe.test('"motion-safe:animate-pulse"')).toBe(true);
expect(safe.test('"motion-full:animate-pulse"')).toBe(false);
const pulse = rule("pulse and spin").pattern;
expect(pulse.test('"size-4 animate-spin"')).toBe(true);
expect(pulse.test('"motion-safe:animate-pulse"')).toBe(true);
expect(pulse.test('"motion-full:animate-spin"')).toBe(false);
const all = rule("no transition-all").pattern;
expect(all.test('"transition-all duration-150"')).toBe(true);
expect(all.test("transition: all 200ms;")).toBe(true);
expect(all.test('"transition-colors"')).toBe(false);
const long = rule("no transition-[width|height]").pattern;
expect(long.test('"motion-full:transition-[width] motion-full:duration-700"')).toBe(true);
expect(long.test('"duration-500"')).toBe(true);
expect(long.test('"duration-[600ms]"')).toBe(true);
expect(long.test('"duration-[1s]"')).toBe(true);
expect(long.test('"duration-420 duration-300 duration-[0.2s] duration-(--motion-enter)"')).toBe(false);
const hover = rule("hover translate or scale").pattern;
expect(hover.test('"motion-full:group-hover:scale-110"')).toBe(true);
expect(hover.test('"motion-safe:hover:-translate-y-0.5"')).toBe(true);
expect(hover.test('"hover:bg-muted"')).toBe(false);
expect(rule("motion transitions come from").pattern.test("<m.li transition={{ duration: 0.3 }} />")).toBe(true);
expect(namedImports('import { AnimatePresence, motion } from "motion/react";', "motion/react")).toEqual([ expect(namedImports('import { AnimatePresence, motion } from "motion/react";', "motion/react")).toEqual([
"AnimatePresence", "AnimatePresence",
"motion", "motion",
@@ -383,6 +383,9 @@ describe.each(SETS)("focus ring around a selected honey option in %s (task A5)",
}); });
}); });
/** ui/tabs.tsx: hover surface of an inactive tab, `bg-card/60` over the --muted bar (task A10). */
const TAB_HOVER_ALPHA = 0.6;
describe.each(SETS)("tabs and menu items in %s (task A8)", (set) => { describe.each(SETS)("tabs and menu items in %s (task A8)", (set) => {
it("an inactive tab (--muted-foreground on the --muted bar) reaches 4.5:1", () => { it("an inactive tab (--muted-foreground on the --muted bar) reaches 4.5:1", () => {
expect(contrastOf(rgb(set, "muted-foreground"), rgb(set, "muted"))).toBeGreaterThanOrEqual(4.5); expect(contrastOf(rgb(set, "muted-foreground"), rgb(set, "muted"))).toBeGreaterThanOrEqual(4.5);
@@ -396,6 +399,12 @@ describe.each(SETS)("tabs and menu items in %s (task A8)", (set) => {
expect(contrastOf(rgb(set, "primary-ink"), rgb(set, "card"))).toBeGreaterThanOrEqual(3); expect(contrastOf(rgb(set, "primary-ink"), rgb(set, "card"))).toBeGreaterThanOrEqual(3);
}); });
it("a hovered inactive tab (bg-card/60 over --muted): --foreground text ≥ 4.5:1, --input bar ≥ 3:1", () => {
const hover = blend(rgb(set, "card"), TAB_HOVER_ALPHA, rgb(set, "muted"));
expect(contrastOf(rgb(set, "foreground"), hover)).toBeGreaterThanOrEqual(4.5);
expect(contrastOf(rgb(set, "input"), hover)).toBeGreaterThanOrEqual(3);
});
it.each(["popover", "accent"])( it.each(["popover", "accent"])(
"the inset ring of a highlighted menu item (--ring) reaches 3:1 on the %s", "the inset ring of a highlighted menu item (--ring) reaches 3:1 on the %s",
(surface) => { (surface) => {
@@ -0,0 +1,25 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
/** Source scan shared by the guard specs (spec 10.7). Spec files and spec helpers are left out. */
export const FRONTEND = join(__dirname, "..", "..");
export const SRC = join(FRONTEND, "src");
export interface SourceFile {
/** Path relative to ApiCore-Frontend, with forward slashes. */
file: string;
text: string;
}
function sourceFiles(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const path = join(dir, name);
if (statSync(path).isDirectory()) return sourceFiles(path);
return /\.spec[.-]/.test(name) ? [] : [path];
});
}
export const FILES: readonly SourceFile[] = sourceFiles(SRC).map((path) => ({
file: relative(FRONTEND, path).replaceAll("\\", "/"),
text: readFileSync(path, "utf8"),
}));
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { FILES } from "./guard-files.spec-helper";
/**
* Touch targets (spec 4.5, Global Constraints): icon buttons get a 44 px hit area under a coarse
* pointer. Two icon buttons side by side need a gap of at least 44 px minus their size there,
* or the hit areas overlap and a tap on the edge of one triggers the other. The scan is a
* heuristic over the JSX source: `<Button>` siblings with only whitespace between them, inside a
* `<div>`/`<span>` that opens right before the first of them.
*/
const ICON_SIZE_PX: Record<string, number> = { "icon-xs": 24, "icon-sm": 28, icon: 32, "icon-lg": 36 };
const HIT_AREA_PX = 44;
interface ButtonTag {
start: number;
end: number;
size: number;
}
/** `<Button …>…</Button>` and `<Button … />` of a file with their size (44 px unless an icon size). */
function buttonsOf(text: string): ButtonTag[] {
return [...text.matchAll(/<Button\b/g)].map((match) => {
const start = match.index;
let depth = 0;
let i = start;
for (; i < text.length; i += 1) {
if (text[i] === "{") depth += 1;
else if (text[i] === "}") depth -= 1;
else if (text[i] === ">" && depth === 0) break;
}
const tag = text.slice(start, i + 1);
const end = tag.endsWith("/>") ? i + 1 : text.indexOf("</Button>", i) + "</Button>".length;
const size = /\bsize="(icon(?:-xs|-sm|-lg)?)"/.exec(tag)?.[1];
const isLarge = /\bsize-1[1-9]\b/.test(tag);
return { start, end, size: size && !isLarge ? ICON_SIZE_PX[size] : HIT_AREA_PX };
});
}
/** Gap under a coarse pointer of the row that opens right before `index`; null without one. */
function rowGapPx(text: string, index: number): number | null {
const open = /<(?:div|span)\b[^>]*className="([^"]*)"[^>]*>\s*$/.exec(text.slice(Math.max(0, index - 400), index));
if (!open) return null;
const coarse = /(?:^|\s)pointer-coarse:gap-(?:x-)?(\d+(?:\.5)?)(?=\s|$)/.exec(open[1]);
const plain = /(?:^|\s)gap-(?:x-)?(\d+(?:\.5)?)(?=\s|$)/.exec(open[1]);
const step = coarse?.[1] ?? plain?.[1];
return step === undefined ? 0 : Number(step) * 4;
}
const isBlank = (text: string) => /^\s*$/.test(text);
function adjacentIconPairs(): { where: string; gap: number | null; need: number }[] {
return FILES.filter(({ file }) => file.endsWith(".tsx")).flatMap(({ file, text }) => {
const buttons = buttonsOf(text);
return buttons.slice(1).flatMap((button, index) => {
const previous = buttons[index];
const smallest = Math.min(previous.size, button.size);
if (!isBlank(text.slice(previous.end, button.start)) || smallest >= HIT_AREA_PX) return [];
let head = index;
while (head > 0 && isBlank(text.slice(buttons[head - 1].end, buttons[head].start))) head -= 1;
const line = text.slice(0, previous.start).split("\n").length;
return [{ where: `${file}:${line}`, gap: rowGapPx(text, buttons[head].start), need: HIT_AREA_PX - smallest }];
});
});
}
describe("icon buttons side by side (spec 4.5)", () => {
it("finds the icon button rows (the scan is not empty)", () => {
expect(adjacentIconPairs().length).toBeGreaterThanOrEqual(2);
});
it("their 44 px hit areas never overlap under a coarse pointer", () => {
const overlapping = adjacentIconPairs()
.filter(({ gap, need }) => gap === null || gap < need)
.map(({ where, gap, need }) => `${where}: gap ${gap ?? "?"} px < ${need} px`);
expect(overlapping).toEqual([]);
});
});