feat(t5): Bewegungs-Sprache mit Tokens, Stufen und Budget (T5a A11)
Backend Image / build-and-push (push) Successful in 11m29s
Frontend Image / build-and-push (push) Successful in 23m35s

- src/lib/motion/tokens.ts und --motion-* in globals.css: Dauern,
  Kurven und Feder an einer Stelle; Beute-Bühne liest dieselben Werte.
- AppMotion: LazyMotion strict, Bewegungs-Code (domAnimation) als ein
  asynchroner Chunk, 28 kB gzip; check:motion-budget im Docker-Build
  neben dem 3D-Budget (Spec 17.6).
- PresenceList: abgehakte Zeilen blenden aus, die übrigen rücken nur bei
  „Voll“ nach; „Dezent“ blendet nur, „Aus“ sofort. Die Zeile geht nach
  der festen Verzögerung, auch wenn der Server schweigt (Spec 17.5);
  Fokus wandert zum nächsten Haken, auch ohne Bewegungs-Chunk.
- Overlays (Sheet, Dialog, Menü, Select) über Base-UI-Start/Ende-Stile
  mit Token-Zeiten; Verschiebung und Skalierung nur bei motion-full.
- Kein transition-all, kein Hover-Heben, keine Breiten-Animation von
  Daten; Skeleton erst nach 150 ms; Wächter-Regeln für alles davon.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-16 21:15:42 +02:00
1 parent 7025a6b589
commit cda3e02a31
66 files changed
+2574 -773

No files matched your search

@@ -14,6 +14,8 @@ interface GuardRule {
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 }[];
}
const FRONTEND = join(__dirname, "..", "..");
@@ -33,6 +35,21 @@ 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).",
},
];
/** 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,})`;
const RULES: GuardRule[] = [
// Task A3/A4: the area tone replaced the gradients (spec 11); the gradient tokens are gone.
{
@@ -153,6 +170,49 @@ const RULES: GuardRule[] = [
scope: "src/**/*.{ts,tsx}",
allow: [],
},
// 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: [],
},
{
id: "pulse and spin only behind motion-full:",
pattern: /(?<!motion-full:)(?<![\w-])animate-(?:pulse|spin)\b/,
scope: "src/**/*.{ts,tsx}",
allow: [],
},
{
id: "no transition-all",
pattern: /(?<![\w-])transition-all\b|transition:\s*all\b/,
scope: "src/**/*.{ts,tsx,css}",
allow: [],
},
{
id: "no transition-[width|height] and no duration above 420 ms outside the hive stage",
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,
},
{
id: "hover translate or scale only on the hive stage",
pattern: /(?<![\w-])(?:group-|peer-)?hover:(?:[^\s"'`:]+:)*-?(?:translate|scale)-/,
scope: "src/**/*.{ts,tsx}",
allow: [],
outside: HIVE_STAGE,
},
{
id: "motion transitions come from the tokens (transitionFor), not inline",
pattern: /\btransition=\{\{/,
scope: "src/**/*.tsx",
allow: [],
outside: HIVE_STAGE,
},
];
const SCOPE_FILE: Record<GuardScope, RegExp> = {
@@ -175,9 +235,10 @@ const FILES = sourceFiles(SRC).map((path) => ({
}));
function offenders(rule: GuardRule): string[] {
return FILES.filter(({ file, text }) => SCOPE_FILE[rule.scope].test(file) && rule.pattern.test(text)).map(
({ file }) => file,
);
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[] {
@@ -399,3 +460,216 @@ describe("icon buttons side by side (spec 4.5)", () => {
expect(overlapping).toEqual([]);
});
});
/**
* 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/);
});
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);
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"]);
});
});