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

- Baukasten-Wächter: jede Regel prüft eigene Beispiele (muss fangen, muss
  ignorieren), veraltete Ausnahmen und Ordner-Ausschlüsse schlagen fehl;
  neue Regeln für text-primary ohne -ink, dark:, Schrift unter 13 px
  (Tailwind, SVG, CSS, style, *_SIZE-Konstanten) und Verlaufs-Hintergründe.
  Aufgeteilt in baukasten-guard, touch-target-guard und einen Scan-Helfer.
- Hinweis „Neu: Sonnenmodus für draußen“ auf dem Dashboard, einmal je Gerät,
  „Ausprobieren“ schaltet live um; Fokus geht danach zur Überschrift.
- Varroa-Diagramm: Beschriftungen als HTML mit fester Größe statt SVG-Text.
- Medikamente zeigen die Tabs auch leer; Zahlen in Tabs nicht beim Laden
  oder Fehler; inaktive Tabs mit Hover für die Maus; TabPicker (Auswahl
  unter 640 px) für Automatisierung und Statistik; Rollen-Dialoge breit
  genug für die Rechte-Matrix.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-19 13:47:59 +02:00
1 parent 355a353671
commit 427969d8b9
42 files changed
+1053 -400

No files matched your search

@@ -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");
+2 -1
View File
@@ -43,6 +43,7 @@ function TabsList({ className, ...props }: TabsPrimitive.List.Props) {
* --muted-foreground on --muted (≥ 4.5:1 in all four sets). Active on --card, --foreground,
* 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.
});