feat(t5): Wächter vollständig, Hinweis Sonnenmodus, Nacharbeiten (T5a A10)
- 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:
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(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 { EntityCard } from "@/components/entity-card";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -84,8 +84,8 @@ export default function ApiariesPage() {
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={activeApiaries.length}
|
||||
historyCount={historyApiaries.length}
|
||||
activeCount={countOf(apiariesQuery.data, activeApiaries)}
|
||||
historyCount={countOf(apiariesQuery.data, historyApiaries)}
|
||||
toolbar={
|
||||
<Input
|
||||
className="w-48"
|
||||
|
||||
@@ -208,7 +208,8 @@ describe("ColonyVarroaCard trend", () => {
|
||||
it("labels the date axis and explains the lines in a legend", async () => {
|
||||
renderCard();
|
||||
const chart = await screen.findByRole("img");
|
||||
const axis = within(chart as unknown as HTMLElement);
|
||||
// The dates sit in an HTML row under the chart (the img names them in its description).
|
||||
const axis = within(chart.parentElement?.parentElement as HTMLElement);
|
||||
expect(axis.getByText("01.06.2026")).toBeInTheDocument();
|
||||
expect(axis.getByText("15.06.2026")).toBeInTheDocument();
|
||||
await screen.findByText("Rote Grenze");
|
||||
@@ -217,4 +218,17 @@ describe("ColonyVarroaCard trend", () => {
|
||||
expect(within(legend).getByText("Rote Grenze")).toBeInTheDocument();
|
||||
expect(within(legend).getByText("Gelbe Grenze")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("chart labels are HTML text at a fixed 13 px, not SVG text that shrinks with a narrow chart", async () => {
|
||||
const { container } = renderCard();
|
||||
await screen.findByText("Grenze 10/Tag");
|
||||
// SVG font sizes are user units: in a 300-unit viewBox on a 360 px phone 13 units are ~12 px.
|
||||
expect(container.querySelectorAll("svg text")).toHaveLength(0);
|
||||
for (const text of ["Grenze 10/Tag", "01.06.2026", "15.06.2026"]) {
|
||||
const label = screen.getByText(text);
|
||||
expect(label.closest("svg"), text).toBeNull();
|
||||
expect(label.className.split(/\s+/), text).toContain("text-xs");
|
||||
expect(label.closest("[aria-hidden='true']"), text).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -20,11 +20,12 @@ import { VarroaEditDialog } from "./varroa-edit-dialog";
|
||||
const CHART_WIDTH = 300;
|
||||
const CHART_HEIGHT = 136;
|
||||
const PAD_X = 12;
|
||||
const PAD_TOP = 12;
|
||||
const PAD_BOTTOM = 28;
|
||||
// Room for the HTML limit label (~22 px) above the highest point.
|
||||
const PAD_TOP = 24;
|
||||
const PAD_BOTTOM = 12;
|
||||
const MARKER_RADIUS = 4;
|
||||
const LABEL_GAP = 4;
|
||||
const AXIS_LABEL_SIZE = 10;
|
||||
/** Horizontal inset of the HTML labels, matching PAD_X of the viewBox. */
|
||||
const PAD_X_PERCENT = `${(PAD_X / CHART_WIDTH) * 100}%`;
|
||||
|
||||
const MAX_BAND_MONTHS = 24;
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||
@@ -203,10 +204,11 @@ function VarroaTrendChart({
|
||||
const monthly = monthLimitsText(limits, range);
|
||||
|
||||
const axisY = CHART_HEIGHT - PAD_BOTTOM + MARKER_RADIUS * 2;
|
||||
const labelY = CHART_HEIGHT - LABEL_GAP;
|
||||
|
||||
return (
|
||||
<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
|
||||
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
|
||||
className="w-full text-primary-ink"
|
||||
@@ -230,17 +232,6 @@ function VarroaTrendChart({
|
||||
) : null}
|
||||
</g>
|
||||
) : 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
|
||||
x1={PAD_X}
|
||||
x2={CHART_WIDTH - PAD_X}
|
||||
@@ -250,18 +241,6 @@ function VarroaTrendChart({
|
||||
strokeWidth={0.5}
|
||||
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} />
|
||||
{points.map((p) => (
|
||||
<circle
|
||||
@@ -275,6 +254,20 @@ function VarroaTrendChart({
|
||||
/>
|
||||
))}
|
||||
</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 !== ""} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ import { PageHeader } from "@/components/page-header";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { EntityCard } from "@/components/entity-card";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -108,8 +108,8 @@ export default function ColoniesPage() {
|
||||
setTab(next);
|
||||
setStatus("");
|
||||
}}
|
||||
activeCount={activeColonies.length}
|
||||
historyCount={historyColonies.length}
|
||||
activeCount={countOf(coloniesQuery.data, activeColonies)}
|
||||
historyCount={countOf(coloniesQuery.data, historyColonies)}
|
||||
toolbar={
|
||||
<EnumSelect
|
||||
ariaLabel="Nach Status filtern"
|
||||
|
||||
@@ -288,4 +288,34 @@ describe("DashboardPage – Heute", () => {
|
||||
expect(header.getAttribute("style")).toBeNull();
|
||||
expect(container.innerHTML).not.toMatch(/text-white|--gradient/);
|
||||
});
|
||||
|
||||
it("the dashboard shows no white text on honey", async () => {
|
||||
vi.mocked(fetchAutopilotToday).mockResolvedValue(
|
||||
todayFixture({
|
||||
nextStep: { ...stepFixture({ title: "Durchsicht A1" }), colonyId: "c1", colonyCode: "A1" },
|
||||
}),
|
||||
);
|
||||
const { container } = renderPage();
|
||||
|
||||
expect(await screen.findByRole("link", { name: "Erfassen" })).toHaveClass("bg-primary");
|
||||
const honey = Array.from(container.querySelectorAll<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 { tasksForToday } from "@/lib/tasks/today";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AppearanceNotice } from "@/components/appearance-notice";
|
||||
import { StandNote } from "@/components/offline/stand-note";
|
||||
import { ComplianceLine } from "@/components/today/compliance-line";
|
||||
import { TodayPanel } from "@/components/today/today-panel";
|
||||
@@ -157,7 +158,8 @@ export default function DashboardPage() {
|
||||
return (
|
||||
<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">
|
||||
<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)}
|
||||
{firstName ? `, ${firstName}` : ""}
|
||||
</h1>
|
||||
@@ -167,6 +169,7 @@ export default function DashboardPage() {
|
||||
</p>
|
||||
</header>
|
||||
<StandNote updatedAt={standAt} isStale={hasStaleData} className="-mt-5" />
|
||||
<AppearanceNotice />
|
||||
|
||||
{autopilotError && !isAutopilotSilent ? (
|
||||
<div
|
||||
|
||||
@@ -15,7 +15,7 @@ import { PageHeader } from "@/components/page-header";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { EntityCard } from "@/components/entity-card";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -102,8 +102,8 @@ export default function HoneyBatchesPage() {
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={activeBatches.length}
|
||||
historyCount={historyBatches.length}
|
||||
activeCount={countOf(honeyBatchesQuery.data, activeBatches)}
|
||||
historyCount={countOf(honeyBatchesQuery.data, historyBatches)}
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
|
||||
@@ -88,6 +88,18 @@ describe("MedicinesPage", () => {
|
||||
expect(screen.getByText("Oxalsäure")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("an empty catalogue keeps the Aktiv (0) / Historie (0) tabs like every other list", async () => {
|
||||
vi.mocked(fetchMedicines).mockResolvedValue([]);
|
||||
vi.mocked(fetchMedicineSuggestions).mockResolvedValue([suggestion()]);
|
||||
renderPage();
|
||||
|
||||
const active = await screen.findByRole("tab", { name: "Aktiv (0)" });
|
||||
expect(active).toHaveAttribute("aria-selected", "true");
|
||||
expect(await within(screen.getByRole("tabpanel")).findByText("Aus deinen Behandlungen übernehmen?")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("tab", { name: "Historie (0)" }));
|
||||
expect(within(screen.getByRole("tabpanel")).getByText("Keine deaktivierten Mittel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applying a suggestion opens the form prefilled with the values of the latest treatment", async () => {
|
||||
vi.mocked(fetchMedicines).mockResolvedValue([]);
|
||||
vi.mocked(fetchMedicineSuggestions).mockResolvedValue([suggestion()]);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { fetchMedicines } from "@/lib/medicines/api";
|
||||
import type { MedicineView } from "@/lib/medicines/types";
|
||||
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { EntityCard } from "@/components/entity-card";
|
||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
@@ -35,9 +35,19 @@ export default function MedicinesPage() {
|
||||
? `${visible.length} ${tab === "active" ? (visible.length === 1 ? "aktives Mittel" : "aktive Mittel") : visible.length === 1 ? "deaktiviertes Mittel" : "deaktivierte Mittel"}`
|
||||
: "";
|
||||
|
||||
const listOf = (bucket: EntityLifecycleTab) => {
|
||||
const listOf = (bucket: EntityLifecycleTab) => (
|
||||
<QueryState query={medicinesQuery} what="Tierarzneimittel">
|
||||
{() => bucketList(bucket)}
|
||||
</QueryState>
|
||||
);
|
||||
|
||||
const bucketList = (bucket: EntityLifecycleTab) => {
|
||||
const shown = bucket === "active" ? active : inactive;
|
||||
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 (
|
||||
<EmptyState
|
||||
icon={Pill}
|
||||
@@ -63,16 +73,11 @@ export default function MedicinesPage() {
|
||||
/>
|
||||
{canCreate ? <OnlineOnlyHint id={hintId} /> : null}
|
||||
|
||||
<QueryState query={medicinesQuery} what="Tierarzneimittel">
|
||||
{() =>
|
||||
medicines.length === 0 ? (
|
||||
<MedicineSuggestions canCreate={canCreate} disabled={isOffline} describedBy={describedBy} />
|
||||
) : (
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={active.length}
|
||||
historyCount={inactive.length}
|
||||
activeCount={countOf(medicinesQuery.data, active)}
|
||||
historyCount={countOf(medicinesQuery.data, inactive)}
|
||||
toolbar={
|
||||
<p aria-live="polite" className="text-sm text-muted-foreground">
|
||||
{countText}
|
||||
@@ -81,9 +86,6 @@ export default function MedicinesPage() {
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { PageHeader } from "@/components/page-header";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { EntityCard } from "@/components/entity-card";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
|
||||
export default function ProductsPage() {
|
||||
const [tab, setTab] = useState<EntityLifecycleTab>("active");
|
||||
@@ -76,8 +76,8 @@ export default function ProductsPage() {
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={activeProducts.length}
|
||||
historyCount={historyProducts.length}
|
||||
activeCount={countOf(productsQuery.data, activeProducts)}
|
||||
historyCount={countOf(productsQuery.data, historyProducts)}
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { PageHeader } from "@/components/page-header";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { EntityCard } from "@/components/entity-card";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { EnumSelect } from "@/components/form/enum-select";
|
||||
|
||||
export default function QueensPage() {
|
||||
@@ -68,8 +68,8 @@ export default function QueensPage() {
|
||||
setTab(next);
|
||||
setStatus("");
|
||||
}}
|
||||
activeCount={activeQueens.length}
|
||||
historyCount={historyQueens.length}
|
||||
activeCount={countOf(queensQuery.data, activeQueens)}
|
||||
historyCount={countOf(queensQuery.data, historyQueens)}
|
||||
toolbar={
|
||||
<EnumSelect
|
||||
ariaLabel="Nach Status filtern"
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { Suspense, type ComponentType } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
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 { TaskTemplatesTab } from "./task-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 },
|
||||
];
|
||||
|
||||
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 {
|
||||
return TABS.some((tab) => tab.value === value);
|
||||
}
|
||||
@@ -60,13 +64,14 @@ function AutomationPageContent() {
|
||||
if (isTab(next)) setTab(next);
|
||||
}}
|
||||
>
|
||||
<TabsList aria-label="Bereiche der Automatisierung">
|
||||
{TABS.map((entry) => (
|
||||
<TabsTrigger key={entry.value} value={entry.value}>
|
||||
{entry.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<TabPicker
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
labels={TAB_LABELS}
|
||||
values={TAB_VALUES}
|
||||
listLabel="Bereiche der Automatisierung"
|
||||
selectLabel="Bereich der Automatisierung wählen"
|
||||
/>
|
||||
{TABS.map(({ value, Content }) => (
|
||||
<TabsContent key={value} value={value}>
|
||||
<Content />
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { EntityCard } from "@/components/entity-card";
|
||||
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { CreateTaskTemplateDialog } from "./create-task-template-dialog";
|
||||
import { EditTaskTemplateDialog } from "./edit-task-template-dialog";
|
||||
|
||||
@@ -116,8 +116,8 @@ export function TaskTemplatesTab() {
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={activeTemplates.length}
|
||||
historyCount={historyTemplates.length}
|
||||
activeCount={countOf(templates, activeTemplates)}
|
||||
historyCount={countOf(templates, historyTemplates)}
|
||||
toolbar={<CreateTaskTemplateDialog />}
|
||||
toolbarAlign="end"
|
||||
>
|
||||
|
||||
@@ -43,6 +43,23 @@ describe("both settings pages use Tabs directly", () => {
|
||||
expect(replace).toHaveBeenLastCalledWith("/settings/automation?tab=webhooks");
|
||||
});
|
||||
|
||||
it("Automatisierung: on a phone a select replaces the seven tabs and keeps the ?tab= contract", async () => {
|
||||
search = "tab=api-keys";
|
||||
const user = userEvent.setup();
|
||||
render(<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", () => {
|
||||
search = "tab=unknown";
|
||||
render(<AutomationPage />);
|
||||
|
||||
@@ -78,7 +78,7 @@ export function CreateRoleDialog() {
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<DialogTitle>Neue Rolle</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -115,7 +115,7 @@ interface EditRoleDialogProps {
|
||||
export function EditRoleDialog({ role, onClose }: EditRoleDialogProps) {
|
||||
return (
|
||||
<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>
|
||||
<DialogTitle>Rolle bearbeiten{role ? `: ${role.name}` : ""}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { useState } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
@@ -45,4 +47,13 @@ describe("PermissionMatrix", () => {
|
||||
expect(box).toHaveAccessibleDescription(expect.stringContaining(hint.textContent ?? "x"));
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["create-role-dialog.tsx", "edit-role-dialog.tsx"])(
|
||||
"%s gives the matrix the wide dialog on a wide screen (sm:max-w-2xl beats the 384 px default)",
|
||||
(file) => {
|
||||
const text = readFileSync(join(__dirname, file), "utf8");
|
||||
const content = /<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 { EmptyState } from "@/components/empty-state";
|
||||
import { EntityCard } from "@/components/entity-card";
|
||||
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { CreateRoleDialog } from "./create-role-dialog";
|
||||
import { EditRoleDialog } from "./edit-role-dialog";
|
||||
|
||||
@@ -160,8 +160,8 @@ export function RolesTab() {
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={activeRoles.length}
|
||||
historyCount={historyRoles.length}
|
||||
activeCount={countOf(roles, activeRoles)}
|
||||
historyCount={countOf(roles, historyRoles)}
|
||||
toolbar={<CreateRoleDialog />}
|
||||
toolbarAlign="end"
|
||||
>
|
||||
|
||||
@@ -63,4 +63,11 @@ describe("StatisticsPage", () => {
|
||||
expect(list).toHaveAttribute("data-variant", "segment");
|
||||
expect(screen.getByRole("tab", { name: "Völker", hidden: true })).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("on a phone the select says what it picks", () => {
|
||||
vi.mocked(fetchColonyStats).mockReturnValue(new Promise(() => {}));
|
||||
renderWithClient(<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 { QueryState } from "@/components/query-state";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { EnumSelect } from "@/components/form/enum-select";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import { TabPicker } from "@/components/tab-picker";
|
||||
import { keysOf } from "@/lib/keys-of";
|
||||
|
||||
const TAB_LABELS = {
|
||||
@@ -345,19 +345,6 @@ function FeedingStatsPanel({ colonyLabels }: { colonyLabels: Map<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() {
|
||||
const [tab, setTab] = useState<StatsTab>("colonies");
|
||||
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)}>
|
||||
<TabPicker value={tab} onChange={setTab} />
|
||||
<TabsList className="hidden sm:inline-flex">
|
||||
{TABS.map((entry) => (
|
||||
<TabsTrigger key={entry} value={entry}>
|
||||
{TAB_LABELS[entry]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<TabPicker
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
labels={TAB_LABELS}
|
||||
values={TABS}
|
||||
listLabel="Auswertungen"
|
||||
selectLabel="Auswertung wählen"
|
||||
/>
|
||||
<TabsContent value="colonies">
|
||||
<ColonyStatsPanel />
|
||||
</TabsContent>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { PageHeader } from "@/components/page-header";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { EntityCard } from "@/components/entity-card";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CreateStorageContainerDialog } from "./create-storage-container-dialog";
|
||||
import { CreateStockItemDialog } from "./create-stock-item-dialog";
|
||||
@@ -65,8 +65,8 @@ export default function StoragePage() {
|
||||
heading={<h2 className="text-lg font-semibold tracking-tight">Lagerbehälter</h2>}
|
||||
value={containerTab}
|
||||
onValueChange={setContainerTab}
|
||||
activeCount={activeContainers.length}
|
||||
historyCount={historyContainers.length}
|
||||
activeCount={countOf(containersQuery.data, activeContainers)}
|
||||
historyCount={countOf(containersQuery.data, historyContainers)}
|
||||
>
|
||||
{{ active: containersOf("active"), history: containersOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { TaskSourceBadge, TaskStatusPill } from "@/components/task-status";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { EntityCard } from "@/components/entity-card";
|
||||
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { EnumSelect } from "@/components/form/enum-select";
|
||||
import { TaskEditDialog } from "./task-edit-dialog";
|
||||
|
||||
@@ -136,8 +136,8 @@ export default function TasksPage() {
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={activeTasks.length}
|
||||
historyCount={historyTasks.length}
|
||||
activeCount={countOf(tasks, activeTasks)}
|
||||
historyCount={countOf(tasks, historyTasks)}
|
||||
toolbar={
|
||||
<>
|
||||
<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">
|
||||
<Icon className="size-4 text-primary-ink" />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useState } from "react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ActiveHistoryTabs, type EntityLifecycleTab } from "./active-history-tabs";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "./active-history-tabs";
|
||||
|
||||
function Harness({ onValueChange }: { onValueChange?: (value: EntityLifecycleTab) => void }) {
|
||||
const [value, setValue] = useState<EntityLifecycleTab>("active");
|
||||
@@ -112,4 +114,29 @@ describe("ActiveHistoryTabs (spec 10.4)", () => {
|
||||
expect(classes).toContain("pointer-fine:h-9");
|
||||
}
|
||||
});
|
||||
|
||||
it("countOf counts only loaded data: no number while loading or after a failed first load", () => {
|
||||
expect(countOf(undefined, [])).toBeUndefined();
|
||||
expect(countOf([], [])).toBe(0);
|
||||
expect(countOf([1, 2, 3], [1, 3])).toBe(2);
|
||||
});
|
||||
|
||||
it("every list passes its counts through countOf, so a failed load never says (0)", () => {
|
||||
const src = join(__dirname, "..");
|
||||
const files = (function walk(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
const path = join(dir, name);
|
||||
if (statSync(path).isDirectory()) return walk(path);
|
||||
return /\.tsx$/.test(name) && !/\.spec\./.test(name) ? [path] : [];
|
||||
});
|
||||
})(src);
|
||||
const users = files.filter((file) => /<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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* The count for a tab: unknown (no number) until the list has loaded, so a failed or pending
|
||||
* first load never reads „Aktiv (0)“. Cached data still counts while a refetch fails.
|
||||
*/
|
||||
export function countOf(data: readonly unknown[] | undefined, items: readonly unknown[]): number | undefined {
|
||||
return data === undefined ? undefined : items.length;
|
||||
}
|
||||
|
||||
const withCount = (label: string, count: number | undefined) =>
|
||||
count === undefined ? label : `${label} (${count})`;
|
||||
|
||||
|
||||
@@ -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]]);
|
||||
});
|
||||
|
||||
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)", () => {
|
||||
render(
|
||||
<>
|
||||
|
||||
@@ -44,6 +44,9 @@ function tileClass(isOn: boolean): string {
|
||||
: "items-center border border-input bg-card px-3 enabled:hover:bg-muted";
|
||||
}
|
||||
|
||||
/** Long German words and slash-joined labels (Umsetzen/Teilen/…) break instead of being clipped. */
|
||||
const WRAP = "wrap-break-word hyphens-auto";
|
||||
|
||||
const HINT_CLASS = "text-sm font-medium text-muted-foreground";
|
||||
|
||||
function joinIds(...ids: (string | false | undefined)[]): string | undefined {
|
||||
@@ -130,11 +133,11 @@ export function CheckboxField(props: CheckboxFieldProps) {
|
||||
) : null;
|
||||
const text = (
|
||||
<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}
|
||||
</span>
|
||||
{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}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -7,7 +7,7 @@ export function HoneycombPattern({ className, id = "honeycomb" }: HoneycombPatte
|
||||
return (
|
||||
<svg
|
||||
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>
|
||||
<pattern
|
||||
@@ -19,13 +19,12 @@ export function HoneycombPattern({ className, id = "honeycomb" }: HoneycombPatte
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
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"
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill={`url(#${id})`} />
|
||||
<rect width="100%" height="100%" fill={`url(#${id})`} stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -115,7 +115,7 @@ export function NotificationBell() {
|
||||
>
|
||||
<Bell className="size-4.5" />
|
||||
{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">
|
||||
{unreadCount > 9 ? "9+" : unreadCount}
|
||||
</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:
|
||||
"h-11 pointer-fine:h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-9 pointer-fine:h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
sm: "h-9 pointer-fine:h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
// 44 px: reliable with gloves or sticky fingers at the apiary.
|
||||
touch: "h-11 gap-2 px-4 text-base [&_svg:not([class*='size-'])]:size-5",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
|
||||
|
||||
@@ -17,6 +19,10 @@ function StatusSelect() {
|
||||
);
|
||||
}
|
||||
|
||||
/** A display class with !important (`!flex`, `flex!`, variants too) would beat preflight's [hidden]. */
|
||||
const IMPORTANT_DISPLAY =
|
||||
/(^|\s)(?:[^\s:]+:)*(?:!(?:block|flex|grid|inline|inline-block|inline-flex|inline-grid|contents|table)|(?:block|flex|grid|inline|inline-block|inline-flex|inline-grid|contents|table)!)(\s|$)/;
|
||||
|
||||
/** Listboxes a screen reader could still reach: not inside a hidden or inert ancestor. */
|
||||
function reachableListboxes(): Element[] {
|
||||
return Array.from(document.querySelectorAll('[role="listbox"]')).filter(
|
||||
@@ -43,4 +49,35 @@ describe("SelectContent (spec 17.4)", () => {
|
||||
await userEvent.click(await screen.findByRole("option", { name: "Aktiv" }));
|
||||
await waitFor(() => expect(reachableListboxes()).toEqual([]));
|
||||
});
|
||||
|
||||
it("a popup Base UI keeps mounted after an autofill (forceMount) is hidden, not a 0x0 listbox for AT", async () => {
|
||||
const { container } = render(<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);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
render(<Harness />);
|
||||
const list = screen.getByRole("tablist");
|
||||
|
||||
@@ -43,6 +43,7 @@ function TabsList({ className, ...props }: TabsPrimitive.List.Props) {
|
||||
* --muted-foreground on --muted (≥ 4.5:1 in all four sets). Active on --card, --foreground,
|
||||
* semibold, with a 3 px --primary-ink bar at the bottom: surface, weight and bar, never colour
|
||||
* alone. The top border keeps the label centred against the bar. Focus is the global ring.
|
||||
* A mouse hover (pointer-fine only, no sticky tap) previews it: card/60, --foreground, --input bar.
|
||||
*/
|
||||
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
return (
|
||||
@@ -50,7 +51,7 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"inline-flex h-11 items-center justify-center gap-1.5 rounded-lg border-t-3 border-b-3 border-transparent px-4 text-base font-medium whitespace-nowrap text-muted-foreground pointer-fine:h-9",
|
||||
"hover:text-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50",
|
||||
"pointer-fine:hover:text-foreground pointer-fine:hover:not-data-active:bg-card/60 pointer-fine:hover:not-data-active:border-b-input disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50",
|
||||
"data-active:border-b-primary-ink data-active:bg-card data-active:font-semibold data-active:text-foreground",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
|
||||
@@ -1,23 +1,8 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Badge } from "./badge";
|
||||
import { Button } from "./button";
|
||||
|
||||
const SRC = join(__dirname, "..", "..");
|
||||
|
||||
function sourceFiles(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
const path = join(dir, name);
|
||||
if (statSync(path).isDirectory()) return sourceFiles(path);
|
||||
return /\.tsx?$/.test(name) && !/\.spec\./.test(name) ? [path] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** A Tailwind class with the dark variant, e.g. `dark:bg-input/30` (not an object key `dark: x`). */
|
||||
const DARK_CLASS = /(^|[\s"'`])dark:[^\s"'`]/m;
|
||||
|
||||
describe("ui colours after the value switch (spec 10.8 step 4)", () => {
|
||||
it("the default button uses solid primary with primary-foreground and no gradient", () => {
|
||||
render(<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("Achtung").className).toMatch(/(^|\s)text-warning-ink(\s|$)/);
|
||||
});
|
||||
|
||||
it("ui components contain no dark: classes", () => {
|
||||
const offenders = sourceFiles(join(SRC, "components", "ui")).filter((file) =>
|
||||
DARK_CLASS.test(readFileSync(file, "utf8")),
|
||||
);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("no source file uses dark: classes - a mode is only another token set", () => {
|
||||
const offenders = sourceFiles(SRC).filter((file) => DARK_CLASS.test(readFileSync(file, "utf8")));
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
// `dark:` classes: the baukasten guard (src/styles/baukasten-guard.spec.ts) bans them in all of src.
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FILES, FRONTEND, SRC } from "./guard-files.spec-helper";
|
||||
|
||||
/**
|
||||
* Baukasten guard (spec 10.7). Each T5 task adds the rule it fulfils, with every exception and
|
||||
@@ -16,11 +17,10 @@ interface GuardRule {
|
||||
allow: { file: string; reason: string }[];
|
||||
/** Directories the rule does not cover at all (path prefix relative to ApiCore-Frontend). */
|
||||
outside?: { prefix: string; reason: string }[];
|
||||
/** Self-test: snippets the pattern must catch and snippets it must leave alone. */
|
||||
examples: { catches: string[]; ignores: string[] };
|
||||
}
|
||||
|
||||
const FRONTEND = join(__dirname, "..", "..");
|
||||
const SRC = join(FRONTEND, "src");
|
||||
|
||||
/**
|
||||
* `keys` (role, type) set to `value` by hand: JSX (role="x", role={on ? "x" : y}), object or
|
||||
* spread form (role: "x") and setAttribute("role", "x"). A CSS selector ([role="x"]) only finds
|
||||
@@ -35,28 +35,50 @@ function ownRole(keys: string, value: string): RegExp {
|
||||
return new RegExp(String.raw`(?<!<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"]> = [
|
||||
{
|
||||
prefix: "src/components/hive/",
|
||||
reason: "The hive stage moves on purpose (Beute §9.4, §9.8): a part lifts on hover and flies into focus.",
|
||||
},
|
||||
{
|
||||
prefix: "src/app/(app)/colonies/[id]/hive/",
|
||||
reason: "The full-screen hive editor is the same stage (spec 17.8).",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Spec 17.8 lets the hive stage move on its own (Beute §9.4, §9.8: lift on hover, flight into
|
||||
* focus). It does so in three.js, not with CSS classes, so no motion rule leaves it out today
|
||||
* (the stale-exception test would fail). Should the stage ever need a CSS exception, add an
|
||||
* `outside` entry with that reason to the one rule it needs.
|
||||
*/
|
||||
/** A duration above --motion-moment (420 ms): Tailwind steps and arbitrary ms values. */
|
||||
const OVER_MOMENT_MS = String.raw`(?:42[1-9]|4[3-9]\d|[5-9]\d\d|[1-9]\d{3,})`;
|
||||
|
||||
/** A px value below 13 (0–12.99…). */
|
||||
const UNDER_13_PX = String.raw`(?:\d|1[0-2])(?:\.\d+)?px`;
|
||||
/** A rem/em value below 0.8125 (13 px at a 16 px root): the digits after the point sort below 8125. */
|
||||
const UNDER_13_REM = String.raw`0?\.(?:[0-7]\d*|8(?:0\d*|1(?:[01]\d*|2(?:[0-4]\d*)?)?)?)r?em`;
|
||||
|
||||
const RULES: GuardRule[] = [
|
||||
// Task A3/A4: the area tone replaced the gradients (spec 11); the gradient tokens are gone.
|
||||
// Task A3/A4/A10: the area tone replaced the gradients (spec 11); the gradient tokens are gone.
|
||||
// PageHeader, DetailHeader and EntityCard have no `gradient` prop any more (also a type error).
|
||||
// A canvas gradient that paints a 3D texture (createLinearGradient) is no CSS background.
|
||||
{
|
||||
id: "no gradient prop or gradient token",
|
||||
pattern: /gradient=|--gradient-|\[background-image:var\(--gradient/,
|
||||
id: "no gradient prop, gradient token or gradient background",
|
||||
pattern: new RegExp(
|
||||
[
|
||||
String.raw`gradient=`,
|
||||
String.raw`--gradient-`,
|
||||
String.raw`(?<![\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}",
|
||||
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
|
||||
// --holo-label-fg instead, so it needs no exception either.
|
||||
@@ -65,6 +87,89 @@ const RULES: GuardRule[] = [
|
||||
pattern: /(?<![\w-])text-white\b/,
|
||||
scope: "src/**/*.{ts,tsx,css}",
|
||||
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).
|
||||
{
|
||||
@@ -72,12 +177,20 @@ const RULES: GuardRule[] = [
|
||||
pattern: /ring-ring\/50/,
|
||||
scope: "src/**/*.{ts,tsx}",
|
||||
allow: [],
|
||||
examples: {
|
||||
catches: ['"focus-visible:ring-3 focus-visible:ring-ring/50"'],
|
||||
ignores: ['"ring-ring"', '"focus:inset-ring-2 focus:inset-ring-ring"'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "no outline-none",
|
||||
pattern: /(?<![\w-])outline-none\b/,
|
||||
scope: "src/**/*.{ts,tsx}",
|
||||
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.
|
||||
{
|
||||
@@ -85,6 +198,10 @@ const RULES: GuardRule[] = [
|
||||
// An offset alone is no own ring (calendar days pull the global ring inside the cell).
|
||||
pattern: /(?<![\w-])focus(?:-visible)?:outline-(?!hidden|offset)/,
|
||||
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: [
|
||||
{
|
||||
file: "src/app/(app)/bottling/[id]/print-labels-card.tsx",
|
||||
@@ -104,6 +221,10 @@ const RULES: GuardRule[] = [
|
||||
pattern: /(?<![\w-])sm:(?:[^\s:"'`]+:)*(?:(?:min-)?h-|size-)/,
|
||||
scope: "src/**/*.{ts,tsx}",
|
||||
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).
|
||||
{
|
||||
@@ -111,6 +232,10 @@ const RULES: GuardRule[] = [
|
||||
pattern: /(?<![\w-])md:text-sm\b/,
|
||||
scope: "src/**/*.{ts,tsx}",
|
||||
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).
|
||||
{
|
||||
@@ -125,6 +250,17 @@ const RULES: GuardRule[] = [
|
||||
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
|
||||
// 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"),
|
||||
scope: "src/**/*.{ts,tsx}",
|
||||
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).
|
||||
{
|
||||
@@ -142,6 +299,16 @@ const RULES: GuardRule[] = [
|
||||
pattern: ownRole("role", "switch"),
|
||||
scope: "src/**/*.{ts,tsx}",
|
||||
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).
|
||||
{
|
||||
@@ -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).",
|
||||
},
|
||||
],
|
||||
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
|
||||
// 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)?"),
|
||||
scope: "src/**/*.{ts,tsx}",
|
||||
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
|
||||
// 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)/,
|
||||
scope: "src/**/*.{ts,tsx}",
|
||||
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:",
|
||||
pattern: /(?<!motion-full:)(?<![\w-])animate-(?:pulse|spin)\b/,
|
||||
scope: "src/**/*.{ts,tsx}",
|
||||
allow: [],
|
||||
examples: {
|
||||
catches: ['"size-4 animate-spin"', '"motion-safe:animate-pulse"'],
|
||||
ignores: ['"motion-full:animate-spin"', '"animate-page-in"'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "no transition-all",
|
||||
pattern: /(?<![\w-])transition-all\b|transition:\s*all\b/,
|
||||
scope: "src/**/*.{ts,tsx,css}",
|
||||
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(
|
||||
String.raw`transition-\[[^\]]*(?:width|height)|(?<![\w-])duration-(?:${OVER_MOMENT_MS}(?!\d)|\[${OVER_MOMENT_MS}ms\]|\[[1-9][\d.]*s\])`,
|
||||
),
|
||||
scope: "src/**/*.{ts,tsx}",
|
||||
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)-/,
|
||||
scope: "src/**/*.{ts,tsx}",
|
||||
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",
|
||||
pattern: /\btransition=\{\{/,
|
||||
scope: "src/**/*.tsx",
|
||||
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)$/,
|
||||
};
|
||||
|
||||
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[] {
|
||||
const isOutside = (file: string) => (rule.outside ?? []).some(({ prefix }) => file.startsWith(prefix));
|
||||
return FILES.filter(
|
||||
@@ -253,21 +453,59 @@ describe("baukasten guard (spec 10.7)", () => {
|
||||
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) => {
|
||||
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) {
|
||||
const hits = new Set(offenders(rule));
|
||||
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(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)", () => {
|
||||
for (const name of ["toggle-chip", "switch-row"]) {
|
||||
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-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/);
|
||||
});
|
||||
|
||||
it("the motion patterns catch what they are meant to catch", () => {
|
||||
const rule = (id: string) => RULES.find((entry) => entry.id.startsWith(id)) as GuardRule;
|
||||
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);
|
||||
// The rule patterns test themselves (rule.examples); here only the scan helpers.
|
||||
it("the motion scan helpers read imports and animated properties", () => {
|
||||
expect(namedImports('import { AnimatePresence, motion } from "motion/react";', "motion/react")).toEqual([
|
||||
"AnimatePresence",
|
||||
"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) => {
|
||||
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);
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
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"])(
|
||||
"the inset ring of a highlighted menu item (--ring) reaches 3:1 on the %s",
|
||||
(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([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user