- 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]>
747 lines
33 KiB
TypeScript
747 lines
33 KiB
TypeScript
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
|
||
* its reason. A rule scans the files of its scope (spec files excluded) for its pattern; an
|
||
* exception names one file, relative to ApiCore-Frontend, and must still be needed.
|
||
*/
|
||
type GuardScope = "src/**/*.tsx" | "src/**/*.{ts,tsx}" | "src/**/*.{ts,tsx,css}";
|
||
|
||
interface GuardRule {
|
||
id: string;
|
||
pattern: RegExp;
|
||
scope: GuardScope;
|
||
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[] };
|
||
}
|
||
|
||
/**
|
||
* `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
|
||
* kit controls, and the `role` prop inside a <CheckboxField> tag is the kit's own API.
|
||
*/
|
||
function ownRole(keys: string, value: string): RegExp {
|
||
const quote = "[\"'`]";
|
||
const quoted = `${quote}${value}${quote}`;
|
||
// role="x" | role={... "x" ...} | role: ... "x" (not role?: or role === "x")
|
||
const attribute = String.raw`(?<![\w[-])(?:${keys})\s*(?:=\s*(?:\{[^}]*?)?|:\s*[^,;}\n]*?)${quoted}`;
|
||
const setAttribute = String.raw`setAttribute\(\s*${quote}(?:${keys})${quote}\s*,\s*${quoted}`;
|
||
return new RegExp(String.raw`(?<!<CheckboxField\b[^<>]*)(?:${attribute}|${setAttribute})`);
|
||
}
|
||
|
||
/**
|
||
* 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/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, 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.
|
||
{
|
||
id: "no text-white",
|
||
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).
|
||
{
|
||
id: "no ring-ring/50",
|
||
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.
|
||
{
|
||
id: "no own focus outline",
|
||
// 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",
|
||
reason:
|
||
"The missing-fields section is focused by script after a click on „Drucken“; :focus-visible does not match such a focus, so the section keeps a focus: outline as the visible cue.",
|
||
},
|
||
{
|
||
file: "src/app/(app)/settings/automation/social-media-tab.tsx",
|
||
reason:
|
||
"The „Gespeichert“ message is focused by script after a click on „Speichern“; :focus-visible does not match such a focus, so the message keeps a focus: outline as the visible cue.",
|
||
},
|
||
],
|
||
},
|
||
// Task A5: compact sizes only for a fine pointer, never by viewport width (spec 4.5).
|
||
{
|
||
id: "no sm:h-, sm:min-h- or sm:size-",
|
||
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).
|
||
{
|
||
id: "no md:text-sm",
|
||
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).
|
||
{
|
||
id: "no role radio, radiogroup or type radio outside choice-group.tsx",
|
||
// JSX (role="radio", type={"radio"}) and object or spread form (role: "radio"); a CSS
|
||
// attribute selector ([role="radio"]) only finds ChoiceGroup radios and is no own build.
|
||
pattern: /(?<![\w[-])(?:role|type)\s*[=:]\s*\{?\s*["'`]radio(?:group)?["'`]/,
|
||
scope: "src/**/*.{ts,tsx}",
|
||
allow: [
|
||
{
|
||
file: "src/components/form/choice-group.tsx",
|
||
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
|
||
// of exceptions stays empty. The `role` prop of <CheckboxField> picks the kit's own control
|
||
// and is no own build (only within that same tag, so put `role` before any arrow handler).
|
||
{
|
||
id: "no role checkbox or type checkbox outside ui/checkbox.tsx",
|
||
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).
|
||
{
|
||
id: "no role switch outside ui/checkbox.tsx and ui/switch.tsx",
|
||
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).
|
||
{
|
||
id: "aria-pressed only through Button pressed",
|
||
// JSX attribute or object/spread key ("aria-pressed": x); not the Tailwind variant aria-pressed:.
|
||
pattern: /aria-pressed\s*=|["'`]aria-pressed["'`]\s*:/,
|
||
scope: "src/**/*.{ts,tsx}",
|
||
allow: [
|
||
{
|
||
file: "src/components/ui/button.tsx",
|
||
reason: "Button turns its `pressed` prop into aria-pressed (toolbar toggles, not a radio group).",
|
||
},
|
||
{
|
||
file: "src/app/(app)/calendar/page.tsx",
|
||
reason:
|
||
"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
|
||
// exceptions stays empty.
|
||
{
|
||
id: "no role tablist, tab or tabpanel outside ui/tabs.tsx",
|
||
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:.
|
||
{
|
||
id: "no motion-safe: on translate, scale, rotate, slide, zoom or animate classes",
|
||
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",
|
||
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: [],
|
||
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: "no hover translate or scale",
|
||
pattern: /(?<![\w-])(?:group-|peer-)?hover:(?:[^\s"'`:]+:)*-?(?:translate|scale)-/,
|
||
scope: "src/**/*.{ts,tsx}",
|
||
allow: [],
|
||
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: [],
|
||
examples: {
|
||
catches: ["<m.li transition={{ duration: 0.3 }} />"],
|
||
ignores: ['<m.li transition={transitionFor(level, "exit")} />'],
|
||
},
|
||
},
|
||
];
|
||
|
||
const SCOPE_FILE: Record<GuardScope, RegExp> = {
|
||
"src/**/*.tsx": /\.tsx$/,
|
||
"src/**/*.{ts,tsx}": /\.tsx?$/,
|
||
"src/**/*.{ts,tsx,css}": /\.(tsx?|css)$/,
|
||
};
|
||
|
||
function offenders(rule: GuardRule): string[] {
|
||
const isOutside = (file: string) => (rule.outside ?? []).some(({ prefix }) => file.startsWith(prefix));
|
||
return FILES.filter(
|
||
({ file, text }) => SCOPE_FILE[rule.scope].test(file) && !isOutside(file) && rule.pattern.test(text),
|
||
).map(({ file }) => file);
|
||
}
|
||
|
||
function violations(rule: GuardRule): string[] {
|
||
const allowed = new Set(rule.allow.map((entry) => entry.file));
|
||
return offenders(rule).filter((file) => !allowed.has(file));
|
||
}
|
||
|
||
const GLOBALS = readFileSync(join(SRC, "app", "globals.css"), "utf8");
|
||
|
||
describe("baukasten guard (spec 10.7)", () => {
|
||
it("scans the source tree", () => {
|
||
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 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(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);
|
||
}
|
||
const users = FILES.filter(({ text }) => /\b(?:ToggleChip|SwitchRow)\b|form\/(?:toggle-chip|switch-row)/.test(text));
|
||
expect(users.map(({ file }) => file)).toEqual([]);
|
||
});
|
||
|
||
it("TabSwitcher is gone (the settings pages use Tabs directly, spec 10.4)", () => {
|
||
expect(existsSync(join(SRC, "components", "tab-switcher.tsx"))).toBe(false);
|
||
const users = FILES.filter(({ text }) => /\bTabSwitcher\b|components\/tab-switcher/.test(text));
|
||
expect(users.map(({ file }) => file)).toEqual([]);
|
||
});
|
||
|
||
it("the tabs kit has one variant, segment (the line variant is gone)", () => {
|
||
const tabs = FILES.find(({ file }) => file === "src/components/ui/tabs.tsx")?.text ?? "";
|
||
expect(tabs).toMatch(/segment/);
|
||
expect(tabs).not.toMatch(/variant=line|\bline:/);
|
||
const lineUsers = FILES.filter(({ text }) => /<TabsList\b[^>]*variant=["'{]/.test(text));
|
||
expect(lineUsers.map(({ file }) => file)).toEqual([]);
|
||
});
|
||
|
||
it("a list of CheckboxFields keeps neighbouring targets at least 4 px apart (spec 4.5)", () => {
|
||
// `.map(… => <CheckboxField` (optionally inside an <li>) and the container opened before it.
|
||
const lists = FILES.filter(({ file }) => file.endsWith(".tsx")).flatMap(({ file, text }) =>
|
||
[...text.matchAll(/\.map\(\s*\([^)]*\)\s*=>\s*\(?\s*(?:<li\b[^>]*>\s*)?<CheckboxField\b/g)].map((match) => {
|
||
const before = text.slice(0, match.index);
|
||
const containers = [...before.matchAll(/<(?:div|ul|fieldset)\b[^>]*?className="([^"]*)"/g)];
|
||
const classes = containers.at(-1)?.[1] ?? "";
|
||
const gap = /(?:^|\s)gap-(?:y-)?(\d+(?:\.5)?)(?=\s|$)/.exec(classes)?.[1];
|
||
const line = before.split("\n").length;
|
||
return { where: `${file}:${line}`, gapPx: gap === undefined ? 0 : Number(gap) * 4 };
|
||
}),
|
||
);
|
||
expect(lists.length).toBeGreaterThanOrEqual(8);
|
||
expect(lists.filter(({ gapPx }) => gapPx < 4).map(({ where, gapPx }) => `${where}: ${gapPx} px`)).toEqual([]);
|
||
});
|
||
|
||
it("no component removes the outline without the global rule", () => {
|
||
expect(GLOBALS).toMatch(/:where\(:focus-visible\)\s*\{[^}]*outline:\s*var\(--focus-width\)\s+solid\s+var\(--ring\)/);
|
||
for (const id of ["no ring-ring/50", "no outline-none"]) {
|
||
const rule = RULES.find((entry) => entry.id === id);
|
||
expect(rule, id).toBeDefined();
|
||
expect(violations(rule as GuardRule), id).toEqual([]);
|
||
}
|
||
// .honey-check took its own shadow ring and `outline: none`; it now takes the global rule.
|
||
expect(GLOBALS).not.toMatch(/outline:\s*none/);
|
||
expect(GLOBALS).not.toMatch(/outline-ring\/50/);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* Motion language (spec 17.6, 17.7). `motion` reaches the DOM only through LazyMotion (strict) with
|
||
* `m.*` elements, so the feature bundle loads once and asynchronously in the (app) layout.
|
||
*/
|
||
const MOTION_REACT_ALLOWED = new Set(["LazyMotion", "MotionConfig", "AnimatePresence", "LayoutGroup"]);
|
||
/** Hooks of motion/react in use, by name: a new one is a decision, not a side effect. */
|
||
const MOTION_REACT_HOOKS = new Set(["useIsPresent", "usePresence"]);
|
||
/** Properties that never move (spec 17.6): they lay out or repaint on every frame. */
|
||
const FORBIDDEN_ANIMATED = [
|
||
"width",
|
||
"height",
|
||
"top",
|
||
"left",
|
||
"margin",
|
||
"padding",
|
||
"grid-template-rows",
|
||
"box-shadow",
|
||
"filter",
|
||
"backdrop-filter",
|
||
];
|
||
/** Components that show data values the beekeeper reads (spec 17.5): nothing grows or counts. */
|
||
const DATA_VALUE_FILES = [
|
||
"src/components/stat-bar-list.tsx",
|
||
"src/app/(app)/colonies/[id]/colony-varroa.tsx",
|
||
"src/components/honeycomb-pattern.tsx",
|
||
"src/components/brand-mark.tsx",
|
||
];
|
||
/** Overlays of the kit (spec 17.4): enter with --motion-enter, leave with --motion-fade and --ease-in. */
|
||
const OVERLAY_FILES = ["sheet", "dialog", "dropdown-menu", "select"].map((name) => `src/components/ui/${name}.tsx`);
|
||
|
||
function source(file: string): string {
|
||
const found = FILES.find((entry) => entry.file === file);
|
||
if (!found) throw new Error(`${file} missing`);
|
||
return found.text;
|
||
}
|
||
|
||
/** Named imports of a value import from `module` (type-only imports and specifiers are skipped). */
|
||
function namedImports(text: string, module: string): string[] {
|
||
const from = module.replaceAll("/", "\\/");
|
||
const pattern = new RegExp(String.raw`import\s+(?!type\b)\{([^}]*)\}\s+from\s+["']${from}["']`, "g");
|
||
return [...text.matchAll(pattern)].flatMap((match) =>
|
||
match[1]
|
||
.split(",")
|
||
.map((name) => name.trim())
|
||
.filter((name) => name !== "" && !name.startsWith("type "))
|
||
.map((name) => name.split(/\s+as\s+/)[0]),
|
||
);
|
||
}
|
||
|
||
/** Animated properties of the @keyframes blocks and the transition declarations in a stylesheet. */
|
||
function animatedProperties(css: string): { where: string; property: string }[] {
|
||
const found: { where: string; property: string }[] = [];
|
||
for (const block of css.matchAll(/@keyframes\s+([\w-]+)\s*\{((?:[^{}]*\{[^{}]*\})*[^{}]*)\}/g)) {
|
||
for (const declaration of block[2].matchAll(/([\w-]+)\s*:/g)) {
|
||
found.push({ where: `@keyframes ${block[1]}`, property: declaration[1] });
|
||
}
|
||
}
|
||
for (const declaration of css.matchAll(/transition(?:-property)?\s*:\s*([^;]+);/g)) {
|
||
for (const part of declaration[1].split(",")) {
|
||
const property = part.trim().split(/\s+/)[0];
|
||
if (property) found.push({ where: `transition: ${declaration[1].trim()}`, property });
|
||
}
|
||
}
|
||
return found;
|
||
}
|
||
|
||
describe("motion language (spec 17.7)", () => {
|
||
it("motion/react is used only for LazyMotion, MotionConfig, AnimatePresence and hooks; elements come from motion/react-m", () => {
|
||
const misuse = FILES.flatMap(({ file, text }) => {
|
||
const wrong = namedImports(text, "motion/react").filter(
|
||
(name) =>
|
||
!MOTION_REACT_ALLOWED.has(name) &&
|
||
!MOTION_REACT_HOOKS.has(name) &&
|
||
// The feature bundle itself, inside the one runtime module (only ever imported dynamically).
|
||
!(file === "src/components/motion/motion-runtime.tsx" && name === "domAnimation"),
|
||
);
|
||
const otherEntry =
|
||
/from\s+["'](?:framer-motion|motion|motion\/react-client|motion\/mini)["']|import\s+\*\s+as\s+\w+\s+from\s+["']motion\/react["']/.test(
|
||
text,
|
||
) || /export\s[^;]*from\s+["']motion\/react["']/.test(text);
|
||
const ownMImport =
|
||
/from\s+["']motion\/react-m["']/.test(text) && !/import\s+\*\s+as\s+m\s+from\s+["']motion\/react-m["']/.test(text);
|
||
return [
|
||
...wrong.map((name) => `${file}: ${name}`),
|
||
...(otherEntry ? [`${file}: other motion entry`] : []),
|
||
...(ownMImport ? [`${file}: m elements not imported as * as m`] : []),
|
||
];
|
||
});
|
||
expect(misuse).toEqual([]);
|
||
// The feature bundle (domAnimation) is only ever loaded with a dynamic import.
|
||
const features = FILES.filter(({ text }) => /\bdomMax\b|\bdomAnimation\b/.test(text)).map(({ file }) => file);
|
||
expect(features).toEqual(["src/components/motion/motion-runtime.tsx"]);
|
||
const staticFeatureImports = FILES.filter(({ text }) =>
|
||
/import\s[^;]*from\s+["'][^"']*motion-runtime["']/.test(text),
|
||
);
|
||
expect(staticFeatureImports.map(({ file }) => file)).toEqual([]);
|
||
// One async chunk: only the runtime imports motion; AppMotion only preloads it.
|
||
const motionUsers = FILES.filter(({ text }) =>
|
||
/^import\s+(?!type\b)[^;]*from\s+["']motion\/react(?:-m)?["']/m.test(text),
|
||
);
|
||
expect(motionUsers.map(({ file }) => file)).toEqual(["src/components/motion/motion-runtime.tsx"]);
|
||
// The three.js scene keeps its own springs (spec 17.6).
|
||
const sceneUsers = FILES.filter(
|
||
({ file, text }) => file.startsWith("src/components/hive/hive-scene/") && /["']motion\//.test(text),
|
||
);
|
||
expect(sceneUsers.map(({ file }) => file)).toEqual([]);
|
||
expect(source("src/app/(app)/layout.tsx")).toMatch(/<AppMotion>/);
|
||
expect(source("src/components/app-motion.tsx")).toMatch(/loadMotionRuntime\(\)/);
|
||
expect(source("src/components/motion/motion-runtime.tsx")).toMatch(/<LazyMotion[^>]*\bstrict\b/);
|
||
});
|
||
|
||
it("at Dezent and with the system's reduce motion the overlays fade with --motion-fade (spec 17.2)", () => {
|
||
const slots = [
|
||
"sheet-content",
|
||
"sheet-overlay",
|
||
"dialog-content",
|
||
"dialog-overlay",
|
||
"dropdown-menu-content",
|
||
"select-content",
|
||
];
|
||
// Innermost rules: selector list and body.
|
||
const rules = [...GLOBALS.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map(([, selector, body]) => ({ selector, body }));
|
||
const fadeRules = rules.filter(({ body }) => /transition-duration:\s*var\(--motion-fade\)/.test(body));
|
||
const subtle = fadeRules.find(({ selector }) => selector.includes(':root[data-motion="subtle"] [data-slot="sheet-content"]'));
|
||
const reduce = fadeRules.find(
|
||
({ selector }) => !selector.includes("data-motion") && selector.includes('[data-slot="sheet-content"]'),
|
||
);
|
||
for (const slot of slots) {
|
||
expect(subtle?.selector, slot).toContain(`:root[data-motion="subtle"] [data-slot="${slot}"]`);
|
||
expect(reduce?.selector, slot).toContain(`[data-slot="${slot}"]`);
|
||
}
|
||
const reduceAt = GLOBALS.indexOf(reduce?.selector ?? "\u0000");
|
||
expect(GLOBALS.lastIndexOf("@media (prefers-reduced-motion: reduce)", reduceAt)).toBeGreaterThan(-1);
|
||
});
|
||
|
||
it("globals.css keyframes and transitions do not animate width, height, grid-template-rows, filter or box-shadow", () => {
|
||
const animated = animatedProperties(GLOBALS);
|
||
expect(animated.length).toBeGreaterThan(4);
|
||
const wrong = animated.filter(({ property }) => FORBIDDEN_ANIMATED.includes(property) || property === "all");
|
||
expect(wrong).toEqual([]);
|
||
});
|
||
|
||
it("stat bars and other data values have no transition", () => {
|
||
for (const file of DATA_VALUE_FILES) {
|
||
expect(source(file), file).not.toMatch(/(?<![\w-])(?:[^\s"'`:]+:)*(?:transition|animate-|duration-)/);
|
||
}
|
||
});
|
||
|
||
it("sheet, dialog, menu and select take their timing from the motion tokens", () => {
|
||
for (const file of OVERLAY_FILES) {
|
||
const text = source(file);
|
||
expect(text, file).toMatch(/--motion-enter/);
|
||
expect(text, file).toMatch(/--motion-fade/);
|
||
expect(text, file).toMatch(/--ease-in\b/);
|
||
expect(text, file).toMatch(/--ease-out-expo/);
|
||
expect(text, file).not.toMatch(/(?<![\w-])duration-\d|ease-in-out/);
|
||
// tw-animate-css keyframes animate `filter` too; overlays use Base UI's starting/ending style.
|
||
expect(text, file).not.toMatch(/(?<![\w-])(?:[^\s"'`:]+:)*animate-(?:in|out)\b/);
|
||
// Movement of an overlay (slide, zoom, translate) belongs to full motion.
|
||
expect(text, file).not.toMatch(/(?<![\w:-])(?:data-[\w-]+:|data-\[[^\]]+\]:)*(?:slide-in|slide-out|zoom-in|zoom-out)-/);
|
||
}
|
||
});
|
||
|
||
it("the button gives way on press at full motion only, and cards do not lift on hover", () => {
|
||
const button = source("src/components/ui/button.tsx");
|
||
expect(button).toMatch(/motion-full:active:(?:[^\s"'`:]+:)*scale-\[0\.97\]/);
|
||
expect(button).not.toMatch(/translate-y-px/);
|
||
expect(source("src/components/ui/card.tsx")).not.toMatch(/hover:-?translate|hover:shadow/);
|
||
expect(source("src/app/(app)/sidebar.tsx")).not.toMatch(/scale-110/);
|
||
});
|
||
|
||
// 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",
|
||
]);
|
||
expect(namedImports('import type { Transition } from "motion/react";', "motion/react")).toEqual([]);
|
||
expect(MOTION_REACT_HOOKS.has("useScroll")).toBe(false);
|
||
expect(
|
||
animatedProperties(".a { transition: grid-template-rows 1s, opacity 1s; }").map(({ property }) => property),
|
||
).toEqual(["grid-template-rows", "opacity"]);
|
||
expect(
|
||
animatedProperties("@keyframes x { from { filter: blur(1px); } to { opacity: 1; } }").map(({ property }) => property),
|
||
).toEqual(["filter", "opacity"]);
|
||
});
|
||
});
|