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
+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",
|
||||
|
||||
Reference in new issue
Block a user