feat(t5): Bewegungs-Sprache mit Tokens, Stufen und Budget (T5a A11)
- 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:
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"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user