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,68 @@
|
||||
// Bundle-Budget fuer `motion` im DOM (Spec T5 §17.6: hoechstens 30 kB gzip).
|
||||
// Laeuft nach `next build` wie check-3d-budget.mjs; Exit 1, wenn die motion-Chunks
|
||||
// zusammen das Budget ueberschreiten oder gar kein motion-Chunk gefunden wird.
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { gzipSync } from "node:zlib";
|
||||
|
||||
// Spec §17.6: hoechstens 30 kB gzip (1 kB = 1000 Byte) fuer motion insgesamt:
|
||||
// den Teil in der App-Huelle und die asynchron geladenen Features.
|
||||
export const BUDGET_BYTES = 30_000;
|
||||
|
||||
// String-Literale, die motion auch minifiziert behaelt und die kein App-Code schreibt:
|
||||
// der Stil-Schluessel der Render-Basis, das Attribut von AnimatePresence (popLayout)
|
||||
// und die Gesten-Props der Features.
|
||||
export const MOTION_MARKERS = ["transformPerspective", "data-motion-pop-id", "whileHover"];
|
||||
const THREE_MARKERS = ["WebGLRenderer", "__THREE__"];
|
||||
|
||||
export function isMotionChunk(source) {
|
||||
if (THREE_MARKERS.every((marker) => source.includes(marker))) return false;
|
||||
return MOTION_MARKERS.some((marker) => source.includes(marker));
|
||||
}
|
||||
|
||||
/**
|
||||
* Misst alle motion-Chunks unter chunksDir (gzip level 9) und zaehlt sie zusammen.
|
||||
* @returns {{ ok: boolean, files: { file: string, gzipBytes: number }[], gzipBytes: number, budget: number, message: string }}
|
||||
*/
|
||||
export function checkMotionBudget(chunksDir, budget = BUDGET_BYTES) {
|
||||
const files = [];
|
||||
for (const name of readdirSync(chunksDir)) {
|
||||
if (!name.endsWith(".js")) continue;
|
||||
const source = readFileSync(path.join(chunksDir, name));
|
||||
if (!isMotionChunk(source.toString("latin1"))) continue;
|
||||
files.push({ file: name, gzipBytes: gzipSync(source, { level: 9 }).length });
|
||||
}
|
||||
const gzipBytes = files.reduce((sum, entry) => sum + entry.gzipBytes, 0);
|
||||
if (files.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
files,
|
||||
gzipBytes,
|
||||
budget,
|
||||
message: `Kein motion-Chunk in ${chunksDir} gefunden (Marker: ${MOTION_MARKERS.join(", ")}). Check kaputt oder Bundle umgebaut?`,
|
||||
};
|
||||
}
|
||||
const ok = gzipBytes <= budget;
|
||||
const list = files.map((entry) => `${entry.file} ${entry.gzipBytes}`).join(", ");
|
||||
return {
|
||||
ok,
|
||||
files,
|
||||
gzipBytes,
|
||||
budget,
|
||||
message: `motion: ${gzipBytes} Byte gzip in ${files.length} Chunks (${list}), Budget ${budget} Byte${ok ? "" : " UEBERSCHRITTEN"}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const chunksDir = path.resolve(process.cwd(), ".next/static/chunks");
|
||||
let result;
|
||||
try {
|
||||
result = checkMotionBudget(chunksDir);
|
||||
} catch (error) {
|
||||
console.error(`check-motion-budget: ${chunksDir} nicht lesbar (zuerst next build?): ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
(result.ok ? console.log : console.error)(result.message);
|
||||
process.exit(result.ok ? 0 : 1);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { BUDGET_BYTES, MOTION_MARKERS, checkMotionBudget, isMotionChunk } from "./check-motion-budget.mjs";
|
||||
|
||||
const motionChunk = (filler: string, marker = MOTION_MARKERS[0]) => `/*${marker}*/var a="${marker}";${filler}`;
|
||||
// Hex of random bytes is barely compressible by gzip (about 50 %).
|
||||
const noise = (bytes: number) => randomBytes(bytes).toString("hex");
|
||||
|
||||
describe("check-motion-budget (spec 17.6: motion at most 30 kB gzip)", () => {
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(path.join(tmpdir(), "motion-budget-"));
|
||||
});
|
||||
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
it("adds up every motion chunk (the shell part and the lazily loaded features)", () => {
|
||||
writeFileSync(path.join(dir, "core.js"), motionChunk(noise(500), MOTION_MARKERS[0]));
|
||||
writeFileSync(path.join(dir, "features.js"), motionChunk(noise(500), MOTION_MARKERS.at(-1)));
|
||||
writeFileSync(path.join(dir, "app.js"), "console.log('Außerdem heute')");
|
||||
const result = checkMotionBudget(dir);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.files.map(({ file }) => file).sort()).toEqual(["core.js", "features.js"]);
|
||||
expect(result.gzipBytes).toBe(result.files.reduce((sum, { gzipBytes }) => sum + gzipBytes, 0));
|
||||
});
|
||||
|
||||
it("fails when the motion chunks together are over budget", () => {
|
||||
writeFileSync(path.join(dir, "a.js"), motionChunk(noise(400)));
|
||||
writeFileSync(path.join(dir, "b.js"), motionChunk(noise(400)));
|
||||
const result = checkMotionBudget(dir, 600);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.gzipBytes).toBeGreaterThan(600);
|
||||
});
|
||||
|
||||
it("fails when no motion chunk exists (check broken or bundle rebuilt)", () => {
|
||||
writeFileSync(path.join(dir, "app.js"), "export const a = 1;");
|
||||
const result = checkMotionBudget(dir);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.files).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not count the three.js chunk", () => {
|
||||
expect(isMotionChunk(`/*WebGLRenderer*/window.__THREE__="186";${MOTION_MARKERS[0]}`)).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the spec budget of 30 kB by default", () => {
|
||||
expect(BUDGET_BYTES).toBe(30_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* T5 motion codemod (spec 17.2, 17.4, 17.7, Task A11). Pure and idempotent:
|
||||
*
|
||||
* - `motion-safe:` only asks the system and skips the level chosen in the app. Movement behind it
|
||||
* (translate, scale, rotate, slide-, zoom-, animate-, transition-transform, also behind further
|
||||
* variants) moves to `motion-full:`.
|
||||
* - Colour and opacity transitions need no variant ("Aus" sets every duration to zero):
|
||||
* `motion-safe:transition-colors`/`-opacity` lose the variant.
|
||||
* - Pulsing and spinning are full motion: a bare `animate-pulse`/`animate-spin` gets
|
||||
* `motion-full:`, and `motion-reduce:animate-none` (the old opt-out) goes.
|
||||
*
|
||||
* Hover lifts, `transition-all` and overlay timings are changed by hand (they need a judgement).
|
||||
*
|
||||
* CLI: node scripts/codemod-t5-motion.mjs [--check] <file or directory…>
|
||||
* --check lists the files that would change and exits 1 if there are any.
|
||||
*/
|
||||
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/** Characters that belong to a class token (so a match never starts inside one). */
|
||||
const TOKEN_CHAR = String.raw`[\w:/\[\]().%-]`;
|
||||
/** Further variants between the motion variant and the utility (`data-open:`, `group-hover:`). */
|
||||
const VARIANTS = String.raw`(?:[^\s"'\`:]+:)*`;
|
||||
const MOVEMENT = String.raw`-?(?:translate|scale|rotate|slide-|zoom-|animate-|transition-transform)`;
|
||||
|
||||
const SAFE_MOVEMENT = new RegExp(String.raw`(?<!${TOKEN_CHAR})motion-safe:(?=${VARIANTS}${MOVEMENT})`, "g");
|
||||
const SAFE_FADE = new RegExp(String.raw`(?<!${TOKEN_CHAR})motion-safe:(?=transition-(?:colors|opacity)(?!${TOKEN_CHAR}))`, "g");
|
||||
const REDUCE_NONE = new RegExp(String.raw` ?(?<!${TOKEN_CHAR})motion-reduce:animate-none(?!${TOKEN_CHAR})`, "g");
|
||||
const BARE_PULSE_SPIN = new RegExp(String.raw`(?<!${TOKEN_CHAR})animate-(pulse|spin)(?![\w-])`, "g");
|
||||
|
||||
/** Rewrites the motion classes of one source text. */
|
||||
export function transform(text) {
|
||||
return text
|
||||
.replace(SAFE_MOVEMENT, "motion-full:")
|
||||
.replace(SAFE_FADE, "")
|
||||
.replace(REDUCE_NONE, "")
|
||||
.replace(BARE_PULSE_SPIN, "motion-full:animate-$1");
|
||||
}
|
||||
|
||||
function filesOf(path) {
|
||||
if (statSync(path).isDirectory()) {
|
||||
return readdirSync(path).flatMap((name) => filesOf(join(path, name)));
|
||||
}
|
||||
return /\.tsx?$/.test(path) && !/\.spec\./.test(path) ? [path] : [];
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const args = process.argv.slice(2);
|
||||
const check = args.includes("--check");
|
||||
const changed = args
|
||||
.filter((arg) => arg !== "--check")
|
||||
.flatMap((arg) => filesOf(resolve(arg)))
|
||||
.filter((file) => {
|
||||
const before = readFileSync(file, "utf8");
|
||||
const after = transform(before);
|
||||
if (after === before) return false;
|
||||
if (!check) writeFileSync(file, after);
|
||||
return true;
|
||||
});
|
||||
for (const file of changed) console.log(file);
|
||||
if (check && changed.length > 0) process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { transform } from "./codemod-t5-motion.mjs";
|
||||
|
||||
describe("codemod t5 motion (spec 17.2, 17.4, 17.7)", () => {
|
||||
it("puts movement behind motion-full: instead of motion-safe:", () => {
|
||||
expect(transform('cn("size-4 motion-safe:transition-transform", open && "rotate-180")')).toBe(
|
||||
'cn("size-4 motion-full:transition-transform", open && "rotate-180")',
|
||||
);
|
||||
expect(transform('"holo-stage bg-holo-bg motion-safe:animate-pulse"')).toBe('"holo-stage bg-holo-bg motion-full:animate-pulse"');
|
||||
expect(transform('" motion-safe:animate-spin"')).toBe('" motion-full:animate-spin"');
|
||||
expect(transform('"motion-safe:data-open:zoom-in-95 motion-safe:slide-in-from-top-2"')).toBe(
|
||||
'"motion-full:data-open:zoom-in-95 motion-full:slide-in-from-top-2"',
|
||||
);
|
||||
expect(transform('"motion-safe:-translate-y-1 motion-safe:scale-95 motion-safe:rotate-90"')).toBe(
|
||||
'"motion-full:-translate-y-1 motion-full:scale-95 motion-full:rotate-90"',
|
||||
);
|
||||
});
|
||||
|
||||
it("drops the variant from colour and opacity transitions (off sets their duration to zero)", () => {
|
||||
expect(transform('"rounded-lg px-2 motion-safe:transition-colors hover:bg-muted"')).toBe(
|
||||
'"rounded-lg px-2 transition-colors hover:bg-muted"',
|
||||
);
|
||||
expect(transform('"motion-safe:transition-opacity"')).toBe('"transition-opacity"');
|
||||
});
|
||||
|
||||
it("gates bare pulse and spin behind full motion and drops motion-reduce:animate-none", () => {
|
||||
expect(transform('"size-4 animate-spin motion-reduce:animate-none"')).toBe('"size-4 motion-full:animate-spin"');
|
||||
expect(transform('"h-14 animate-pulse rounded-xl"')).toBe('"h-14 motion-full:animate-pulse rounded-xl"');
|
||||
expect(transform('"size-4 animate-spin"')).toBe('"size-4 motion-full:animate-spin"');
|
||||
});
|
||||
|
||||
it("leaves other animate classes, tokens and identifiers alone", () => {
|
||||
const untouched = [
|
||||
'"data-open:animate-in data-closed:animate-out"',
|
||||
'"motion-full:animate-pulse"',
|
||||
"const isAnimating = animate-spinner;",
|
||||
'"animate-page-in"',
|
||||
"// motion-safe: is forbidden for movement",
|
||||
];
|
||||
for (const text of untouched) expect(transform(text)).toBe(text);
|
||||
});
|
||||
|
||||
it("is idempotent", () => {
|
||||
const once = transform('"a motion-safe:transition-transform animate-spin motion-reduce:animate-none motion-safe:transition-colors"');
|
||||
expect(transform(once)).toBe(once);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user