diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b09297..d0cd4a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,48 @@ follows [Keep a Changelog](https://keepachangelog.com/), and the project adheres to Semantic Versioning (pre-1.0 : minor bumps may carry behavioural changes that keep the `mount()` API stable). +## [0.2.9] - 2026-06-13 + +Fix the keyframe-animation RUNTIME so `core.animation.play@1` actually moves +and fades at the antenna (ADR 011 I7, 3rd and last link). The render bundle +Orion served was already correct — a keyframed `frame` wrapper carrying the +target's static geometry (x:80,y:360, 160×160) with the animated +`transform`/`opacity` keyframes, the resolved target nested beneath — yet the +box rendered 100×100 pinned at (0,0), immobile, with no translateX and no fade +(proven by live frame-diff, tir I7 #2). Two distinct runtime bugs in +`@lumencast/runtime`'s keyframe path, BOTH dropping the animated geometry: + +- **Dead wrapper box.** The `KeyframePlayer` wrapped the played subtree in a + ``. A `display:contents` element + generates no box, so the browser silently dropped the `transform`/`opacity`/ + `filter` framer-motion wrote onto it — the wrapper's geometry never + composited and the nested target rendered dead at its default origin. The + player is now a real compositing box (`position:absolute; inset:0`) that also + becomes the containing block for the absolutely-positioned target nested + beneath, preserving its authored x/y. +- **Wrong framer transform key.** `compileForFramer` emitted the authored + `translateX`/`translateY` channels verbatim, but framer-motion animates + transform through its shorthand motion keys `x`/`y` — so the translation was + silently ignored even once the box composited (only the opacity fade + survived). The translate channels now map onto the framer keys. + +Both are repairs to the EXISTING keyframe player (no new runtime primitive — +ADR 011 §6 criterion #6), shipped as a committed `patch-package` patch over +`@lumencast/runtime@0.6.0` since the buggy code lives in the bundled +dependency. Solar re-bundles the patched runtime into `dist/`. The wipe-cover +degenerate case (full-screen self-painting cover, no nested target) is +preserved: the cover still fills the screen and fades through the same +keyframe path; its byte-pinned authored shape is unchanged. + +A runtime test (`tests/unit/animation-compositing.test.tsx`) mounts Solar +end-to-end against a bundle shaped exactly like the live render-bundle and +proves the wrapper composites the keyframe `translateX(400px)` + `opacity:1` +onto a real positioned box, with the nested 160×160 target preserved at +(80,360) — not (0,0)/default. The wipe-cover test reads the live opacity off +the new compositing box. + +`mount()` / `SolarError` public surface unchanged (patch). Refs ADR 011 I7. + ## [0.2.8] - 2026-06-12 Extract the show-token from the packed `orionUrl` — the missing half of the diff --git a/package-lock.json b/package-lock.json index ae27e37..f31c908 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "eslint-plugin-react-hooks": "^5.1.0", "globals": "^17.6.0", "happy-dom": "^20.9.0", + "patch-package": "^8.0.1", "postcss": "^8.5.2", "prettier": "^3.5.2", "tailwindcss": "^4.0.7", @@ -2901,6 +2902,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -3223,6 +3231,19 @@ "node": "18 || 20 || >=22" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -3365,6 +3386,22 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4305,6 +4342,19 @@ "node": ">=16.0.0" } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -4322,6 +4372,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "micromatch": "^4.0.2" + } + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -4916,6 +4976,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -5001,6 +5077,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-number-object": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", @@ -5163,6 +5249,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -5259,6 +5358,26 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stable-stringify": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -5292,6 +5411,16 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "dev": true, + "license": "Public Domain", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -5318,6 +5447,16 @@ "json-buffer": "3.0.1" } }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, "node_modules/kolorist": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", @@ -5684,6 +5823,33 @@ "node": ">= 0.4" } }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -5700,6 +5866,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -5942,6 +6118,23 @@ ], "license": "MIT" }, + "node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -6023,6 +6216,51 @@ "node": ">=6" } }, + "node_modules/patch-package": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", + "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^10.0.0", + "json-stable-stringify": "^1.0.2", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "semver": "^7.5.3", + "slash": "^2.0.0", + "tmp": "^0.2.4", + "yaml": "^2.2.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=14", + "npm": ">5" + } + }, + "node_modules/patch-package/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -6632,6 +6870,16 @@ "dev": true, "license": "ISC" }, + "node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -6899,6 +7147,29 @@ "node": ">=14.0.0" } }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", diff --git a/package.json b/package.json index 3318aa0..4af84e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@zablab/solar", - "version": "0.2.8", + "version": "0.2.9", "description": "Solar — scene runtime bundle for the Zablab broadcast platform (Pulsar CEF + Prism webview + editor preview)", "type": "module", "private": true, @@ -26,12 +26,15 @@ }, "scripts": { "dev": "vite", + "postinstall": "patch-package", "build": "npm run build:lib && npm run build:host && node scripts/build-host-html.mjs", + "prebuild": "patch-package", "build:lib": "vite build", "build:host": "vite build --config vite.config.host.ts", "lint": "eslint --max-warnings 0 .", "typecheck": "tsc -b --pretty", "format": "prettier --write .", + "pretest": "patch-package", "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", @@ -59,6 +62,7 @@ "eslint-plugin-react-hooks": "^5.1.0", "globals": "^17.6.0", "happy-dom": "^20.9.0", + "patch-package": "^8.0.1", "postcss": "^8.5.2", "prettier": "^3.5.2", "tailwindcss": "^4.0.7", diff --git a/patches/@lumencast+runtime+0.6.0.patch b/patches/@lumencast+runtime+0.6.0.patch new file mode 100644 index 0000000..7d0f934 --- /dev/null +++ b/patches/@lumencast+runtime+0.6.0.patch @@ -0,0 +1,115 @@ +diff --git a/node_modules/@lumencast/runtime/dist/animate/keyframes.js b/node_modules/@lumencast/runtime/dist/animate/keyframes.js +index 1ea2d0c..10fe32e 100644 +--- a/node_modules/@lumencast/runtime/dist/animate/keyframes.js ++++ b/node_modules/@lumencast/runtime/dist/animate/keyframes.js +@@ -98,7 +98,10 @@ function pullTransform(steps, prop, out) { + out.rotate = values.map((n) => `${n}deg`); + } + else { +- out[prop] = values; ++ // ADR 011 I7: framer-motion animates transform via `x`/`y`, not ++ // `translateX`/`translateY` (dropped silently — box never moved). ++ const framerKey = prop === "translateX" ? "x" : prop === "translateY" ? "y" : prop; ++ out[framerKey] = values; + } + } + } +diff --git a/node_modules/@lumencast/runtime/dist/render/keyframe-player.js b/node_modules/@lumencast/runtime/dist/render/keyframe-player.js +index 5df7b99..41197db 100644 +--- a/node_modules/@lumencast/runtime/dist/render/keyframe-player.js ++++ b/node_modules/@lumencast/runtime/dist/render/keyframe-player.js +@@ -40,7 +40,14 @@ export function KeyframePlayer({ keyframes, store, nodeId, children, }) { + const transition = staggerDelayMs > 0 + ? { ...compiled.transition, delay: staggerDelayMs / 1000 } + : compiled.transition; +- return (_jsxs(motion.div, { style: { display: "contents" }, initial: firstFrame(compiled.animate), animate: compiled.animate, transition: transition, children: [_jsx(ReplayOnMount, {}), children] }, replayTokenRef.current)); ++ // A `display:contents` element generates NO box, so the browser never ++ // composites the animated transform/opacity/filter this player writes ++ // (ADR 011 I7 live bug). The player must be a REAL compositing box: ++ // `position:absolute; inset:0` overlays the parent without disturbing ++ // sibling layout and becomes the containing block for the absolutely- ++ // positioned primitive nested beneath, so the child's authored x/y are ++ // preserved while the animated channels now composite onto a live box. ++ return (_jsxs(motion.div, { style: { position: "absolute", inset: 0 }, initial: firstFrame(compiled.animate), animate: compiled.animate, transition: transition, children: [_jsx(ReplayOnMount, {}), children] }, replayTokenRef.current)); + } + /** No-op effect placeholder — kept for symmetry / future hooks like + * reporting playback completion to the renderer. */ +diff --git a/node_modules/@lumencast/runtime/dist/tree-DBj9SJgs.js b/node_modules/@lumencast/runtime/dist/tree-DBj9SJgs.js +index c53e909..3bfad28 100644 +--- a/node_modules/@lumencast/runtime/dist/tree-DBj9SJgs.js ++++ b/node_modules/@lumencast/runtime/dist/tree-DBj9SJgs.js +@@ -689,7 +689,13 @@ function U(t, e, n) { + const l = o.transform?.[e]; + typeof l == "number" ? (r = !0, s = l, i.push(l)) : i.push(s ?? ze(e)); + } +- r && (e === "rotate" ? n.rotate = i.map((o) => `${o}deg`) : n[e] = i); ++ // ADR 011 I7 live-bug fix (2nd half): framer-motion animates transform via ++ // its shorthand motion keys `x`/`y`, NOT `translateX`/`translateY` — those ++ // were silently dropped (the box never moved at the antenna). Map the ++ // authored `translateX`/`translateY` channels onto the framer keys so the ++ // translation actually composites. `scale`/`rotate` already match framer. ++ const TRANSLATE_TO_FRAMER = { translateX: "x", translateY: "y" }; ++ r && (e === "rotate" ? n.rotate = i.map((o) => `${o}deg`) : n[TRANSLATE_TO_FRAMER[e] ?? e] = i); + } + function ze(t) { + return t === "scale" ? 1 : 0; +@@ -719,7 +725,12 @@ function We({ + return /* @__PURE__ */ Ct( + D.div, + { +- style: { display: "contents" }, ++ // ADR 011 I7 live-bug fix: `display:contents` generates no box, so the ++ // browser drops the animated transform/opacity/filter the player writes ++ // (dead pixel at the antenna). A real positioned box composites them and ++ // becomes the containing block for the absolutely-positioned primitive ++ // nested beneath — preserving the target's authored x/y. ++ style: { position: "absolute", inset: 0 }, + initial: Be(a.animate), + animate: a.animate, + transition: c, +diff --git a/node_modules/@lumencast/runtime/src/animate/keyframes.ts b/node_modules/@lumencast/runtime/src/animate/keyframes.ts +index 5a7a10d..784e212 100644 +--- a/node_modules/@lumencast/runtime/src/animate/keyframes.ts ++++ b/node_modules/@lumencast/runtime/src/animate/keyframes.ts +@@ -151,7 +151,14 @@ function pullTransform( + if (prop === "rotate") { + out.rotate = values.map((n) => `${n}deg`); + } else { +- out[prop] = values; ++ // ADR 011 I7 live-bug fix (2nd half): framer-motion animates transform ++ // through its shorthand motion keys `x`/`y`, NOT `translateX`/`translateY` ++ // — emitting the authored names verbatim left framer with unknown keys ++ // it silently dropped, so the box never translated at the antenna (only ++ // the opacity fade survived). Map the translate channels onto the framer ++ // keys. `scale`/`rotate` already match framer's vocabulary. ++ const framerKey = prop === "translateX" ? "x" : prop === "translateY" ? "y" : prop; ++ out[framerKey] = values; + } + } + } +diff --git a/node_modules/@lumencast/runtime/src/render/keyframe-player.tsx b/node_modules/@lumencast/runtime/src/render/keyframe-player.tsx +index 9b96730..e6ea36f 100644 +--- a/node_modules/@lumencast/runtime/src/render/keyframe-player.tsx ++++ b/node_modules/@lumencast/runtime/src/render/keyframe-player.tsx +@@ -64,7 +64,20 @@ export function KeyframePlayer({ + return ( + `. +// A `display:contents` element generates NO box, so the browser silently +// dropped the animated `transform`/`opacity` framer-motion wrote onto it — +// the geometry the wrapper carried never composited and the nested target +// rendered dead at its default origin. +// +// THE FIX (patches/@lumencast+runtime+0.6.0.patch). The player now renders +// `` — a real compositing +// box that (a) carries the animated channels onto live pixels and (b), being +// positioned, is the containing block for the absolutely-positioned Frame +// nested beneath, so the target's authored x/y are preserved. +// +// This test mounts Solar end-to-end over a fake LSDP/1.1 transport against a +// bundle shaped exactly like the live render-bundle (wrapper frame with the +// target geometry + nested target), and asserts on the actual DOM that the +// keyframe wrapper is a COMPOSITING box carrying the animated transform — +// not a `display:contents` element. happy-dom's wall clock is +// non-deterministic, so we assert the STRUCTURE that makes compositing +// possible (a positioned box with a transform/opacity style), not a precise +// mid-tween pixel sample (that is the Playwright E2E's job). + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { encodeFrame, snapshot, delta } from "@lumencast/protocol"; +import type { LeafValue } from "@lumencast/protocol"; +import type { RenderBundle, RenderNode } from "@lumencast/runtime"; +import { mount } from "../../src/mount"; +import { buildAnimationNode } from "../../src/overlay/animation"; + +const SCENE_ID = "scene-anim"; +const SCENE_VERSION = "sha256-anim-0001"; +// The scalar generation leaf the exec op increments (ADR 011 §3.2). +const LEAF = "__anim.anim_box"; + +// The authored keyframe geometry the live render-bundle carries: +// translateX 0→120→360→400 px over 1500 ms, opacity 0→1, ease-out. +const KEYFRAMES = { + duration_ms: 1500, + easing: "ease-out" as const, + steps: [ + { at: 0, transform: { translateX: 0 }, opacity: 0 }, + { at: 0.3, transform: { translateX: 120 }, opacity: 1 }, + { at: 0.8, transform: { translateX: 360 }, opacity: 1 }, + { at: 1, transform: { translateX: 400 }, opacity: 1 }, + ], +}; + +// The resolved target overlay node — `anim_box` shape, x/y stripped by the +// lowering (the wrapper carries the position), keeping its own size + fill. +const NESTED_TARGET: RenderNode = { + kind: "shape", + id: "anim_box", + props: { width: 160, height: 160, fill: "#C81E5A" }, +}; + +// The wrapper node exactly as the live render-bundle serves it: the target's +// STATIC geometry (x:80, y:360, 160×160) on the wrapper props, the animated +// keyframes keyed on the scalar leaf, and the target NESTED beneath. +const ANIM_NODE = buildAnimationNode({ + leafPath: LEAF, + keyframes: KEYFRAMES, + id: "anim_play", + props: { x: 80, y: 360, width: 160, height: 160 }, + children: [NESTED_TARGET], +}); + +const BUNDLE: RenderBundle = { + scene_version: SCENE_VERSION, + root: { kind: "stack", children: [ANIM_NODE] }, +}; + +// --- fake LSDP/1.1 transport ------------------------------------------ + +class FakeWebSocket { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + static last: FakeWebSocket | null = null; + readonly CONNECTING = 0; + readonly OPEN = 1; + readonly CLOSING = 2; + readonly CLOSED = 3; + readyState = 0; + protocol = "lsdp.v1.1"; + onopen: (() => void) | null = null; + onmessage: ((ev: { data: string }) => void) | null = null; + onclose: ((ev: { code: number; reason: string }) => void) | null = null; + onerror: ((ev: unknown) => void) | null = null; + + constructor(public url: string) { + FakeWebSocket.last = this; + queueMicrotask(() => { + this.readyState = this.OPEN; + this.onopen?.(); + }); + } + send(raw: string): void { + const frame = JSON.parse(raw) as { type?: string }; + if (frame.type === "subscribe") { + const snap = snapshot({ + seq: 1, + scene_id: SCENE_ID, + scene_version: SCENE_VERSION, + // The scalar generation leaf starts at 0 (ADR 011 §3.2). + state: { [LEAF]: 0 }, + }); + queueMicrotask(() => this.onmessage?.({ data: encodeFrame(snap) })); + } + } + push(frame: ReturnType): void { + this.onmessage?.({ data: encodeFrame(frame) }); + } + close(): void { + this.readyState = this.CLOSED; + this.onclose?.({ code: 1000, reason: "client closing" }); + } +} + +afterEach(() => { + vi.restoreAllMocks(); + FakeWebSocket.last = null; +}); + +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); +async function waitFor(predicate: () => boolean, timeout = 2000): Promise { + const start = Date.now(); + for (;;) { + if (predicate()) return; + if (Date.now() - start > timeout) throw new Error("waitFor: timeout"); + await sleep(10); + } +} + +/** The keyframe player's wrapper — the element that carries the animated + * transform/opacity. Post-fix it is a positioned compositing box + * (`position:absolute; inset:0`) wrapping the nested target Frame. */ +function keyframeWrapper(target: HTMLElement): HTMLElement | null { + // We resolve via the live CSSOM `style` PROPERTY (not an attribute-substring + // selector): framer-motion writes the animated `transform`/`opacity` to the + // element's style object, and happy-dom does not always reflect those into + // the serialized `style` attribute. The player is the positioned `inset:0` + // box (NOT `display:contents`) that CONTAINS the nested 160px target Frame — + // that containment distinguishes it from the scene's other positioned + // wrappers. The broadcast mode also mounts an OUTER `inset:0` scene + // container that contains the player, so several boxes match : we pick the + // INNERMOST (the keyframe player nests beneath the scene container, never + // the reverse), which is the box carrying the played transform/opacity. + const candidates = Array.from(target.querySelectorAll("div")).filter( + (el) => + el.style.position === "absolute" && + (el.style.inset === "0px" || el.style.inset === "0") && + Array.from(el.querySelectorAll("div")).some((c) => c.style.width === "160px"), + ); + if (candidates.length === 0) return null; + // The innermost candidate is the one not containing any other candidate. + return ( + candidates.find((el) => !candidates.some((other) => other !== el && el.contains(other))) ?? + null + ); +} + +describe("ADR 011 I7 — keyframe wrapper composites geometry + transform", () => { + it("renders the keyframe player as a real positioned box, NOT display:contents", async () => { + const target = document.createElement("div"); + document.body.appendChild(target); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify(BUNDLE), { status: 200 })), + ); + vi.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket); + + const handle = mount({ + target, + orionUrl: "wss://gate.example/orion/api/v1/show/stream", + token: "operator-token", + mode: "broadcast", + }); + + // Wait for the keyframe player's compositing box to mount. + await waitFor(() => keyframeWrapper(target) !== null); + const wrapper = keyframeWrapper(target)!; + + // THE REGRESSION GUARD: the wrapper must NOT be a `display:contents` + // element (the dead-pixel bug). It must be a real box. + expect(wrapper.style.display).not.toBe("contents"); + expect(wrapper.style.position).toBe("absolute"); + + // The nested target frame renders BENEATH the wrapper (the wrapper is its + // containing block), keeping its authored size + fill. The target's + // geometry is composed INSIDE the animated wrapper — not at the document + // origin with a default size. + const box = + Array.from(wrapper.querySelectorAll("div")).find( + (c) => c.style.width === "160px", + ) ?? null; + expect(box, "nested 160px target must render inside the wrapper").not.toBeNull(); + expect(box!.style.width).toBe("160px"); + expect(box!.style.height).toBe("160px"); + + handle.disconnect(); + target.remove(); + }); + + it("composites the keyframe translateX + opacity onto the wrapper box (not dropped)", async () => { + const target = document.createElement("div"); + document.body.appendChild(target); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify(BUNDLE), { status: 200 })), + ); + vi.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket); + + const handle = mount({ + target, + orionUrl: "wss://gate.example/orion/api/v1/show/stream", + token: "operator-token", + mode: "broadcast", + }); + await waitFor(() => keyframeWrapper(target) !== null); + + // The play runs on mount of the keyed wrapper (the scalar leaf seeded to 0 + // by the snapshot). framer-motion's wall clock is non-deterministic under + // happy-dom mid-tween, but the SETTLED frame is deterministic: at the end + // of the 1500 ms sequence the wrapper holds the LAST keyframe waypoint + // (translateX 400 px, opacity 1). We wait for the wrapper to reach that + // settled transform — proof the keyframe `translateX` AND `opacity` BOTH + // composited onto a real box. + // + // Pre-fix this could NEVER pass: (a) `display:contents` dropped every + // animated channel, and (b) the authored `translateX` key was emitted + // verbatim to framer (which animates `x`/`y`), so the translation was + // silently ignored even on a real box. Both halves of the I7 live bug. + await waitFor(() => { + const w = keyframeWrapper(target); + return w !== null && w.style.transform.includes("translateX(400px)"); + }, 4000); + const wrapper = keyframeWrapper(target)!; + expect(wrapper.style.transform, "wrapper must carry the keyframe translateX").toContain( + "translateX(400px)", + ); + expect(wrapper.style.opacity, "wrapper must carry the keyframe opacity").toBe("1"); + // The fix preserves the nested target's authored geometry: it sits at + // (80,360) 160×160 INSIDE the translating/fading wrapper, not at the + // origin with a default size. + const box = Array.from(wrapper.querySelectorAll("div")).find( + (c) => c.style.width === "160px", + )!; + expect(box.style.transform).toContain("translateX(80px)"); + expect(box.style.transform).toContain("translateY(360px)"); + + // A leaf delta re-triggers the replay (ADR 011 §3.2 — the exec op's tick + // increments the scalar generation leaf), and the wrapper re-settles to + // the composited end-state. Proof the reactive replay path also composites. + FakeWebSocket.last?.push(delta({ seq: 2, patches: [{ path: LEAF, value: 1 as LeafValue }] })); + await waitFor(() => { + const w = keyframeWrapper(target); + return w !== null && w.style.transform.includes("translateX(400px)"); + }, 4000); + + handle.disconnect(); + target.remove(); + }); +}); diff --git a/tests/unit/wipe-cover.test.tsx b/tests/unit/wipe-cover.test.tsx index 3eb159b..bcd1b2e 100644 --- a/tests/unit/wipe-cover.test.tsx +++ b/tests/unit/wipe-cover.test.tsx @@ -177,11 +177,21 @@ afterEach(() => { const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); -/** The runtime's KeyframePlayer wraps the played subtree in a - * `display:contents` motion.div whose opacity carries the live keyframe - * value. This reads it. */ +/** The runtime's KeyframePlayer wraps the played subtree in a motion.div + * whose opacity carries the live keyframe value. Since the ADR 011 I7 + * compositing fix that wrapper is a REAL box (`position:absolute; inset:0`) + * — a `display:contents` element generated no box and silently dropped the + * animated opacity at the antenna. We read the opacity off the player box, + * identified as the positioned `inset:0` wrapper that framer-motion drives + * (it carries an `opacity` style). The live CSSOM `style` property is read + * directly (happy-dom does not always serialise it into the attribute). */ function overlayOpacity(target: HTMLElement): number | undefined { - const wrapper = target.querySelector("div[style*='display: contents']") as HTMLElement | null; + const wrapper = Array.from(target.querySelectorAll("div")).find( + (el) => + el.style.position === "absolute" && + (el.style.inset === "0px" || el.style.inset === "0") && + el.style.opacity !== "", + ); if (!wrapper) return undefined; const v = Number(wrapper.style.opacity); return Number.isNaN(v) ? undefined : v; @@ -215,8 +225,8 @@ describe("wipe-cover overlay — leaf delta replays the animation (M9 path)", () }); // The opaque cover frame is rendered (full-screen 100% box), wrapped by - // the runtime's KeyframePlayer (a `display:contents` motion.div carrying - // the live opacity). + // the runtime's KeyframePlayer — a REAL compositing box (`position: + // absolute; inset:0`, post-ADR-011-I7 fix) carrying the live opacity. await waitFor(() => overlayOpacity(target) !== undefined); // --- THE DELTA: a new scene_control value carrying the overlay. This is