Files
ApiCore/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts
T
Nick.RosenloecherandClaude Opus 5.5 427969d8b9
Backend Image / build-and-push (push) Successful in 15m27s
Frontend Image / build-and-push (push) Successful in 23m9s
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]>
2026-10-19 13:47:59 +02:00

747 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"]);
});
});