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

@@ -0,0 +1,134 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { SPRING_DAMPING, SPRING_STIFFNESS, springStep } from "@/components/hive/hive-scene/quality";
import {
EASE_IN,
EASE_OUT_EXPO,
MOTION,
SPRING_EASING_MS,
SPRING_FLIGHT,
SUBTLE_FADE_SECONDS,
scrollBehaviorFor,
springEasing,
transitionFor,
} from "./tokens";
import type { MotionLevel } from "./motion-preference";
const GLOBALS = readFileSync(join(__dirname, "..", "..", "app", "globals.css"), "utf8");
/** Value of a custom property declared in globals.css (first declaration). */
function cssVar(name: string): string {
const match = new RegExp(`${name}:\\s*([^;]+);`).exec(GLOBALS);
if (!match) throw new Error(`${name} missing in globals.css`);
return match[1].trim().replace(/\s+/g, " ");
}
/** Position of the flight spring from 0 toward 1 after `seconds`, stepped like the hive does it. */
function springAt(seconds: number): number {
let state = { value: 0, velocity: 0 };
// The hive steps once per frame; 1/60 s frames, remainder last.
let left = seconds;
while (left > 1e-9) {
const dt = Math.min(1 / 60, left);
state = springStep(state.value, state.velocity, 1, dt);
left -= dt;
}
return state.value;
}
function linearStops(easing: string): number[] {
const body = /^linear\((.*)\)$/.exec(easing)?.[1];
if (!body) throw new Error(`not a linear() easing: ${easing}`);
return body.split(",").map((stop) => Number(stop.trim()));
}
const LEVELS: MotionLevel[] = ["full", "subtle", "off"];
const KINDS = ["enter", "exit", "fade", "layout"] as const;
describe("motion tokens (spec 17.3)", () => {
it("css motion variables equal the TS tokens", () => {
expect(cssVar("--motion-fade")).toBe(`${MOTION.fadeMs}ms`);
expect(cssVar("--motion-enter")).toBe(`${MOTION.enterMs}ms`);
expect(cssVar("--motion-settle")).toBe(`${MOTION.settleMs}ms`);
expect(cssVar("--motion-moment")).toBe(`${MOTION.momentMs}ms`);
expect(cssVar("--motion-shift")).toBe(`${MOTION.shiftPx}px`);
expect(cssVar("--motion-spring")).toBe(`${SPRING_EASING_MS}ms`);
expect(cssVar("--ease-out-expo")).toBe(`cubic-bezier(${EASE_OUT_EXPO.join(", ")})`);
expect(cssVar("--ease-in")).toBe(`cubic-bezier(${EASE_IN.join(", ")})`);
expect(cssVar("--ease-spring")).toBe(springEasing());
// The page enters with the enter token (it was 260 ms before spec 17).
expect(cssVar("--animate-page-in")).toBe("page-in var(--motion-enter) var(--ease-out-expo)");
expect(SUBTLE_FADE_SECONDS).toBe(MOTION.fadeMs / 1000);
});
it("the flight spring equals the hive spring in quality.ts", () => {
expect(SPRING_FLIGHT).toEqual({ type: "spring", stiffness: SPRING_STIFFNESS, damping: SPRING_DAMPING, mass: 1 });
});
it("--ease-spring samples stay within 0.02 of springStep", () => {
const stops = linearStops(springEasing());
expect(stops[0]).toBe(0);
expect(stops.at(-1)).toBe(1);
// A touch of overshoot, as on the hive stage.
expect(Math.max(...stops)).toBeGreaterThan(1);
const seconds = SPRING_EASING_MS / 1000;
const last = stops.length - 1;
for (let i = 0; i < last; i += 1) {
for (const fraction of [0, 0.5]) {
const at = (i + fraction) / last;
const eased = stops[i] + (stops[i + 1] - stops[i]) * fraction;
expect(Math.abs(eased - springAt(at * seconds)), `t = ${at.toFixed(3)}`).toBeLessThanOrEqual(0.02);
}
}
});
it("transitionFor off has zero duration and subtle has no spatial part", () => {
for (const kind of KINDS) {
expect(transitionFor("off", kind)).toEqual({ duration: 0 });
const subtle = transitionFor("subtle", kind);
expect(subtle).not.toHaveProperty("type", "spring");
expect((subtle as { duration?: number }).duration ?? 0).toBeLessThanOrEqual(SUBTLE_FADE_SECONDS);
}
expect(transitionFor("subtle", "layout")).toEqual({ duration: 0 });
expect(transitionFor("full", "layout")).toEqual(SPRING_FLIGHT);
expect(transitionFor("full", "enter")).toEqual({ duration: MOTION.enterMs / 1000, ease: EASE_OUT_EXPO });
// Exits are never longer than the fade and leave faster than they came (ease-in).
for (const level of LEVELS) {
const exit = transitionFor(level, "exit") as { duration: number; ease?: unknown };
expect(exit.duration).toBeLessThanOrEqual(SUBTLE_FADE_SECONDS);
if (level !== "off") expect(exit.ease).toEqual(EASE_IN);
}
});
it("scrollBehaviorFor is smooth only at full and below one viewport", () => {
expect(scrollBehaviorFor("full", 300, 800)).toBe("smooth");
expect(scrollBehaviorFor("full", -300, 800)).toBe("smooth");
expect(scrollBehaviorFor("full", 800, 800)).toBe("instant");
expect(scrollBehaviorFor("full", 2400, 800)).toBe("instant");
expect(scrollBehaviorFor("subtle", 100, 800)).toBe("instant");
expect(scrollBehaviorFor("off", 100, 800)).toBe("instant");
});
it("the hive reads its spring and its fade from tokens.ts (one source)", () => {
const hive = join(__dirname, "..", "..", "components", "hive", "hive-scene");
const quality = readFileSync(join(hive, "quality.ts"), "utf8");
const hiveMotion = readFileSync(join(hive, "use-hive-motion.ts"), "utf8");
for (const text of [quality, hiveMotion]) expect(text).toMatch(/from\s+["']@\/lib\/motion\/tokens["']/);
expect(quality).not.toMatch(/SPRING_(?:STIFFNESS|DAMPING)\s*=\s*\d/);
expect(hiveMotion).not.toMatch(/SUBTLE_FADE_SECONDS\s*=\s*[\d.]/);
});
it("tokens.ts stays tiny and motion-free at runtime (type imports only)", () => {
const tokens = readFileSync(join(__dirname, "tokens.ts"), "utf8");
const imports = [...tokens.matchAll(/^import\s.*$/gm)].map(([line]) => line);
expect(imports.every((line) => line.startsWith("import type "))).toBe(true);
expect(tokens.length).toBeLessThan(8_000);
});
it("no DOM duration token is longer than the moment", () => {
for (const ms of [MOTION.fadeMs, MOTION.enterMs, MOTION.settleMs]) {
expect(ms).toBeLessThanOrEqual(MOTION.momentMs);
}
});
});
+96
View File
@@ -0,0 +1,96 @@
import type { Transition } from "motion/react";
import type { MotionLevel } from "./motion-preference";
/**
* Motion tokens (spec 17.3): one source for CSS (`--motion-*`, `--ease-*` in globals.css, kept
* equal by tokens.spec.ts) and for TypeScript (`motion` and the hive stage). The values come from
* what the hive already ships, so the stage and the app feel like one app.
*/
export const MOTION = {
/** Every fade and exit, and every change at "Dezent". */
fadeMs: 150,
/** Entry of sheet, dialog, popover, menu, page and a new list row. */
enterMs: 200,
/** A move without a spring: a chevron turning, a section opening. */
settleMs: 350,
/** The one moment: ticking a task (the honey cell fills). No DOM transition is longer. */
momentMs: 420,
/** Distance content travels on entry. */
shiftPx: 8,
} as const;
/** A skeleton or loading line appears only after this, so short loads do not blink (spec 17.4). */
export const SKELETON_DELAY_MS = 150;
/** `cubic-bezier` points of `--ease-out-expo` (entries, the moment). */
export const EASE_OUT_EXPO = [0.16, 1, 0.3, 1] as const;
/** `cubic-bezier` points of `--ease-in` (exits: gone faster than they came). */
export const EASE_IN = [0.4, 0, 1, 1] as const;
/** The hive's flight spring (damping ratio ≈ 0.72, settled after ≈ 0.5 s), for `layout` moves. */
export const SPRING_FLIGHT = { type: "spring", stiffness: 210, damping: 21, mass: 1 } as const;
/** Fade at "Dezent" in seconds (the hive stage uses the same). */
export const SUBTLE_FADE_SECONDS = MOTION.fadeMs / 1000;
/** Duration of a CSS move on `--ease-spring` (the spring is at rest by then). */
export const SPRING_EASING_MS = 500;
const SPRING_SUBSTEP_SECONDS = 1 / 120;
const DEFAULT_SPRING_POINTS = 32;
/**
* The flight spring as a CSS `linear()` easing over SPRING_EASING_MS: position from 0 toward 1,
* stepped like the hive steps it (semi-implicit Euler, 1/120 s substeps), sampled at `points`
* evenly spaced stops. The last stop is exactly 1. Generates `--ease-spring` in globals.css.
*/
export function springEasing(points: number = DEFAULT_SPRING_POINTS): string {
const seconds = SPRING_EASING_MS / 1000;
const last = Math.max(2, Math.round(points)) - 1;
const stops: number[] = [0];
let x = 0;
let v = 0;
let elapsed = 0;
for (let i = 1; i <= last; i += 1) {
const until = (i / last) * seconds;
while (until - elapsed > 1e-9) {
const dt = Math.min(SPRING_SUBSTEP_SECONDS, until - elapsed);
v += (SPRING_FLIGHT.stiffness * (1 - x) - SPRING_FLIGHT.damping * v) * dt;
x += v * dt;
elapsed += dt;
}
stops.push(i === last ? 1 : Math.round(x * 1000) / 1000);
}
return `linear(${stops.join(", ")})`;
}
export type TransitionKind = "enter" | "exit" | "fade" | "layout";
const INSTANT: Transition = { duration: 0 };
const FADE: Transition = { duration: SUBTLE_FADE_SECONDS, ease: "easeOut" };
const EXIT: Transition = { duration: SUBTLE_FADE_SECONDS, ease: EASE_IN };
const TRANSITIONS: Record<MotionLevel, Record<TransitionKind, Transition>> = {
full: {
enter: { duration: MOTION.enterMs / 1000, ease: EASE_OUT_EXPO },
exit: EXIT,
fade: FADE,
layout: SPRING_FLIGHT,
},
// "Dezent": only opacity and colour change; things stand at their target at once.
subtle: { enter: FADE, exit: EXIT, fade: FADE, layout: INSTANT },
off: { enter: INSTANT, exit: INSTANT, fade: INSTANT, layout: INSTANT },
};
/** The `motion` transition for one kind of change at a motion level ("Aus" → no duration). */
export function transitionFor(level: MotionLevel, kind: TransitionKind): Transition {
return TRANSITIONS[level][kind];
}
/**
* Scrolling to a programmatic focus target (first error field, an opened section): smooth only
* at "Voll" and for less than one screen height, so nobody waits for a long glide.
*/
export function scrollBehaviorFor(level: MotionLevel, distancePx: number, viewportPx: number): ScrollBehavior {
return level === "full" && Math.abs(distancePx) < viewportPx ? "smooth" : "instant";
}
@@ -0,0 +1,25 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useAfterDelay } from "./use-after-delay";
describe("useAfterDelay (spec 17.4: a skeleton appears only after 150 ms)", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("is false first and true once the delay has passed", () => {
const { result } = renderHook(() => useAfterDelay(150));
expect(result.current).toBe(false);
act(() => vi.advanceTimersByTime(149));
expect(result.current).toBe(false);
act(() => vi.advanceTimersByTime(1));
expect(result.current).toBe(true);
});
it("does not fire after unmount (a short load never blinks)", () => {
const clear = vi.spyOn(globalThis, "clearTimeout");
const { unmount } = renderHook(() => useAfterDelay(150));
unmount();
expect(clear).toHaveBeenCalled();
clear.mockRestore();
});
});
@@ -0,0 +1,16 @@
"use client";
import { useEffect, useState } from "react";
/**
* True once `ms` have passed since mount. A skeleton or a loading line waits for it, so a short
* load never makes the page blink (spec 17.4: skeleton after SKELETON_DELAY_MS).
*/
export function useAfterDelay(ms: number): boolean {
const [passed, setPassed] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setPassed(true), ms);
return () => clearTimeout(timer);
}, [ms]);
return passed;
}