motionspec 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -12
- package/bin/assert-canonical.js +86 -0
- package/bin/forge.js +106 -0
- package/bin/motion.js +1 -1
- package/bin/promote-gate.js +341 -0
- package/bin/sbom-check.js +90 -0
- package/bin/worker-smoke.js +101 -0
- package/catalog.lock.json +28 -1
- package/package.json +20 -8
- package/primitives/cssTransition.json +7 -4
- package/primitives/floatLoop.json +44 -0
- package/primitives/pinnedSection.json +3 -2
- package/primitives/scrollReveal.json +3 -2
- package/primitives/staggerReveal.json +3 -2
- package/schema/motionspec.schema.json +2 -2
- package/src/compiler/catalog.js +27 -1
- package/src/compiler/compile.js +38 -26
- package/src/compiler/keyword-map.js +27 -0
- package/src/compiler/lower-waapi.js +538 -0
- package/src/compiler/safety.js +66 -0
- package/src/compiler/validate.js +112 -81
- package/src/discover/discover.js +4 -12
- package/src/forge/generate.js +221 -0
- package/src/forge/prioritize.js +63 -0
- package/src/mcp/register-tools.js +6 -20
- package/src/router/cache.js +2 -1
- package/src/router/clients.js +60 -8
- package/src/router/prompt.js +2 -11
- package/src/router/route.js +18 -4
- package/src/router/telemetry.js +13 -4
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/*
|
|
4
|
+
* SBOM-Werkzeug (TASK-021, Audit-Befund #13). Zero-dep, CJS.
|
|
5
|
+
*
|
|
6
|
+
* Hintergrund: `npm sbom --omit dev` laesst hier 9 Laufzeit-Pakete aus
|
|
7
|
+
* (cross-spawn, debug, ms, which, isexe, path-key, shebang-command,
|
|
8
|
+
* shebang-regex, fast-deep-equal) — sie haengen am Prod-Baum von
|
|
9
|
+
* @modelcontextprotocol/sdk, werden aber, weil sie ZUSAETZLICH ueber einen
|
|
10
|
+
* dev-Pfad (eslint) erreichbar sind, von npm faelschlich weggeschnitten. Das
|
|
11
|
+
* Lizenz-Gate war dadurch lueckenhaft. Gleichzeitig darf die SBOM keine reinen
|
|
12
|
+
* dev-Pakete (wrangler/eslint/…) enthalten, weil deren Lizenzen das Gate sonst
|
|
13
|
+
* (zu Recht) rot faerben und nicht ausgeliefert werden.
|
|
14
|
+
*
|
|
15
|
+
* Loesung: die VOLLE SBOM erzeugen und auf die LAUFZEIT-Pakete filtern
|
|
16
|
+
* (Quelle der Wahrheit: package-lock.json, top-level node_modules/* mit
|
|
17
|
+
* dev!==true). Das ergibt eine vollstaendige UND ausschliesslich-Laufzeit-SBOM.
|
|
18
|
+
*
|
|
19
|
+
* Modi:
|
|
20
|
+
* --filter liest eine volle CycloneDX-SBOM von stdin, schreibt die
|
|
21
|
+
* Laufzeit-gefilterte SBOM nach stdout (genutzt vom `sbom`-Script).
|
|
22
|
+
* (default) prueft sbom.cdx.json: jedes Laufzeit-Paket MUSS als Komponente
|
|
23
|
+
* vorhanden sein; fehlt eines -> Liste + exit 1, sonst exit 0.
|
|
24
|
+
*/
|
|
25
|
+
const fs = require("fs");
|
|
26
|
+
const path = require("path");
|
|
27
|
+
|
|
28
|
+
const ROOT = path.join(__dirname, "..");
|
|
29
|
+
const LOCK = path.join(ROOT, "package-lock.json");
|
|
30
|
+
const SBOM = path.join(ROOT, "sbom.cdx.json");
|
|
31
|
+
|
|
32
|
+
/* Laufzeit-Pakete laut Lockfile: top-level node_modules/<name>, dev !== true.
|
|
33
|
+
* Liefert ein Set voller Namen (inkl. @scope). */
|
|
34
|
+
function runtimeNames() {
|
|
35
|
+
const lock = JSON.parse(fs.readFileSync(LOCK, "utf8"));
|
|
36
|
+
const names = new Set();
|
|
37
|
+
for (const key of Object.keys(lock.packages || {})) {
|
|
38
|
+
const m = /^node_modules\/(@[^/]+\/[^/]+|[^/]+)$/.exec(key);
|
|
39
|
+
if (!m) continue; // nur top-level (keine verschachtelten Dubletten)
|
|
40
|
+
if (lock.packages[key].dev === true) continue; // reine dev-Pakete weglassen
|
|
41
|
+
names.add(m[1]);
|
|
42
|
+
}
|
|
43
|
+
return names;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* Voller Name einer CycloneDX-Komponente (npm legt @scope direkt in name, aber
|
|
47
|
+
* group wird defensiv mitgenommen, falls ein Generator es doch trennt). */
|
|
48
|
+
function compName(c) {
|
|
49
|
+
return c.group ? c.group + "/" + c.name : c.name;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readStdin() {
|
|
53
|
+
return fs.readFileSync(0, "utf8");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function filterMode() {
|
|
57
|
+
const full = JSON.parse(readStdin());
|
|
58
|
+
const runtime = runtimeNames();
|
|
59
|
+
const keep = (full.components || []).filter((c) => runtime.has(compName(c)));
|
|
60
|
+
const keptRefs = new Set(keep.map((c) => c["bom-ref"]).filter(Boolean));
|
|
61
|
+
full.components = keep;
|
|
62
|
+
/* Abhaengigkeits-Graph auf behaltene Komponenten beschneiden, damit keine
|
|
63
|
+
* baumelnden Referenzen zurueckbleiben (die Wurzel-Komponente bleibt). */
|
|
64
|
+
if (Array.isArray(full.dependencies)) {
|
|
65
|
+
const rootRef = full.metadata && full.metadata.component && full.metadata.component["bom-ref"];
|
|
66
|
+
full.dependencies = full.dependencies
|
|
67
|
+
.filter((d) => d.ref === rootRef || keptRefs.has(d.ref))
|
|
68
|
+
.map((d) => Array.isArray(d.dependsOn)
|
|
69
|
+
? Object.assign({}, d, { dependsOn: d.dependsOn.filter((r) => r === rootRef || keptRefs.has(r)) })
|
|
70
|
+
: d);
|
|
71
|
+
}
|
|
72
|
+
process.stdout.write(JSON.stringify(full, null, 2) + "\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function checkMode() {
|
|
76
|
+
if (!fs.existsSync(SBOM)) { console.error("Keine sbom.cdx.json — erst `npm run sbom`."); process.exit(1); }
|
|
77
|
+
const sbom = JSON.parse(fs.readFileSync(SBOM, "utf8"));
|
|
78
|
+
const present = new Set((sbom.components || []).map(compName));
|
|
79
|
+
const missing = [...runtimeNames()].filter((n) => !present.has(n)).sort();
|
|
80
|
+
if (missing.length) {
|
|
81
|
+
console.error("SBOM unvollstaendig — " + missing.length + " Laufzeit-Paket(e) fehlen:");
|
|
82
|
+
missing.forEach((n) => console.error(" - " + n));
|
|
83
|
+
console.error("Fix: `npm run sbom` neu erzeugen (vollstaendige Laufzeit-SBOM).");
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
console.error("SBOM OK — alle " + present.size + " Komponenten decken die Laufzeit-Pakete ab.");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (process.argv.includes("--filter")) filterMode();
|
|
90
|
+
else checkMode();
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/*
|
|
4
|
+
* worker-smoke.js — Post-Deploy-Smoke fuer den motionspec-mcp Worker.
|
|
5
|
+
* ------------------------------------------------------------------
|
|
6
|
+
* Der Worker ist "all private": ALLE Routen ausser /dashboard sitzen hinter dem
|
|
7
|
+
* Key-Gate (worker/index.mjs). Daher zwei Modi:
|
|
8
|
+
*
|
|
9
|
+
* - Mit Key (env MOTIONSPEC_KEY oder MCP_SHARED_SECRET): GET /health mit
|
|
10
|
+
* Header x-motionspec-key. Verlangt 200 + ok:true und prueft catalogVersion
|
|
11
|
+
* gegen den lokalen Pin (Byte-Identitaet local==hosted, vgl. CLAUDE.md).
|
|
12
|
+
*
|
|
13
|
+
* - Ohne Key: ungated Liveness gegen /dashboard (HTML 200). Reicht als
|
|
14
|
+
* Post-Deploy-Lebenszeichen; der Catalog-Pin wird dann NICHT geprueft und
|
|
15
|
+
* das ausdruecklich vermerkt.
|
|
16
|
+
*
|
|
17
|
+
* Idempotent: nur read-only GETs, kein State. Exit 1 bei jedem Mismatch.
|
|
18
|
+
* Ziel-URL ueberschreibbar via WORKER_URL (Default = Hosted Prod).
|
|
19
|
+
*/
|
|
20
|
+
const SECRET_HEADER = "x-motionspec-key";
|
|
21
|
+
const BASE = (process.env.WORKER_URL || "https://motionspec-mcp.froeba-kevin.workers.dev")
|
|
22
|
+
.replace(/\/+$/, "");
|
|
23
|
+
const KEY = process.env.MOTIONSPEC_KEY || process.env.MCP_SHARED_SECRET || "";
|
|
24
|
+
|
|
25
|
+
function localCatalogPin() {
|
|
26
|
+
try {
|
|
27
|
+
// Lokaler Pin aus den primitives/ (loadCatalog) — derselbe Hash, den der
|
|
28
|
+
// Worker aus dem gebundelten Katalog rechnet (vgl. worker/index.mjs).
|
|
29
|
+
const { loadCatalog, catalogVersion } = require("../src/compiler/catalog.js");
|
|
30
|
+
return catalogVersion(loadCatalog());
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function fail(label, problems) {
|
|
37
|
+
console.error("Smoke FAIL — " + label + ":");
|
|
38
|
+
problems.forEach((p) => console.error(" - " + p));
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function gatedHealth() {
|
|
43
|
+
const url = BASE + "/health";
|
|
44
|
+
let res, body;
|
|
45
|
+
try {
|
|
46
|
+
res = await fetch(url, {
|
|
47
|
+
headers: { "user-agent": "motionspec-smoke", [SECRET_HEADER]: KEY },
|
|
48
|
+
});
|
|
49
|
+
body = await res.json();
|
|
50
|
+
} catch (e) {
|
|
51
|
+
fail(url, ["nicht erreichbar (" + e.message + ")"]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const problems = [];
|
|
55
|
+
if (res.status !== 200) problems.push("HTTP " + res.status + " (erwartet 200)");
|
|
56
|
+
if (!body || body.ok !== true) problems.push("ok !== true im Body: " + JSON.stringify(body));
|
|
57
|
+
|
|
58
|
+
const pin = localCatalogPin();
|
|
59
|
+
if (pin && body && body.catalogVersion && body.catalogVersion !== pin) {
|
|
60
|
+
problems.push(
|
|
61
|
+
"catalogVersion hosted=" + body.catalogVersion + " != local=" + pin +
|
|
62
|
+
" (Engine NICHT byte-identisch)"
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (problems.length) fail(url, problems);
|
|
66
|
+
|
|
67
|
+
console.error(
|
|
68
|
+
"Smoke OK (gated /health) — " + url + " | version=" + body.version +
|
|
69
|
+
" catalogVersion=" + body.catalogVersion + " primitives=" + body.primitives
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function ungatedLiveness() {
|
|
74
|
+
const url = BASE + "/dashboard";
|
|
75
|
+
let res, text;
|
|
76
|
+
try {
|
|
77
|
+
res = await fetch(url, { headers: { "user-agent": "motionspec-smoke" } });
|
|
78
|
+
text = await res.text();
|
|
79
|
+
} catch (e) {
|
|
80
|
+
fail(url, ["nicht erreichbar (" + e.message + ")"]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const problems = [];
|
|
84
|
+
if (res.status !== 200) problems.push("HTTP " + res.status + " (erwartet 200)");
|
|
85
|
+
const ct = res.headers.get("content-type") || "";
|
|
86
|
+
if (!/text\/html/i.test(ct)) problems.push("content-type nicht text/html: " + ct);
|
|
87
|
+
if (!text || !/<!doctype html|<html/i.test(text)) problems.push("kein HTML-Body");
|
|
88
|
+
if (problems.length) fail(url, problems);
|
|
89
|
+
|
|
90
|
+
console.error(
|
|
91
|
+
"Smoke OK (ungated /dashboard liveness) — " + url +
|
|
92
|
+
"\n Hinweis: kein MOTIONSPEC_KEY/MCP_SHARED_SECRET gesetzt -> Catalog-Pin NICHT geprueft."
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function main() {
|
|
97
|
+
if (KEY) await gatedHealth();
|
|
98
|
+
else await ungatedLiveness();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
main();
|
package/catalog.lock.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"generatedAt": "2026-06-
|
|
2
|
+
"generatedAt": "2026-06-29",
|
|
3
3
|
"note": "Released catalog baseline for ADR-0001 D2 SemVer gate. Relock at release time.",
|
|
4
4
|
"primitives": {
|
|
5
5
|
"counterUp": {
|
|
@@ -70,6 +70,33 @@
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
},
|
|
73
|
+
"floatLoop": {
|
|
74
|
+
"version": "1.0.0",
|
|
75
|
+
"output": "css",
|
|
76
|
+
"params": {
|
|
77
|
+
"axis": {
|
|
78
|
+
"type": "string",
|
|
79
|
+
"required": false,
|
|
80
|
+
"min": null,
|
|
81
|
+
"max": null,
|
|
82
|
+
"pattern": "^(x|y)$"
|
|
83
|
+
},
|
|
84
|
+
"distance": {
|
|
85
|
+
"type": "string",
|
|
86
|
+
"required": false,
|
|
87
|
+
"min": null,
|
|
88
|
+
"max": null,
|
|
89
|
+
"pattern": "^[0-9]*\\.?[0-9]+(px|rem|%)$"
|
|
90
|
+
},
|
|
91
|
+
"duration": {
|
|
92
|
+
"type": "number",
|
|
93
|
+
"required": false,
|
|
94
|
+
"min": 1,
|
|
95
|
+
"max": 10,
|
|
96
|
+
"pattern": null
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
},
|
|
73
100
|
"marquee": {
|
|
74
101
|
"version": "1.0.0",
|
|
75
102
|
"output": "css",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "motionspec",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"mcpName": "io.github.MasterPlayspots/motionspec",
|
|
5
5
|
"description": "MotionSpec — a formal intermediate language for scroll-driven web motion. A small model writes schema-validated specs; a deterministic compiler emits GSAP/CSS, hallucination-proof by construction with enforced reduced-motion fallbacks.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"type": "git",
|
|
22
22
|
"url": "git+https://github.com/MasterPlayspots/motionspec.git"
|
|
23
23
|
},
|
|
24
|
-
"homepage": "https://
|
|
24
|
+
"homepage": "https://www.npmjs.com/package/motionspec",
|
|
25
25
|
"bugs": {
|
|
26
26
|
"url": "https://github.com/MasterPlayspots/motionspec/issues"
|
|
27
27
|
},
|
|
@@ -38,10 +38,11 @@
|
|
|
38
38
|
"catalog.lock.json"
|
|
39
39
|
],
|
|
40
40
|
"scripts": {
|
|
41
|
-
"test": "node --test test/validate.test.js test/compile.test.js test/specversion-v1.test.js test/route.test.js test/route-live.test.js test/catalog-wave1.test.js test/catalog-semver.test.js test/schema-parity.test.js test/catalog-pin.test.js test/css-safety.test.js test/fuzz.test.js test/hardening.test.js test/cache.test.js test/errorcodes.test.js test/catalog-integrity.test.js test/discover.test.js test/telemetry-sink.test.js test/worker-contract.test.mjs test/analytics-engine-sink.test.mjs test/canary.test.mjs test/dashboard.test.mjs test/mcp.test.mjs test/cli-output.test.mjs",
|
|
41
|
+
"test": "node --test test/validate.test.js test/compile.test.js test/specversion-v1.test.js test/route.test.js test/route-live.test.js test/catalog-wave1.test.js test/catalog-semver.test.js test/schema-parity.test.js test/catalog-pin.test.js test/css-safety.test.js test/csstransition-validation.test.js test/safety-parity.test.js test/fuzz.test.js test/hardening.test.js test/cache.test.js test/errorcodes.test.js test/catalog-integrity.test.js test/discover.test.js test/telemetry-sink.test.js test/worker-contract.test.mjs test/worker-keys.test.mjs test/worker-abuse-alert.test.mjs test/worker-preauth-ratelimit.test.mjs test/analytics-engine-sink.test.mjs test/canary.test.mjs test/dashboard.test.mjs test/mcp.test.mjs test/cli-output.test.mjs test/waapi-lowering.test.js test/forge-prioritize.test.js test/forge-promote-gate.test.js test/forge-generate.test.js test/forge-workflow-guard.test.js test/canonical-guard.test.js",
|
|
42
42
|
"coverage": "node --test --experimental-test-coverage --test-coverage-exclude=test/** --test-coverage-exclude=bin/** --test-coverage-lines=90 --test-coverage-functions=90 --test-coverage-branches=75 test/*.test.js test/*.test.mjs",
|
|
43
43
|
"e2e": "playwright test",
|
|
44
|
-
"sbom": "npm sbom --sbom-format cyclonedx --
|
|
44
|
+
"sbom": "npm sbom --sbom-format cyclonedx | node bin/sbom-check.js --filter > sbom.cdx.json",
|
|
45
|
+
"sbom:check": "node bin/sbom-check.js",
|
|
45
46
|
"catalog-lock": "node bin/catalog-lock.js",
|
|
46
47
|
"catalog-lock:check": "node bin/catalog-lock.js --check",
|
|
47
48
|
"mcp": "node src/mcp/server.mjs",
|
|
@@ -51,20 +52,31 @@
|
|
|
51
52
|
"pipeline": "node bin/motion.js pipeline",
|
|
52
53
|
"catalog": "node bin/motion.js catalog",
|
|
53
54
|
"stats": "node bin/motion.js stats",
|
|
54
|
-
"
|
|
55
|
-
"
|
|
55
|
+
"assert-canonical": "node bin/assert-canonical.js",
|
|
56
|
+
"prepublishOnly": "npm run assert-canonical && npm test && npm run catalog-lock:check && npm run sbom && npm run sbom:check && node bin/license-check.js && node -e \"if(!require('fs').existsSync('CHANGELOG.md'))throw new Error('CHANGELOG.md fehlt')\"",
|
|
57
|
+
"predeploy:worker": "npm run assert-canonical",
|
|
58
|
+
"deploy:worker": "wrangler deploy",
|
|
59
|
+
"smoke:worker": "node bin/worker-smoke.js",
|
|
60
|
+
"release": "npm run assert-canonical && npm test && npm run catalog-lock:check && npm run sbom && npm run sbom:check && node bin/license-check.js && npm publish",
|
|
61
|
+
"release:worker": "npm run assert-canonical && npm run deploy:worker && npm run smoke:worker",
|
|
62
|
+
"lint": "eslint .",
|
|
63
|
+
"prepare": "husky"
|
|
56
64
|
},
|
|
57
65
|
"engines": {
|
|
58
66
|
"node": ">=18"
|
|
59
67
|
},
|
|
60
68
|
"dependencies": {
|
|
61
69
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
62
|
-
"zod": "
|
|
70
|
+
"zod": "4.4.3"
|
|
63
71
|
},
|
|
64
72
|
"devDependencies": {
|
|
73
|
+
"@commitlint/cli": "^21.0.2",
|
|
74
|
+
"@commitlint/config-conventional": "^21.0.2",
|
|
65
75
|
"@eslint/js": "^10.0.1",
|
|
66
76
|
"@playwright/test": "^1.50.0",
|
|
67
77
|
"eslint": "^10.5.0",
|
|
68
|
-
"globals": "^17.6.0"
|
|
78
|
+
"globals": "^17.6.0",
|
|
79
|
+
"husky": "^9.1.7",
|
|
80
|
+
"wrangler": "4.105.0"
|
|
69
81
|
}
|
|
70
82
|
}
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cssTransition",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"purpose": "Einfache Hover-Mikrointeraktion ganz ohne JavaScript.",
|
|
5
5
|
"output": "css",
|
|
6
6
|
"engine": "native-css",
|
|
7
7
|
"paramSchema": {
|
|
8
8
|
"property": {
|
|
9
9
|
"type": "string",
|
|
10
|
-
"default": "transform"
|
|
10
|
+
"default": "transform",
|
|
11
|
+
"pattern": "^(transform|opacity|color|background-color|border-color|filter|width|height)$"
|
|
11
12
|
},
|
|
12
13
|
"hoverValue": {
|
|
13
14
|
"type": "string",
|
|
14
|
-
"default": "translateY(-4px)"
|
|
15
|
+
"default": "translateY(-4px)",
|
|
16
|
+
"pattern": "^[A-Za-z0-9#%.,()-]+( [A-Za-z0-9#%.,()-]+)?( [A-Za-z0-9#%.,()-]+)?$"
|
|
15
17
|
},
|
|
16
18
|
"duration": {
|
|
17
19
|
"type": "number",
|
|
@@ -21,7 +23,8 @@
|
|
|
21
23
|
},
|
|
22
24
|
"easing": {
|
|
23
25
|
"type": "string",
|
|
24
|
-
"default": "ease-out"
|
|
26
|
+
"default": "ease-out",
|
|
27
|
+
"pattern": "^(ease|ease-in|ease-out|ease-in-out|linear|step-start|step-end|cubic-bezier\\([-0-9., ]{1,40}\\))$"
|
|
25
28
|
}
|
|
26
29
|
},
|
|
27
30
|
"triggerDefaults": {},
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "floatLoop",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"purpose": "Sanftes, endloses Schweben dekorativer Elemente — kontinuierliche Loop-Bewegung entlang einer Achse (compositor-guenstig).",
|
|
5
|
+
"output": "css",
|
|
6
|
+
"engine": "native-css",
|
|
7
|
+
"paramSchema": {
|
|
8
|
+
"distance": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"default": "8px",
|
|
11
|
+
"pattern": "^[0-9]*\\.?[0-9]+(px|rem|%)$"
|
|
12
|
+
},
|
|
13
|
+
"duration": {
|
|
14
|
+
"type": "number",
|
|
15
|
+
"default": 3,
|
|
16
|
+
"min": 1,
|
|
17
|
+
"max": 10
|
|
18
|
+
},
|
|
19
|
+
"axis": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"default": "y",
|
|
22
|
+
"pattern": "^(x|y)$"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"triggerDefaults": {},
|
|
26
|
+
"performance": {
|
|
27
|
+
"verified": true,
|
|
28
|
+
"lcpSafe": true,
|
|
29
|
+
"cost": 1,
|
|
30
|
+
"verifiedAt": "2026-06-29"
|
|
31
|
+
},
|
|
32
|
+
"a11y": {
|
|
33
|
+
"reducedMotionFallback": "static"
|
|
34
|
+
},
|
|
35
|
+
"template": "{{css target}} { animation: motion-floatLoop-{{css id}} {{css params.duration}}s ease-in-out infinite alternate; }\n@keyframes motion-floatLoop-{{css id}} { from { transform: translate{{css params.axis}}(0) } to { transform: translate{{css params.axis}}(-{{css params.distance}}) } }",
|
|
36
|
+
"demo": {
|
|
37
|
+
"html": "<div class=\"float-box d-floatLoop\" style=\"display:inline-flex;align-items:center;justify-content:center;width:6rem;height:6rem;border:1px solid #8886;border-radius:14px\">FLOAT</div>",
|
|
38
|
+
"params": {
|
|
39
|
+
"distance": "10px",
|
|
40
|
+
"duration": 3,
|
|
41
|
+
"axis": "y"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pinnedSection",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"purpose": "Sektion fixieren, waehrend der Inhalt hindurchscrollt.",
|
|
5
5
|
"output": "js",
|
|
6
6
|
"engine": "gsap.ScrollTrigger",
|
|
7
7
|
"paramSchema": {
|
|
8
8
|
"distance": {
|
|
9
9
|
"type": "string",
|
|
10
|
-
"default": "+=100%"
|
|
10
|
+
"default": "+=100%",
|
|
11
|
+
"pattern": "^\\+=[0-9]{1,6}(\\.[0-9]{1,3})?(px|%|vh|vw|em|rem)?$"
|
|
11
12
|
},
|
|
12
13
|
"pinSpacing": {
|
|
13
14
|
"type": "boolean",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scrollReveal",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"purpose": "Element blendet beim Eintritt in den Viewport ein.",
|
|
5
5
|
"output": "js",
|
|
6
6
|
"engine": "gsap.ScrollTrigger",
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
},
|
|
18
18
|
"ease": {
|
|
19
19
|
"type": "string",
|
|
20
|
-
"default": "power3.out"
|
|
20
|
+
"default": "power3.out",
|
|
21
|
+
"pattern": "^[A-Za-z0-9.()]{1,40}$"
|
|
21
22
|
},
|
|
22
23
|
"stagger": {
|
|
23
24
|
"type": "number",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "staggerReveal",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"purpose": "Mehrere Kind-Elemente gestaffelt nacheinander einblenden.",
|
|
5
5
|
"output": "js",
|
|
6
6
|
"engine": "gsap.ScrollTrigger",
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
},
|
|
18
18
|
"ease": {
|
|
19
19
|
"type": "string",
|
|
20
|
-
"default": "power2.out"
|
|
20
|
+
"default": "power2.out",
|
|
21
|
+
"pattern": "^[A-Za-z0-9.()]{1,40}$"
|
|
21
22
|
},
|
|
22
23
|
"stagger": {
|
|
23
24
|
"type": "number",
|
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
"additionalProperties": false,
|
|
36
36
|
"required": ["id", "primitive", "target"],
|
|
37
37
|
"properties": {
|
|
38
|
-
"id": { "type": "string", "minLength": 1 },
|
|
38
|
+
"id": { "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[A-Za-z0-9_-]{1,64}$" },
|
|
39
39
|
"primitive": { "type": "string", "description": "Muss ein Name aus dem Primitive-Katalog sein. Allow-List, zur Laufzeit geprueft." },
|
|
40
|
-
"target": { "type": "string", "minLength": 1 },
|
|
40
|
+
"target": { "type": "string", "minLength": 1, "maxLength": 200 },
|
|
41
41
|
"params": { "type": "object" },
|
|
42
42
|
"trigger": {
|
|
43
43
|
"type": "object",
|
package/src/compiler/catalog.js
CHANGED
|
@@ -91,4 +91,30 @@ function catalogVersion(catalog) {
|
|
|
91
91
|
return crypto.createHash("sha256").update(stable).digest("hex").slice(0, 16);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
/* Gemeinsame Katalog-Zusammenfassung (TASK-026, Audit #23). Vorher gab es zwei
|
|
95
|
+
* divergierende Implementierungen (prompt.js 4 Felder, register-tools.js 8). Eine
|
|
96
|
+
* Quelle: Default sind alle 8 Felder; ein optionales fields-Subset liefert weniger
|
|
97
|
+
* (z.B. fuer einen schlankeren Prompt). Primitive nach Namen sortiert. */
|
|
98
|
+
function catalogSummary(catalog, fields) {
|
|
99
|
+
const ALL = ["name", "version", "purpose", "engine", "cost", "paramSchema", "triggerDefaults", "reducedMotionFallback"];
|
|
100
|
+
const pick = Array.isArray(fields) ? fields : ALL;
|
|
101
|
+
return Object.keys(catalog).sort().map((name) => {
|
|
102
|
+
const p = catalog[name];
|
|
103
|
+
const full = {
|
|
104
|
+
name,
|
|
105
|
+
version: p.version,
|
|
106
|
+
purpose: p.purpose,
|
|
107
|
+
engine: p.engine,
|
|
108
|
+
cost: (p.performance && p.performance.cost) || 0,
|
|
109
|
+
paramSchema: p.paramSchema || {},
|
|
110
|
+
triggerDefaults: p.triggerDefaults || {},
|
|
111
|
+
reducedMotionFallback: (p.a11y && p.a11y.reducedMotionFallback) || null,
|
|
112
|
+
};
|
|
113
|
+
if (pick === ALL) return full;
|
|
114
|
+
const o = {};
|
|
115
|
+
pick.forEach((f) => { o[f] = full[f]; });
|
|
116
|
+
return o;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = { loadCatalog, buildCatalog, catalogVersion, checkPrimitive, catalogSummary, screenPattern };
|
package/src/compiler/compile.js
CHANGED
|
@@ -14,11 +14,13 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Ablauf: Trust Boundary -> Defaults -> Templates -> Gates -> Report.
|
|
16
16
|
*/
|
|
17
|
-
const { validateSpec
|
|
17
|
+
const { validateSpec } = require("./validate.js");
|
|
18
|
+
/* CSS-Rohtext-Screening + Default-Fuellung kommen aus der geteilten Quelle
|
|
19
|
+
* (safety.js) — byte-identisch genutzt vom WAAPI-Lowering (lower-waapi.js). */
|
|
20
|
+
const { cssRaw, withDefaults } = require("./safety.js");
|
|
18
21
|
const VERSION = require("../../package.json").version;
|
|
19
22
|
|
|
20
23
|
const BUDGET = 10; /* Performance-Budget: Summe der Primitiv-Kosten */
|
|
21
|
-
const CSS_SAFE_RE = /^[A-Za-z0-9 _\-#.:,()>+~*=%[\]"|^$]{0,200}$/;
|
|
22
24
|
|
|
23
25
|
function resolvePath(ctx, expr) {
|
|
24
26
|
return expr.split(".").reduce(
|
|
@@ -33,14 +35,6 @@ function jsLiteral(v) {
|
|
|
33
35
|
return JSON.stringify(v); /* Strings UND Objekte: immer korrekt escaped */
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
function cssRaw(v, expr) {
|
|
37
|
-
const s = String(v);
|
|
38
|
-
const bad = unsafeToken(s); /* defense in depth: never emit javascript:/expression(/url( ... */
|
|
39
|
-
if (bad || !CSS_SAFE_RE.test(s) || s.includes("/*") || s.includes("*/"))
|
|
40
|
-
throw new Error('CSS-Interpolation abgelehnt fuer "' + expr + '": Wert "' + s + '"' + (bad ? ' enthaelt gefaehrliches Token "' + bad + '"' : " verletzt die CSS-Allow-List") + ".");
|
|
41
|
-
return s;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
38
|
function fill(tpl, ctx) {
|
|
45
39
|
return tpl.replace(/\{\{([^}]+)\}\}/g, (_, raw) => {
|
|
46
40
|
const expr = raw.trim();
|
|
@@ -52,20 +46,35 @@ function fill(tpl, ctx) {
|
|
|
52
46
|
});
|
|
53
47
|
}
|
|
54
48
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
*
|
|
67
|
-
|
|
68
|
-
|
|
49
|
+
/**
|
|
50
|
+
* @typedef {Object} CompileReport
|
|
51
|
+
* @property {number} motions - Anzahl kompilierter Motions
|
|
52
|
+
* @property {number} jsCount - Anzahl JS-Ausgaben
|
|
53
|
+
* @property {number} cssCount - Anzahl CSS-Ausgaben
|
|
54
|
+
* @property {number} cost - Summe der Primitiv-Kosten
|
|
55
|
+
* @property {number} budget - Performance-Budget (Default BUDGET)
|
|
56
|
+
* @property {boolean} budgetOk - true wenn cost <= budget
|
|
57
|
+
* @property {boolean} reducedMotion - ob ein prefers-reduced-motion-Guard emittiert wird
|
|
58
|
+
* @property {boolean} reducedMotionOverriddenOff - true wenn die Spec den Guard explizit abschaltet
|
|
59
|
+
* @property {string} specVersion - specVersion der Quelle
|
|
60
|
+
* @property {Array<{code:string,message:string}>} deprecations - Deprecation-Hinweise (ADR-0001 D4)
|
|
61
|
+
*/
|
|
62
|
+
/**
|
|
63
|
+
* @typedef {Object} CompileResult
|
|
64
|
+
* @property {boolean} ok - true bei Erfolg, false fail-closed (keine Teil-Ausgabe)
|
|
65
|
+
* @property {string[]} [errors] - Fehler mit [MS-XXX]-Praefix, nur wenn ok=false
|
|
66
|
+
* @property {string|null} [js] - generiertes JS (null wenn keine JS-Motions)
|
|
67
|
+
* @property {string|null} [css] - generiertes CSS (null wenn keine CSS-Motions)
|
|
68
|
+
* @property {CompileReport} [report] - Report, nur wenn ok=true
|
|
69
|
+
*/
|
|
70
|
+
/**
|
|
71
|
+
* Validiert (fail-closed) und kompiliert eine MotionSpec deterministisch zu GSAP/CSS.
|
|
72
|
+
* Dieselbe Spec ergibt immer denselben Code; bei Validierungs- oder CSS-Fehlern
|
|
73
|
+
* wird nichts emittiert.
|
|
74
|
+
* @param {Object} spec - die MotionSpec
|
|
75
|
+
* @param {Object} catalog - geladener Primitiv-Katalog
|
|
76
|
+
* @param {{specName?: string, budget?: number}} [opts] - optionaler Artefakt-Name + Budget
|
|
77
|
+
* @returns {CompileResult}
|
|
69
78
|
*/
|
|
70
79
|
function compileSpec(spec, catalog, opts) {
|
|
71
80
|
const o = opts || {};
|
|
@@ -77,7 +86,7 @@ function compileSpec(spec, catalog, opts) {
|
|
|
77
86
|
* so validate-only callers and the compile report agree. */
|
|
78
87
|
const deprecations = v.deprecations || [];
|
|
79
88
|
|
|
80
|
-
const respectRM =
|
|
89
|
+
const respectRM = !(spec.globals && spec.globals.respectReducedMotion === false);
|
|
81
90
|
const js = [], css = [];
|
|
82
91
|
let cost = 0, nJs = 0, nCss = 0;
|
|
83
92
|
|
|
@@ -140,10 +149,13 @@ function compileSpec(spec, catalog, opts) {
|
|
|
140
149
|
budget,
|
|
141
150
|
budgetOk: cost <= budget,
|
|
142
151
|
reducedMotion: respectRM,
|
|
152
|
+
reducedMotionOverriddenOff: !!(spec.globals && spec.globals.respectReducedMotion === false),
|
|
143
153
|
specVersion: spec.specVersion,
|
|
144
154
|
deprecations,
|
|
145
155
|
},
|
|
146
156
|
};
|
|
147
157
|
}
|
|
148
158
|
|
|
149
|
-
|
|
159
|
+
/* withDefaults/cssRaw werden aus safety.js re-exportiert, damit der Parity-Test
|
|
160
|
+
* die Referenz-Identitaet beider Paesse gegen die EINE Quelle pruefen kann. */
|
|
161
|
+
module.exports = { compileSpec, fill, withDefaults, cssRaw, BUDGET };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* Gemeinsame Keyword->Primitiv-Heuristik (TASK-025, Audit #22).
|
|
4
|
+
*
|
|
5
|
+
* Vorher gab es ZWEI divergierende Tabellen: clients.js (mockClient, 5 Eintraege,
|
|
6
|
+
* mit target/params) und discover.js (Gap-Report, 8 Eintraege, ohne target). Dadurch
|
|
7
|
+
* fehlten counterUp/marquee/scaleOnScroll im Mock — der Repair-Loop lief gegen ein
|
|
8
|
+
* kuenstlich unvollstaendiges Modell. Eine Quelle der Wahrheit schliesst die Luecke.
|
|
9
|
+
*
|
|
10
|
+
* Quelle der Regex/Reihenfolge ist discover.js (der vollstaendigere 8er-Satz);
|
|
11
|
+
* target/params der fuenf bekannten Primitive stammen aus clients.js, fuer die drei
|
|
12
|
+
* neuen sind sinnvolle Default-Selektoren gesetzt. WICHTIG: Reihenfolge ist API —
|
|
13
|
+
* discover.mapIntent() liefert den ERSTEN Treffer, daher Eintraege nicht umsortieren.
|
|
14
|
+
*/
|
|
15
|
+
const KEYWORD_MAP = [
|
|
16
|
+
{ re: /(stagger|nacheinander|gestaffelt|one after|cards?|karten|liste)/i, primitive: "staggerReveal", target: ".features .card", params: { from: { opacity: 0, y: 32 } } },
|
|
17
|
+
{ re: /(z(ä|ae)hl|counter|hochz(ä|ae)hl|count up|nummer|zahl)/i, primitive: "counterUp", target: ".stats .num", params: {} },
|
|
18
|
+
{ re: /(parallax|tiefe|depth|layer|ebene)/i, primitive: "parallaxLayer", target: ".hero .bg", params: { yPercent: -25 } },
|
|
19
|
+
{ re: /(pin|fixier|sticky|festhalten|haften)/i, primitive: "pinnedSection", target: ".showcase", params: { distance: "+=100%" } },
|
|
20
|
+
{ re: /(marquee|laufband|logo.?band|ticker|endlos)/i, primitive: "marquee", target: ".marquee-track", params: {} },
|
|
21
|
+
{ re: /(schweb|schwebe|schweben|float|floating|bob|gleitet sanft|auf und ab)/i, primitive: "floatLoop", target: ".float", params: { distance: "8px", duration: 3, axis: "y" } },
|
|
22
|
+
{ re: /(skalier|scale|zoom|gr(ö|oe)(ß|ss)er werden)/i, primitive: "scaleOnScroll", target: ".section", params: {} },
|
|
23
|
+
{ re: /(hover|maus|cursor|button)/i, primitive: "cssTransition", target: ".cta-button", params: { hoverValue: "translateY(-4px)" } },
|
|
24
|
+
{ re: /(reveal|einblend|fade|erscheinen|appear|headline|(ü|ue)berschrift|gleitet|scroll)/i, primitive: "scrollReveal", target: ".hero h1", params: { from: { opacity: 0, y: 48 } } },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
module.exports = { KEYWORD_MAP };
|