Fourth PR of the template-variables Studio stack — the HTTP plumbing.
- preview routes (/preview and /preview/comp/*) accept
?variables=<url-encoded json> and inject
`window.__hfVariables = {...}` into <head>, before the runtime and any
composition script — the exact global the engine sets via
evaluateOnNewDocument at render time, so preview-with-values cannot
diverge from render output. Values are escaped against </script>
breakout, malformed payloads 400 instead of silently previewing
defaults, and the ETag is salted with a hash of the payload so cached
previews revalidate when values change.
- POST /projects/:id/render accepts variables ({variableId: value}) and
forwards them through StudioApiAdapter.startRender into the producer's
RenderConfig.variables — the same channel `hyperframes render
--variables` uses. Wired in both adapters (CLI embedded server + vite
dev adapter).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow-up to the previous commit in this PR — found while auditing whether
any SDK-surface documentation gaps existed beyond this stack:
- types.mdx: EditOp's own union listing was missing declareVariable/
removeVariable (present in edit-operations.mdx's table but not mirrored
here). Adds a full CompositionVariable reference section (base fields +
all 7 variants) since composition.mdx's new declareVariable/listVariables
docs reference it without it being defined anywhere in the type reference.
- utilities.mdx: documents 6 exported functions with zero prior docs —
resolveScoped, findById, bareId, escapeHfId, isNewHostBoundary (Id & Scope
Utilities) and readVariableDefault (Variable Utilities). Pre-existing gaps,
unrelated to this stack.
- canvas-integration.mdx: adds a "Keeping the preview in sync" section
covering attachSync right where the guide already sets up preview + comp —
previously the guide never mentioned it despite being exactly the answer
to "how do I keep the iframe in sync with edits."
- Script-mirror filter changed from an exact "/script/gsap" match to
path.startsWith("/script/") — the documented contract is "never mirror
script-tag rewrites," not just today's one known path; startsWith covers
any future script-kind patch under the same intent.
- _syncDetach is now cleared when the caller invokes the returned detach
function directly, not only on the next attachSync call — avoids holding
a stale (already-unsubscribed) reference between an explicit detach() and
a later attachSync(other).
- The initial applyOverrideSet call is now wrapped in try/catch: a bad
initial snapshot no longer prevents the ongoing patch subscription from
attaching, matching the SDK's existing swallow-and-warn precedent for
silent-failure paths (adapters/iframe.ts's tainted-canvas warning).
- Added a test proving declareVariable/removeVariable (the /variable-decls/
patches PR #2098 introduces) mirror onto the live document's
data-composition-variables attribute — the existing suite only covered
setVariableValue's CSS-custom-property path, not the schema-metadata path.
These SDK reference docs were behind the API surface: PR #2100's attachSync
had zero documentation, and PR #2098/#2092's declareVariable, removeVariable,
getVariableValue, listVariables, and getRootElements were all missing from
composition.mdx despite being real public Composition methods. getAllAnimationIds
was also undocumented (pre-existing gap, unrelated to this stack).
- composition.mdx: adds getVariableValue, listVariables, declareVariable,
removeVariable (Typed edit methods), getRootElements, getAllAnimationIds
(Query section)
- adapters.mdx: adds attachSync to the PreviewAdapter interface + a
ParamField documenting its contract (immediate sync, ongoing patch
mirroring, script-patch exclusion, detach semantics)
- edit-operations.mdx: adds declareVariable/removeVariable rows + examples
to the Variables op table
setVariableValue is the headline case the sync spec was built for and
had no coverage; setTiming (data-start/data-end mirroring) was also
untested. Both regression-checked by temporarily breaking the
underlying mutate/apply-patches code paths and confirming the new
assertions fail.
PlaygroundPreview implements PreviewAdapter but was missing attachSync,
which this branch added to the interface — a real TS break (no
typecheck script wires sdk-playground into CI, so nothing caught it).
Mirrors the same no-op stub already added to HeadlessPreviewAdapter.
Adds attachSync(comp) to PreviewAdapter/IframePreviewAdapter — does an
immediate full sync via the existing applyOverrideSet, then subscribes to
comp.on('patch', ...) and replays every future patch (forward or inverse —
undo/redo included) via the existing applyPatchesToDocument, pointed at the
iframe's live document instead of the offscreen linkedom one. No new
mutation logic; both functions already work against any
{document, wrapped, stamped}-shaped object.
Also adds a no-op attachSync stub to HeadlessPreviewAdapter, required to
keep it satisfying the widened PreviewAdapter interface.
Closes the gap that made pacific's canvas-react hand-roll its own
override-application code (applyOverrideToIframe.ts) with two separate
mechanisms (diffing for normal edits, verbatim op-replay for undo/redo) —
subscribing to the patch stream directly needs only one.
- validateOp now handles declareVariable/removeVariable (E_NO_ROOT when no
composition root), matching setVariableValue's existing case — previously
comp.can() returned E_UNKNOWN_OP for both.
- removeVariable's undo-inverse now tags its {decl, index} reinsert payload
with __kind: "reinsert" instead of relying on structural "decl"/"index"
key presence to disambiguate it from a plain declareVariable patch.
VariableDecl has an open index signature, so a real variable schema could
legally declare its own "decl"/"index" fields and be misinterpreted by the
old structural check; a regression test pins the exact collision.
- getVariableValue's return type tightened from `unknown` to
`string | number | boolean | FontValue | ImageValue | undefined`, matching
setVariableValue's parameter type for round-trip symmetry. The underlying
unknown-typed read is cast once at this SDK boundary.
- Added a redo test for declareVariable/removeVariable (existing tests only
covered undo).
Closes the remaining Tier 2/3 gaps from the SDK surface audit that motivated
#2092 — real, contained fixes short of the two genuinely architectural items
(a live-DOM apply adapter, structural editing ops) that need their own design
pass, not a quick patch.
Variable CRUD was write-only and creation-blocked: setVariableValue existed,
but there was no getVariableValue, listVariables, declareVariable, or
removeVariable — and writeVariableDefault intentionally refuses to create an
undeclared variable ("keep the schema authoritative"), so a variables panel
(list what exists, read current values, let someone add one) could not be
built against the SDK at all.
- getVariableValue(id) / listVariables(): thin reads over the existing
readVariableDefault / a new listVariableDecls.
- declareVariable(decl) / removeVariable(id): new EditOps with full
undo/redo support via a new patch path (/variable-decls/{id}, distinct
from /variables/{id} which is default-only) — removeVariable's inverse
bundles the original array index so undo reinserts at the same position
instead of appending, mirroring handleRemoveElement's siblingIndex.
Export gaps (same shape as #2092's fixes — the logic already existed,
just wasn't reachable): resolveScoped, findById, escapeHfId from
engine/model.ts; readVariableDefault from engine/variableModel.ts.
17 new tests across mutate.test.ts (declareVariable/removeVariable engine
semantics + undo), session.test.ts (Composition-level API), and smoke.test.ts
(export-surface import check). 439/439 sdk tests passing. Full workspace
build (incl. studio) verified clean.
Documents the shared-pattern context (3rd copy of "resolve relative
data-start", after runtime startResolver.ts and the SDK's own
getElementTimings) and explains when the raw parseFloat fallback in
resolveStart's else branch can actually fire (a malformed grammar string
with a leading number). Adds a test pinning the "reference target exists
but its own timing is unresolvable" branch, which existing tests didn't
cover (only "target doesn't exist" was tested).
Cross-checked the negative-offset clamp concern raised in review: the
SDK's own resolveReferenceStart (session.ts) also clamps to
Math.max(0, ...), so this stays consistent with its sibling — no code
change needed there.
Same bug class as the SDK's getElementTimings fix (#2092): data-start can be a
relative-reference expression ("intro", "intro + 2"), not just an absolute
number. The old code did a raw parseFloat on it, so any reference silently
resolved to undefined instead of an actual time.
Also: this function never read data-duration at all (only data-start/data-end
literally), so a reference to a duration-authored (not end-authored) clip was
unresolvable regardless of the parseFloat bug — resolving a reference needs
the target's END, which for a duration-authored clip requires start+duration.
Both fixed together via the shared parseStartExpression grammar parser
(@hyperframes/core/runtime/start-expression), with the same cycle-guard
pattern as the SDK fix. Reference resolution against other elements is scoped
to this file's existing findById (bare data-hf-id lookup).
6 new tests: duration-based end resolution, relative reference (with and
without offset), missing target, and a mutual-cycle termination check.
## What
`applyPositionEdits(doc)` in `@hyperframes/core/runtime/position-edits` guarded each candidate element with `instanceof HTMLElement`. `doc` is frequently an iframe's document (the SDK's edit preview, any host embedding a composition), whose elements are `HTMLElement` instances of *that frame's realm* — never this module's. The check silently no-ops on every single element cross-realm, so bulk position edits never apply inside an iframe.
## Why
Found during an audit of `@hyperframes/sdk`'s surface against pacific's movio integration. Pacific's `canvas-react` code has an explicit workaround comment for this exact bug: *"Upstream fix would be duck-typing in `@hyperframes/core` — until then, all host code must use this wrapper."* Every iframe-hosted consumer has had to reimplement the bulk-apply loop themselves to avoid it.
## How
Use the document's own realm's `HTMLElement` constructor (`doc.defaultView?.HTMLElement`) instead of the module-scope global. Duck-type on `.style` when `defaultView` is unavailable (a detached/synthetic document). The single-element `applyPositionEditToElement` was already realm-safe — only the bulk wrapper had the bug.
## Test plan
- [x] New regression test using a real jsdom iframe — confirmed it fails on the old `instanceof HTMLElement` check (0 applied, expected 1) and passes with the fix
- [x] Full existing `positionEdits.test.ts` suite passes (14/14)
- [x] Full `@hyperframes/core` suite passes (81 files / 1131 tests)
- [x] `bun run build` clean (core + full workspace, incl. studio)
A figma text node whose box is shorter than its line-height carries
vertically-trimmed (cap-to-baseline) bounds. The mapper positioned the box
at those bounds but let the browser lay glyphs with half-leading, pushing
them ~6px low on a 70px font (glyph-centroid measurement against figma's
own render: +9.1px vs figma's +3.4px inside the same pill). Emitting
text-box-trim: trim-both / text-box-edge: cap alphabetic reproduces the
trim in the render engine; post-fix centroid agrees within 0.4px and the
motion verifier's min window score improved 20.3 -> 25.3dB. Trim applies
only to single-line trimmed text; boxes matching their line-height are
untouched.
Skill: component imports now include a static fidelity self-check step
against figma's PNG export of the same node.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(cli): bump @puppeteer/browsers to ^3.0.6 to fix render hang on node >=24.16
`hyperframes render` (and `browser ensure --force`) hangs forever during
Chrome provisioning on Node >= 24.16 (repro'd on macOS arm64 / Node 26.5.0;
fine on Node 22). Root cause is a transitive extractor bug, not our logic:
@puppeteer/browsers@2.13.x install()
-> extract-zip@2.0.1 -> yauzl@2.10.0
A classic-stream backpressure regression (nodejs/node#63487, works 24.15,
breaks 24.16+) surfaces a latent fd-slicer destroy() bug in yauzl 2.x
(yauzl#169). The inflate read stream stalls partway through the first entry
large enough to cross the write highWaterMark (chrome-headless-shell's
1.86MB LICENSE.headless_shell, stalls at ~1.31MB), never emits `end`, so
stream.pipeline never settles and extraction busy-spins. The half-extracted
cache has no executable, so every later render re-enters
"Cached binary missing -> re-download" and hangs again (puppeteer#14957).
Fix: @puppeteer/browsers 3.0.2 dropped extract-zip/yauzl entirely (now uses
modern-tar). Verified 3.0.6 extracts chrome-headless-shell cleanly under
Node 26.5.0 and keeps the full API manager.ts uses (install,
getInstalledBrowsers, Cache, computeExecutablePath, detectBrowserPlatform,
Browser) with an identical on-disk cache layout. Cross-platform (the same
.zip/yauzl path affected Linux + Windows too).
Adds a regression guard asserting the pin stays on the extractor-free
major (>= 3) and never reintroduces extract-zip/yauzl.
Fixes#2103
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(cli): clarify extractor-guard wording — yauzl is an optional peer, not dropped entirely
Review note on #2104: @puppeteer/browsers 3.0.6 keeps yauzl as an optional
peer fallback (default extractor is modern-tar), so the regression-guard
comment + it-text shouldn't say it was 'dropped entirely'. Test assertions
(extract-zip + yauzl absent from `dependencies`) unchanged and correct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A base `gsap.set(...)` written AFTER the tween calls is wiped on the next
soft reload: when a `from()` tween on the same target lazily initializes
during a backwards render (the studio rebind's progress(0.0001) kick), GSAP
reverts its internal isFromStart set, which removes the whole inline
`transform` — taking the base set's x/y with it. The from() tween then
re-parses the computed transform as identity and bakes x/y = 0 into the
GSAP cache, so every element previously moved in the studio snaps back to
its authored position whenever any other element is edited.
Emitting the global set BEFORE the timeline construction makes it part of
the pre-tween state the from() records, so every revert restores the moved
pose instead of stripping it.
- addAnimationToScript: global sets insert above the timeline declaration;
the new-id lookup now diffs content-based ids instead of assuming the
appended statement is last in source order.
- updateAnimationInScript: a legacy trailing global set is relocated above
the declaration whenever it's touched, healing files written before this
change on the next nudge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
HF_DE_PARALLEL_STREAM was restored on every exit path, but the producer
server allows concurrent renders in one process — a router-eligible job's
mutation was still visible to an unrelated render already executing during
that window. Thread the router's decision as a per-render local instead of
a global env var; HF_DE_PARALLEL_STREAM stays as the manual opt-in.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- motionContextToDocs: escape regex metacharacters in arrayAfterKey /
scalarAfterKey key interpolation (safe today for \\w+ keys; now safe for
any future caller), and document balancedBlock's no-strings invariant.
- verify-motion.mjs: execSync shell string -> spawnSync with array args
(JSON.stringify is not shell escaping); verifier re-calibrated unchanged
(faithful render still PASS at min 20.30dB).
- command-failure-tracking: rebase folded the group-delegation skip into
upstream's recursive wrapCommand (HF#2033) — leaf commands now assert
their own flag tables, so `figma component --namee` is rejected at the
leaf while `--name` passes the group; heuristic invariant documented.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two guarantees so figma-motion imports can't drift from the design again:
- motionContextToDocs(): raw get_motion_context response -> MotionDoc[],
in code. Parses the motion.dev snippets (the reliable encoding; the CSS
snippets stretch durations and can disagree), strips loop-wrap tail
keyframes (sub-ms segments at the window end are the loop reset, not
authored motion), preserves bezier eases verbatim. Fixture test uses the
verbatim response from a real Motion timeline whose translation was
frame-validated against Figma's own export_video render.
- skills/figma/scripts/verify-motion.mjs: mandatory post-render gate.
Compares motion-energy deltas between the render and the export_video
ground truth so static import fidelity cancels out and the score
isolates choreography. Calibrated on a faithful translation (min 20.3dB)
vs a diverging one (min 5.0dB); threshold 15dB.
The skill's Motion step now routes through both: no hand transcription,
no unverified completion.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field lesson from translating a real Motion timeline: the two returned
encodings window durations differently, and keyframes at times ~0.9999
are loop-wrap resets, not authored motion. Hand-normalizing across
encodings and inventing visible returns produced a render that diverged
from Figma. The skill now mandates verbatim single-encoding translation,
wrap-via-repeat, and a frame-grid comparison against export_video ground
truth before completion.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
slugify("3D Object - Headphones") produced id="3d-object-headphones" —
valid HTML, but querySelector("#3d-…") throws (CSS idents cannot start
with a digit), which kills GSAP targeting and figma-motion translation
against imported components. uniqueSlug now prefixes digit-leading slugs
("n3d-object-headphones"). Found translating a real Figma Motion timeline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both found running the brand-loop guide end-to-end against the Simple
Design System:
- nodeToHtml subtracted the ROOT origin from every node's absolute bounds,
but CSS absolute positioning resolves against the nearest positioned
ancestor — every nesting level re-added its ancestors' offsets, drifting
nested content down-right and pushing deep children off-frame (hero
buttons invisible, pricing grid collapsed to one card). Children now
subtract their PARENT's box; regression test with a two-level tree.
- trackCommandFailures asserted unknown flags against the command group's
own (flagless) arg table even when the group was delegating to a
subcommand, so `figma component <ref> --name x` imported and THEN threw
"Unknown flag: --name". The assertion is now skipped when the first
positional names a subcommand; leaf and non-delegating behavior is
unchanged and covered by tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
applyPositionEdits(doc) guarded each element with `instanceof HTMLElement` —
but `doc` is frequently an iframe's document (the SDK's edit preview, any host
embedding a composition), whose elements are HTMLElement instances of THAT
frame's realm, never this module's. The check silently no-ops on every single
element cross-realm, so bulk position edits never apply inside an iframe.
Use the document's own realm's HTMLElement constructor (doc.defaultView);
duck-type on `.style` when defaultView is unavailable (a detached/synthetic
document). The single-element applyPositionEditToElement was already
realm-safe — only the bulk wrapper had the bug.
Added a regression test using a real jsdom iframe, confirmed it fails on the
old `instanceof HTMLElement` check and passes with the fix.
Address unanimous review feedback on PR #2095 (Miga, Magi REQUEST_CHANGES,
Rames D Jusso): the router's process.env.HF_DE_PARALLEL_STREAM mutation
was only cleared on the DE self-verify-retry branch — every other exit
(happy path, any non-DE-verify error, abort) left it set, leaking the
parallel-streaming opt-in into the next render sharing the same process
(a regression/benchmark harness, or any batch host).
Capture the prior value before mutating and restore it in the outer
`finally` (executeRenderJob's own top-level try/finally, which runs on
every exit path by construction), not just the narrow retry branch. Note
`deParallelRouter` and the new `deParallelStreamEnvBefore` had to move
above the outer `try` — a `let` declared inside `try {}` is not visible
in the sibling `finally {}` block in JS, so the original placement
alongside the other DE state would not have compiled once referenced
from the finally.
Also: renamed the shared `preInversionWorkerCount` local to
`preRoutingWorkerCount` (Miga + Rames nit — it now serves both the
inversion and the router), and pinned worker count 3 explicitly overrides
calibration by design (documented per Miga/Rames's question, not a bug).
Verified end-to-end (not just unit tests): ran two executeRenderJob calls
back-to-back in one process, router-eligible then not — env is restored
to undefined after render 1 and stays clean through render 2, the exact
leak scenario the reviews described. 117 orchestrator tests pass (3 new,
covering the restoreEnv primitive directly).
Blocker (flagged by all three reviewers, still open after the CI fix):
- Composition.serialize() interface in types.ts never got the { stripRuntime? }
param the implementation already accepts, so a consumer holding a
Composition-typed ref (exactly pacific's case) got a strict-TS arity error
calling comp.serialize({ stripRuntime: true }). Widened the interface.
Also addresses:
- getRootElements() now cached like elementsCache (same 3 invalidation sites) —
cheap insurance if a layer panel calls it every render tick.
- getElementTimings' resolver now uses the already-parsed expr.value for the
absolute-number case instead of silently re-parsing via parseFloat, via a
small resolveReferenceStart helper split out to keep resolveStart's own
branching low.
- bareId's `?? scopedId` fallback gets a comment: it's unreachable at runtime
(split() always returns >=1 element) but required by noUncheckedIndexedAccess.
- serialize({ stripRuntime }) docblock generalized past "the editing iframe" —
it's for any host driving its own clock.
- Documented (and pinned with a test) the bare-id reference resolution's
cross-scope behavior: a sub-composition element referencing a colliding bare
id resolves to the canonical top-level match, same as the runtime's own
resolver — consistent, but a real authoring footgun worth calling out.
- New tests: chained (A->B->C) references, a direct self-reference cycle, a
mutual A<->B cycle, the cross-scope bare-id collision above, and an import
assertion that RUNTIME_BOOTSTRAP_ATTR is actually reachable from
@hyperframes/core and matches the marker generators stamp.
422/422 sdk tests passing (417 + 5 new). Full workspace build (incl. studio)
verified clean.
* fix(cli): map zh to espeak-ng's cmn for Kokoro TTS synthesis
espeak-ng 1.52.0 recognizes Mandarin Chinese as the ISO 639-3 code "cmn",
not Kokoro's own voice-prefix convention "zh". `hyperframes tts --lang zh`
was forwarding "zh" straight through to kokoro_onnx.Kokoro.create(), which
failed with "language zh is not supported by espeak backend". Translate
only at the Python/espeak boundary; the public --lang value stays "zh"
since that matches Kokoro's own docs and voice-ID prefixes.
* test(cli): cover Kokoro zh language override
Promotes the opt-in HF_DE_PARALLEL_STREAM mechanism (#2056) into the
auto-routing decision, gated behind its own default-off flag
(HF_DE_PARALLEL_ROUTER). This is the next step from the 2026-07-08
parallel-DE benchmark verdict: par3/single 1.16-1.36x on real-work
comps >=2,000 frames, no comp anywhere losing to single-worker.
shouldPreferParallelDrawElement mirrors shouldPreferSingleWorkerDrawElement
(#2026) but takes priority over it when both are eligible — its higher
default threshold (HF_DE_PARALLEL_MIN_FRAMES=2000 vs the inversion's 900)
means it only ever picks up the long tail the inversion's own benchmark
didn't cover. Fixed at 3 workers (benchmark-validated; not calibration-
derived), same shape as the inversion pinning to a fixed 1.
resolveParallelRouterRetryPlan mirrors resolveInversionRetryPlan for the
self-verify-failure rollback path: falls back to the ordinary (non-DE)
parallel-disk path at the pre-router worker count. The caller must clear
HF_DE_PARALLEL_STREAM before recomputing useStreamingEncode or the retry
would keep resolving to the parallel-streaming shape.
New telemetry (de_parallel_router, de_pre_router_workers) tags which
render used the router, separate from de_worker_inversion — needed
before the planned telemetry soak can segment revert-rate and
de_verify_min_db to the parallel cohort specifically; today there's no
way to tell those apart from ordinary single-worker DE renders.
Verified end-to-end: HF_DE_PARALLEL_ROUTER=true on a 2,381-frame comp
resolves to 3 workers with 3 separate drawElement sessions and renders
successfully; without the flag, behavior is unchanged (falls through to
the existing single-worker inversion, workerCount=1) — no regression to
current production routing. 114 orchestrator tests pass (15 new).
Closes gaps surfaced by pacific#30298 (hyperframes layer panel), where consumer
code had to hand-roll fixes for things the SDK/core already solve or nearly solve:
- getRootElements(): getElements() flattens the tree, so every descendant also
appears as its own top-level entry. buildRoots() already computes true roots
internally; this exposes it directly instead of making consumers re-derive
roots by filtering out descendant ids.
- Export isNewHostBoundary + bareId from @hyperframes/sdk: both already existed
internally (engine/model.ts) but weren't exported, so consumers were
duplicating sub-composition-boundary detection and scoped-id-to-DOM-leaf
conversion by hand.
- Export stripEmbeddedRuntimeScripts + RUNTIME_BOOTSTRAP_ATTR from
@hyperframes/core, and wire serialize({ stripRuntime: true }) on the SDK
session: a proper tokenizing implementation already existed in
compiler/htmlDocument.ts (handles more runtime-script marker variants than a
naive regex), just never exported. The SDK itself imports these via narrow
subpaths (./runtime/start-expression, ./compiler/html-document) rather than
the wide ./compiler barrel, matching the SDK's existing import convention and
avoiding pulling Node-only compiler code (fs/path) into browser bundles.
- Fix getElementTimings(): data-start can be a relative-reference expression
("intro", "intro + 2" — see parseStartExpression's grammar), not just an
absolute number. The old code did a raw parseFloat() on it, which silently
resolved any reference expression to 0. Now resolves references recursively
against the target element's own resolved start + duration, Node-safe (no
live GSAP timeline needed for this case).
14 new tests (session.timings.test.ts, session.subcomp.test.ts). Full sdk
suite: 417/417 passing. Full workspace build (incl. studio) verified clean.
Address PR #2093 review feedback (Miga, Rames D Jusso):
- The walker treated a repeating nested timeline (total > single) as an
opaque interval and never descended into it, so a tl.call() living
inside one would slip past hasTimelineCall detection entirely — the
"any tl.call() disqualifies" claim wasn't quite literal. Now recurses
for detection purposes even when the span is already opaque; the
parent-level interval still dominates for frame-animated-marking, so
this only widens what counts as "has a call()," never narrows the
existing interval coverage.
- Restored the totalDuration() vs duration() rationale comment that got
dropped when the tl.call() detection comment was added above it.
Real bug report: a mono count span driven by a GSAP tl.call() (a counter
going "0 sur 0" -> "1 sur 1" at a later beat) rendered the LATER value
baked in from frame 0 of an EARLIER, unrelated static-hold span, despite
the dedup log reporting "verified".
Root cause: computeStaticFrameSet's tween walker only tracks property
tweens, so a call()-driven textContent mutation carries no tracked
interval and the span around it looks fully static. verifyStaticFramesSafe
does catch genuine drift WITHIN a run it's checking, but a call() is a
one-shot side effect wired as both onComplete and onReverseComplete (GSAP
has no separate "undo" — crossing it in either direction fires the SAME
forward mutation). Verifying a LATER run forward-seeks past the call(),
permanently mutating the live page; an EARLIER run already passed its own
check before that happened, so nothing re-verifies it afterward. Real
capture then starts on the same corrupted page and bakes the wrong value
into the earlier span's reused buffer.
No reliable way to tell a DOM-mutating call() from a harmless one
(analytics ping, class toggle) without executing it, so this disqualifies
the whole comp on ANY call() — conservative, costs some dedup perf on
comps that use call() harmlessly, but correctness over speed.
The storyboard view's empty state was a single "no storyboard yet" line. Add a
copy-to-clipboard prompt box (a ready-to-paste handoff prompt carrying the
canonical STORYBOARD.md frontmatter + per-frame format) so users can hand it to
their coding agent, plus a faded skeleton of the contact-sheet grid so landing
on an empty board previews what a filled one looks like instead of a dead end.
Crop is now part of the element selection instead of a separate mode. Selecting
a croppable element shows edge handles just outside each side and, once cropped,
the full content with the cropped-away area dimmed plus a center reposition
handle to pan the crop window. Dragging the body moves the element, edge handles
crop, the center handle pans; corners stay free for the resize handle. Removes
the crop-mode toggle (toolbar + property-panel buttons), the cropMode/
cropAvailable player-store state, and the double-click-to-crop gesture. The
clip-path inset model is unchanged.