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
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user