diff --git a/.agents/skills/changelog-video/SKILL.md b/.agents/skills/changelog-video/SKILL.md
index 93b7ac7225..6eb6bb73fb 100644
--- a/.agents/skills/changelog-video/SKILL.md
+++ b/.agents/skills/changelog-video/SKILL.md
@@ -147,7 +147,7 @@ scaffold.
### 6 · Gates (all green before presenting)
-1. `bun run --cwd packages/cli hyperframes check --caption-zone "x0=0;y0=.90;x1=1;y1=1;severity=error;seek=.02,.06,.10,.14,.18,.22,.26,.30,.34,.38,.42,.46,.50,.54,.58,.62,.66,.70,.74,.78,.82,.86,.90,.94,.98"` (or the installed
+1. `node packages/cli/bin/hyperframes.mjs check --caption-zone "x0=0;y0=.90;x1=1;y1=1;severity=error;seek=.02,.06,.10,.14,.18,.22,.26,.30,.34,.38,.42,.46,.50,.54,.58,.62,.66,.70,.74,.78,.82,.86,.90,.94,.98"` (or the installed
`hyperframes` CLI from the repo-local `skills/hyperframes-cli/` skill) —
0 errors (contrast: dim text ≥ .66 alpha; scene content stays above the
caption rail). Do NOT reach for
diff --git a/.agents/skills/changelog-video/examples/master-skeleton.html b/.agents/skills/changelog-video/examples/master-skeleton.html
index 401b22285a..53b23add95 100644
--- a/.agents/skills/changelog-video/examples/master-skeleton.html
+++ b/.agents/skills/changelog-video/examples/master-skeleton.html
@@ -23,7 +23,11 @@
border: 1px solid rgba(255,255,255,.14); border-radius: 12px; padding: 10px 18px; }
#progress { top: 58px; right: 76px; display: flex; gap: 10px; }
#progress .dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(245,246,244,.18); }
- .slide { position: absolute; inset: 0; opacity: 0; }
+ /* The clip element itself carries NO opacity base and is never targeted by GSAP:
+ the framework owns a clip's visibility window, and `gsap_animates_clip_element`
+ is an ERROR-severity lint rule. Seams animate the inner .swrap instead. */
+ .slide { position: absolute; inset: 0; }
+ .swrap { position: absolute; inset: 0; opacity: 0; }
.sec-chip { position: absolute; top: 128px; left: 76px; font-family: 'TT Norms Mono', monospace;
font-size: 22px; letter-spacing: .05em; color: rgba(245,246,244,.72);
background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.14);
@@ -52,17 +56,23 @@
@@ -81,6 +91,15 @@
tl.to('#kicker-chip', { autoAlpha: 1, y: 0, duration: 0.5, ease: 'power3.out' }, 0.1);
// tl.set('#dot1', { backgroundColor: '#f5f6f4' }, ); …
+ // Title entry — authored HERE, never stamped (seam-stamp owns every other
+ // wrapper entry). immediateRender:false is load-bearing: seam-stamp emits a
+ // zero-duration tl.set(#w-title, autoAlpha:0) at the title's cut, and a
+ // zero-duration GSAP tween immediate-renders at BUILD time. Without the flag
+ // this fromTo captures that 0 as its start value and tweens 0 → 0, which the
+ // seam gate reports as `exit-visible … op 0.00` while exit-vector PASSES.
+ tl.fromTo('#w-title', { autoAlpha: 0 },
+ { autoAlpha: 1, duration: 0.30, ease: 'power2.out', immediateRender: false }, 0);
+
// ══ per-scene internal beats ══
// init states: gsap.set(...) at build time; motion: sequential tl.to(...) only.
// Every beat lands on a vo-words.json timestamp (local = master − scene start).
diff --git a/.agents/skills/changelog-video/references/build-spec.md b/.agents/skills/changelog-video/references/build-spec.md
index 395d6bf8dd..6a70d7a621 100644
--- a/.agents/skills/changelog-video/references/build-spec.md
+++ b/.agents/skills/changelog-video/references/build-spec.md
@@ -81,11 +81,18 @@ otherwise) and must stay flat 2D (no 3D ancestors).
## Seams + internal life (doctrine mechanics)
- `ledger.json`: every ordinary seam `cut-the-curve LEFT` (x, dir −1), exit
- and entry selectors = the slide wrappers. Outro entry `travel: 8`.
+ and entry selectors = the inner `.swrap` wrappers (`#w-*`), NEVER the `#s-*`
+ clip element — `gsap_animates_clip_element` is an error-severity lint rule and
+ every stamped seam writes `autoAlpha`. Outro entry `travel: 8`.
- `seam-stamp.mjs --ledger ledger.json --write index.html` owns ALL wrapper
entries/exits — author none yourself. Title (film open) authors its own
- entry only.
-- Slides: CSS `opacity: 0` base; `data-start` = exactly the cut time.
+ entry only, and that entry MUST pass `immediateRender: false`: the stamped
+ zero-duration `tl.set(autoAlpha: 0)` at the title's cut renders at BUILD time,
+ so an entry without the flag captures 0 as its start and tweens 0 → 0. The
+ symptom is `exit-visible … op 0.00` with `exit-vector` PASSING — moving but
+ invisible. Neither a CSS opacity base nor a longer clip window fixes it.
+- Slides: the `.swrap` carries the CSS `opacity: 0` base, not the `.slide` clip
+ element; `data-start` = exactly the cut time.
- Each scene's shell (chip, headline, mock chrome, initial state) is
COMPOSED at local t=0 — the wrapper flies it in. Internal reveals start
≥0.4s after the cut and end ≥0.45s before the next cut (stamped exits
diff --git a/.agents/skills/motion-doctrine/scripts/seam-gate.mjs b/.agents/skills/motion-doctrine/scripts/seam-gate.mjs
index 569c91bbbd..16893a18a7 100644
--- a/.agents/skills/motion-doctrine/scripts/seam-gate.mjs
+++ b/.agents/skills/motion-doctrine/scripts/seam-gate.mjs
@@ -73,9 +73,27 @@ async function ensureServer() {
delete env.HYPERFRAME_RUNTIME_URL; // wrong value fails silently as 200 HTML
// `preview` backgrounds itself when stdin/stdout aren't TTYs, which they never are here: the
// launcher would exit 0 before the server is up and detach it out of our process group.
+ // Default to the REPO-LOCAL CLI whenever this skill is running from its repo
+ // checkout. The gate this script backs is defined against that build, and the
+ // skill forbids `npx hyperframes@latest` for it, so defaulting to the published
+ // package here would let the two gates measure different runtimes. npx stays the
+ // fallback for a copy of the skill living outside the repo.
+ const repoCli = join(
+ import.meta.dirname,
+ "..",
+ "..",
+ "..",
+ "..",
+ "packages",
+ "cli",
+ "bin",
+ "hyperframes.mjs",
+ );
const cmd = flag(
"server-cmd",
- `npx --yes hyperframes preview --foreground --no-open --port ${port}`,
+ existsSync(repoCli)
+ ? `node "${repoCli}" preview --foreground --no-open --port ${port}`
+ : `npx --yes hyperframes preview --foreground --no-open --port ${port}`,
);
const child = spawn("sh", ["-c", cmd.replace(/\{port\}/g, String(port))], {
cwd: project,
diff --git a/.claude/skills/changelog-video/SKILL.md b/.claude/skills/changelog-video/SKILL.md
index 93b7ac7225..6eb6bb73fb 100644
--- a/.claude/skills/changelog-video/SKILL.md
+++ b/.claude/skills/changelog-video/SKILL.md
@@ -147,7 +147,7 @@ scaffold.
### 6 · Gates (all green before presenting)
-1. `bun run --cwd packages/cli hyperframes check --caption-zone "x0=0;y0=.90;x1=1;y1=1;severity=error;seek=.02,.06,.10,.14,.18,.22,.26,.30,.34,.38,.42,.46,.50,.54,.58,.62,.66,.70,.74,.78,.82,.86,.90,.94,.98"` (or the installed
+1. `node packages/cli/bin/hyperframes.mjs check --caption-zone "x0=0;y0=.90;x1=1;y1=1;severity=error;seek=.02,.06,.10,.14,.18,.22,.26,.30,.34,.38,.42,.46,.50,.54,.58,.62,.66,.70,.74,.78,.82,.86,.90,.94,.98"` (or the installed
`hyperframes` CLI from the repo-local `skills/hyperframes-cli/` skill) —
0 errors (contrast: dim text ≥ .66 alpha; scene content stays above the
caption rail). Do NOT reach for
diff --git a/.claude/skills/changelog-video/examples/master-skeleton.html b/.claude/skills/changelog-video/examples/master-skeleton.html
index 401b22285a..53b23add95 100644
--- a/.claude/skills/changelog-video/examples/master-skeleton.html
+++ b/.claude/skills/changelog-video/examples/master-skeleton.html
@@ -23,7 +23,11 @@
border: 1px solid rgba(255,255,255,.14); border-radius: 12px; padding: 10px 18px; }
#progress { top: 58px; right: 76px; display: flex; gap: 10px; }
#progress .dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(245,246,244,.18); }
- .slide { position: absolute; inset: 0; opacity: 0; }
+ /* The clip element itself carries NO opacity base and is never targeted by GSAP:
+ the framework owns a clip's visibility window, and `gsap_animates_clip_element`
+ is an ERROR-severity lint rule. Seams animate the inner .swrap instead. */
+ .slide { position: absolute; inset: 0; }
+ .swrap { position: absolute; inset: 0; opacity: 0; }
.sec-chip { position: absolute; top: 128px; left: 76px; font-family: 'TT Norms Mono', monospace;
font-size: 22px; letter-spacing: .05em; color: rgba(245,246,244,.72);
background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.14);
@@ -52,17 +56,23 @@
@@ -81,6 +91,15 @@
tl.to('#kicker-chip', { autoAlpha: 1, y: 0, duration: 0.5, ease: 'power3.out' }, 0.1);
// tl.set('#dot1', { backgroundColor: '#f5f6f4' }, ); …
+ // Title entry — authored HERE, never stamped (seam-stamp owns every other
+ // wrapper entry). immediateRender:false is load-bearing: seam-stamp emits a
+ // zero-duration tl.set(#w-title, autoAlpha:0) at the title's cut, and a
+ // zero-duration GSAP tween immediate-renders at BUILD time. Without the flag
+ // this fromTo captures that 0 as its start value and tweens 0 → 0, which the
+ // seam gate reports as `exit-visible … op 0.00` while exit-vector PASSES.
+ tl.fromTo('#w-title', { autoAlpha: 0 },
+ { autoAlpha: 1, duration: 0.30, ease: 'power2.out', immediateRender: false }, 0);
+
// ══ per-scene internal beats ══
// init states: gsap.set(...) at build time; motion: sequential tl.to(...) only.
// Every beat lands on a vo-words.json timestamp (local = master − scene start).
diff --git a/.claude/skills/changelog-video/references/build-spec.md b/.claude/skills/changelog-video/references/build-spec.md
index 395d6bf8dd..6a70d7a621 100644
--- a/.claude/skills/changelog-video/references/build-spec.md
+++ b/.claude/skills/changelog-video/references/build-spec.md
@@ -81,11 +81,18 @@ otherwise) and must stay flat 2D (no 3D ancestors).
## Seams + internal life (doctrine mechanics)
- `ledger.json`: every ordinary seam `cut-the-curve LEFT` (x, dir −1), exit
- and entry selectors = the slide wrappers. Outro entry `travel: 8`.
+ and entry selectors = the inner `.swrap` wrappers (`#w-*`), NEVER the `#s-*`
+ clip element — `gsap_animates_clip_element` is an error-severity lint rule and
+ every stamped seam writes `autoAlpha`. Outro entry `travel: 8`.
- `seam-stamp.mjs --ledger ledger.json --write index.html` owns ALL wrapper
entries/exits — author none yourself. Title (film open) authors its own
- entry only.
-- Slides: CSS `opacity: 0` base; `data-start` = exactly the cut time.
+ entry only, and that entry MUST pass `immediateRender: false`: the stamped
+ zero-duration `tl.set(autoAlpha: 0)` at the title's cut renders at BUILD time,
+ so an entry without the flag captures 0 as its start and tweens 0 → 0. The
+ symptom is `exit-visible … op 0.00` with `exit-vector` PASSING — moving but
+ invisible. Neither a CSS opacity base nor a longer clip window fixes it.
+- Slides: the `.swrap` carries the CSS `opacity: 0` base, not the `.slide` clip
+ element; `data-start` = exactly the cut time.
- Each scene's shell (chip, headline, mock chrome, initial state) is
COMPOSED at local t=0 — the wrapper flies it in. Internal reveals start
≥0.4s after the cut and end ≥0.45s before the next cut (stamped exits
diff --git a/.claude/skills/motion-doctrine/scripts/seam-gate.mjs b/.claude/skills/motion-doctrine/scripts/seam-gate.mjs
index 569c91bbbd..16893a18a7 100644
--- a/.claude/skills/motion-doctrine/scripts/seam-gate.mjs
+++ b/.claude/skills/motion-doctrine/scripts/seam-gate.mjs
@@ -73,9 +73,27 @@ async function ensureServer() {
delete env.HYPERFRAME_RUNTIME_URL; // wrong value fails silently as 200 HTML
// `preview` backgrounds itself when stdin/stdout aren't TTYs, which they never are here: the
// launcher would exit 0 before the server is up and detach it out of our process group.
+ // Default to the REPO-LOCAL CLI whenever this skill is running from its repo
+ // checkout. The gate this script backs is defined against that build, and the
+ // skill forbids `npx hyperframes@latest` for it, so defaulting to the published
+ // package here would let the two gates measure different runtimes. npx stays the
+ // fallback for a copy of the skill living outside the repo.
+ const repoCli = join(
+ import.meta.dirname,
+ "..",
+ "..",
+ "..",
+ "..",
+ "packages",
+ "cli",
+ "bin",
+ "hyperframes.mjs",
+ );
const cmd = flag(
"server-cmd",
- `npx --yes hyperframes preview --foreground --no-open --port ${port}`,
+ existsSync(repoCli)
+ ? `node "${repoCli}" preview --foreground --no-open --port ${port}`
+ : `npx --yes hyperframes preview --foreground --no-open --port ${port}`,
);
const child = spawn("sh", ["-c", cmd.replace(/\{port\}/g, String(port))], {
cwd: project,