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

@@ -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",