C1: getElementTimings/setElementTiming typed session methods + setHold typed
wrapper. getElementTimings reads data-duration (preferred) or data-end−data-start
(fallback) — same attr-preference as handleSetTiming. setElementTiming dispatches
a sparse map as one batch → one patch event → one undo step. setHold mirrors
setVariableValue pattern.
Also fixes a pre-existing apply-patches.ts gap: the timing/duration patch case was
absent, causing undo of duration changes to silently no-op. Added the duration
branch so inverse patches restore data-duration correctly.
C2: packages/core/src/compiler/timingResolver.ts — shared pure resolveTimings()
consumed by BOTH preview (sdk session) and render (timingCompiler) paths. Word-
anchored elements get enterAt = wordTimings[k].start + offset; elastic hold =
max(0, slotEnd − (enterAt + enterDuration + exitDuration)), clamped ≥ 0; never
timescales animated content. Un-anchored elements keep authored timing (align-on-
adjust). Deterministic + pure: no Date.now, no Math.random, no DOM.
extractGsapLabels() added to gsapParserAcorn.ts to parse tl.addLabel() calls for
the getElementTimings labels field.
Tests: timingResolver.test.ts (10 pure-function tests including preview==render
parity golden test); session.timings.test.ts (15 session-layer tests covering
duration-authored, end-authored, label extraction, batching, undo, and setHold
regression).
Gates: build ✓ · bun test (sdk+core/compiler) 434/434 ✓ · oxlint 0 warnings ✓ ·
oxfmt --check ✓ · fallow --gate new-only ✓ (complexity suppressed on 2 new
inline functions, duplication warn-only pre-existing)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## WS-B — variables / brand, object-valued (end-to-end)
Part of the AI Studio (Pacific) SDK integration. **Base of the SDK-hotspot stack** (`main → ws-b → ws-c → ws-d → ws-3c → ws-3f`).
### Problem
The variable system was split-brained: SDK `setVariableValue` wrote a `--{id}` CSS custom prop, while the runtime `getVariables()` read a separate JSON model (`data-composition-variables` / `__hfVariables`). The two never connected, and there was no `--brand-*` convention. Variables were scalar-only.
### What this does
- **B1 — one source of truth.** `setVariableValue` now drives the runtime variable model (`data-composition-variables` / `__hfVariables`), with CSS compatibility emitted as explicit `stylePath`-based patches alongside the model patch. A brand kit is a variables JSON; a batch of `setVariableValue` re-skins in one frame.
- **B2 — object-valued variables.** The `CompositionVariable` union extends from scalar-only to typed objects: `font` (`{name, source}`) and `image` (`{url, …}`), end-to-end (core union → SDK op → runtime merge). Colors stay scalar (per §7 LOCKED decision).
### Implementation notes
CSS compatibility was moved out of `apply-patches.ts` (where it was incorrectly writing CSS props as a side-effect of model patches, breaking inverse/undo) and into explicit patches emitted in `mutate.ts`. Forward emits `[modelPatch, cssPatch]` for scalars; inverse correctly generates `patchRemove` for the CSS prop when there was no prior CSS prop. Font/image variables never become CSS props.
### Files (12 changed, +441 −32)
- `packages/core`: `core.types.ts`, `lint/rules/composition.ts`, `parsers/htmlParser.ts` (+test), `runtime/validateVariables.ts`
- `packages/sdk`: `engine/mutate.ts` (+test), `engine/apply-patches.ts`, `engine/patches.ts`, `index.ts`, `types.ts`
### Gates
- `bun run build` ✅
- `bun test` SDK 304/0 ✅ · `validateVariables.test.ts` 13/0 ✅
- `bunx oxlint` 0/0 ✅ · `bunx oxfmt --check` ✅
- `fallow audit --gate new-only` ✅ (complexity inherited only)
> The +8 new `htmlParser.test.ts` font/image tests fail under the pre-existing `DOMParser is not defined` happy-dom limitation (main already carries 425 such failures) — not a logic bug; the pure runtime logic is covered by `validateVariables.test.ts`.
### Deferred
Brand-kit picker UI and `batch(setVariableValue × N)` wiring are Pacific-side; per-composition variable scoping beyond `__hfVariablesByComp`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Add readiness-only runtime adapters for Mapbox GL JS, Leaflet, Google
Maps, MapLibre GL JS, and D3. Each adapter gates `__renderReady` until
the library's async initialization completes, preventing the renderer
from capturing blank or half-loaded frames.
Built on the `getReadyPromise` adapter contract from #1543. A shared
`createReadinessAdapter()` helper in `_readiness.ts` owns the
settled-tracking WeakSet, promise-identity stability, and
`Promise.allSettled` gate — each adapter provides only its type, window
global name, and `waitFor` callback.
Readiness signals per library:
- Mapbox / MapLibre: `map.loaded()` + `map.on('load', ...)`
- Leaflet: `map.whenReady(cb)`
- Google Maps: `map.addListener('tilesloaded', cb)` with handle cleanup
- D3: `transition.end()` promise
50 unit tests across 5 test files covering happy path, no-instances,
stable promise identity, post-settle drain, loaded-before-subscribe
race, and listener cleanup. 5 producer regression tests with
Docker-generated baselines for end-to-end render verification.
Replaces the original `window.__hyperframesReady` authored API with an internal adapter contract: `RuntimeDeterministicAdapter.getReadyPromise?: () => PromiseLike | null`. The Three.js adapter implements it by hooking `THREE.DefaultLoadingManager.onStart/onLoad`; the runtime collects promises from every adapter and gates `window.__renderReady = true` on them. Zero authoring burden — composition authors write plain Three.js, framework handles async asset gating automatically.
Also keeps the orthogonal `htmlDocument.ts` script-stripping refactor (substring → regex for simple flag assignments), which fixes the bug where authored scripts referencing readiness flags were stripped despite never assigning them.
Stamped by Magi and Miguel; CI green; tests 33/33 pass.
* fix(studio): restore timeline move/resize fallback parity (review #1466)
The §3.2 sdkTimingPersist rewrite regressed the non-SDK fallback path vs the
pre-cutover behavior. Restored, on both fallback entry points (no-session and
sdkTimingPersist-returned-unhandled):
- Resize live DOM patch dropped the conditional data-playback-start/media-start
attr — restored so a start-trim updates the preview's in-point immediately.
- Move/resize fallback dropped the GSAP-position sync (shift/scaleGsapPositions)
+ reloadPreview — restored so server-path edits keep GSAP tweens in sync and
refresh the preview (the SDK path folds both into setTiming).
- Undo-coalesce drift: fallback enqueueEdit carried no coalesceKey while the SDK
branch did — plumbed coalesceKey through persistTimelineEdit so undo
granularity is identical on either path.
- Documented the hasPbsAdjustment second clause + sdkTimingPersist before-capture
transition limitation.
Flag-off (dark launch) so this lands as one fix PR at the stack tip rather than
restacking the mid-stack §3.2 commit. #1500 review items: parity-harness gap
already closed at the tip (arc/unroll recast-vs-acorn parity added); blockRemoveRange
flagged 'potential' but verified correct (no comma residue on any block position).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sdk): retire duplicate removeGsapKeyframe keyframeIndex variant (review #1498)
EditOp had two removeGsapKeyframe members with the same discriminant but
different shapes (keyframeIndex vs percentage) — TS can't discriminate them and
a handler could get the wrong shape. Per both reviewers (option 2): retire the
keyframeIndex variant. It had no production caller (Studio dispatches percentage
only); removed the dead by-index handleRemoveGsapKeyframe + simplified the
dispatcher. resolveKeyframe stays (setGsapKeyframe still uses keyframeIndex).
Converted the one by-index test to the percentage API.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(studio): gate ALL cutover persist paths on the flag — true dark launch (review #1469 finding #6)
Only sdkCutoverPersist (style/text/attr) checked STUDIO_SDK_CUTOVER_ENABLED.
sdkTimingPersist, dispatchGsapOpAndPersist (every GSAP op) and sdkDeletePersist
guarded only on `!sdkSession` — and useSdkSession opens a session by default
for shadow/selection, so timing/GSAP/keyframe/delete cutover was ALWAYS live
regardless of the flag. Flipping the flag OFF could not disable it, so the
data-loss bugs in those paths (single-prop wipe, wrong-keyframe match, tween
collapse, arc strip) ship LIVE on merge instead of being dark-launched.
Added the flag guard at all three chokepoints → flag OFF returns false → callers
fall back to the legacy server path. Makes the stack genuinely dark-launchable:
merge is now a no-op in prod, and the remaining cutover correctness bugs become
flip-prerequisites rather than merge-blockers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(core,sdk): correct 8 GSAP write-path review findings (#1539)
Eight correctness bugs from the SDK-cutover review. Several were cases where
BOTH writers were identically wrong, so the recast-vs-acorn parity suite stayed
green; the new tests assert the real-world-correct result, not agreement.
- #2 findKfPropByPct: match the CLOSEST keyframe within tolerance, not the first
within 2% — removing/updating 50% on 0/49/50/100 no longer hits 49%.
- #3 handleSetTiming: shift each tween by the start DELTA and scale duration by
the clip-duration RATIO per-tween, instead of writing absolute newStart/
newDuration onto every tween (which collapsed staggers and blew durations).
- #4 enableArcPath: insert motionPath via appendRight at the object start so the
insertion can't collide with the x/y remove-range end (which made MagicString
discard the append and emit '{}').
- #5 splitAnimationsInScript: compute the inherited baseline in a forward pre-pass
so the split-spanning midpoint sees earlier tweens (the reverse write loop is
kept for stable count-suffixed ids).
- #9 unrollDynamicAnimations: preserve non-target loop-body statements (e.g.
tl.set initial-state) per iteration instead of overwriting the whole loop.
- #10 buildMotionPathObjectCode (both writers): emit the cubic form when segment
curviness varies so per-segment curviness survives, not just segments[0].
- #11 readLastWaypointXY: handle UnaryExpression so negative destination coords
are recovered when disabling an arc path.
- #15 no-bang: removed every `!` non-null assertion in the touched files,
replaced with guards/fallbacks.
Tests: gsapWriter.reviewFixes.test.ts (#2/#4/#5/#9/#10/#11) and
mutate.gsap.test.ts setTiming GSAP-sync block (#3). All fail on the base and
pass after the fix; tsc + full core/sdk suites + parity stay green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(studio): SDK cutover review fixes — merge tween props, stabilize debounce, serialize gsap writes, on-disk undo baseline, self-write identity
Addresses 5 SDK-cutover review findings (studio-only):
- #1 useGsapPropertyDebounce: editing one GSAP tween property no longer drops
the tween's other animated props. setGsapTween REPLACES the property set, so
merge the single edit into the tween's CURRENT properties (read from the SDK
doc) before dispatching, mirroring the legacy server merge.
- #7 useGsapPropertyDebounce: stabilize the flush callback by reading sdk deps
from a ref instead of an unmemoized literal, so a parent re-render mid-edit
no longer tears down + flushes the debounce (one commit/undo entry per render).
- #8 sdkCutover/useGsapScriptCommits: route SDK gsap-write persists through the
same per-file keyed serializer the legacy commitMutation uses, so concurrent
same-file read-modify-writes can't interleave and lose an edit.
- #12 sdkCutover/useTimelineEditing: capture the exact on-disk bytes as the undo
'before' for timing/GSAP persists (matching the style/delete paths) instead of
a normalized SDK serialize() re-emit that reformatted the whole file on undo.
- #14 useSdkSession/sdkSelfWriteRegistry: discriminate a cutover echo from an
undo write by CONTENT identity (registered self-write hash), not just the 2 s
timestamp window — an undo write always reloads the SDK session.
Tests: useGsapPropertyDebounce(.test), useGsapPropertyDebounceFlush.test,
sdkSelfWriteRegistry.test, and new sdkCutover.test cases; each reproduces the
review scenario and asserts the corrected behavior (verified red before fix).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(core): extract split/collapse helpers to satisfy no-fallow-ignore rule
The #5 (split) and #15 (no-bang guards) fixes pushed splitAnimationsInScript and
removeAllKeyframesFromScript over fallow's complexity threshold, and a fallow-ignore
had been added to splitAnimationsInScript. Per the hard rule (never ignore — fix),
extracted buildSpanningSplit + applyTweenSplit (split) and buildCollapsedFlatVars
(collapse), and removed the ignore. Both functions now under threshold; fallow new-only
gate reports 0 new findings. Behavior unchanged — core 1811 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(studio): pin dark-launch flag-gate contract (review #1539, Rames/Via)
flag OFF ⇒ sdkTimingPersist / sdkGsapTweenPersist (GSAP-op chokepoint) /
sdkDeletePersist all return false even with a valid session → legacy fallback.
The prod flag-flip rests on this contract; sdkCutover.test.ts only mocks the flag
TRUE, so a future gate refactor could silently re-enable cutover on flag-off
without failing CI. This sibling file mocks it FALSE and locks the three guards.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(studio): leading flag-gate on sdkGsapTweenPersist (review #1539 nit, Via)
The add-op getElement existence check ran before the inner gate, so flag-off did
an SDK touch before falling back. Lead with the flag guard to match the other
three chokepoints — flag-off is now a clean no-op at every entry point.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(core): unroll-preservation regressions — non-for loops + AST index substitution (review R2)
The #9 unroll-preservation fix had two confirmed regressions:
- Non-for loops (forEach/for-of/for-in/while): loopIndexVarName returns null, so
substitution no-op'd and preserved siblings kept a now-undefined loop variable
(e.g. `item`) → ReferenceError at render. Now returns null for those forms →
caller falls back to the blanket loop overwrite (drops siblings, valid code).
The #9 fixture only used `for(let i…)` so it never caught this.
- substituteLoopIndex did a \bvar\b regex over raw source including string
literals, corrupting selectors like ".row-i" → ".row-0". Now AST-based:
substitutes only real Identifier uses, skipping string literals and non-computed
member/key positions (extracted isIndexBindingPosition helper to stay under the
fallow complexity threshold — no ignore added).
Two regression tests added (forEach no-dangling-var; for-loop string-literal intact).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sdk,core): unrollDynamicAnimations rejects empty element list (R1 #1501b)
An empty `elements` array has no unrolled form — the writer would overwrite
the loop/statement with zero tween calls, silently deleting the animation.
- gsapWriterAcorn: unrollDynamicAnimations returns the script verbatim on an
empty list (no-op instead of a destructive overwrite).
- validateOp: reject unrollDynamicAnimations with empty elements as
E_INVALID_ARGS so callers get a clean error rather than silent corruption.
- Tests: writer no-op on []; validateOp E_INVALID_ARGS on [].
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* perf(sdk): cache draft element in applyDraft, drop HTMLElement casts (R1 #1490a)
applyDraft runs at 60fps during a drag but re-ran doc.querySelector on every
call — the _draftEl/_draftId fields were only consumed by commit/cancel, never
to skip the query. Reuse the tracked element when the id matches and the node
is still connected; re-query only on id change or detach (iframe reload).
Retypes _draftEl to HTMLElement | null (only ever set from
querySelector<HTMLElement>), which removes the `as HTMLElement` casts in
commitPreview / _clearDraft. Test asserts a repeated same-id drag queries once.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sdk,core): round-3 correctness — unroll AST safety, single-dispatch undo, empty-arg guards, persist decouple
Addresses the highest-severity round-3 review findings:
- gsapWriterAcorn unroll (R3 #1/#2/#9): the round-2 AST-substitution fix emitted
invalid GSAP for object shorthand `{ i }` (→ `{ 0 }`) and shadowed inner
bindings (→ `for(let i=0;0<3;0++)`), and silently dropped sibling statements on
non-`for` loops (forEach/for-of). The unroll now REFUSES (no-ops, leaving the
dynamic loop intact) whenever siblings can't be safely reproduced — a non-`for`
loop, an unmodeled statement, or an unsafe index use — instead of dropping or
corrupting. Plain `for` loops with safe siblings still unroll.
- session single-dispatch undo (R3 #5/#11): _dispatch now reverses the inverse
patch list (parity with batch()). A single op emitting order-dependent inverse
patches — a nested parent+child removeElement, an aliased multi-target — undid
forward and dropped the child subtree / landed on an intermediate value.
- materializeKeyframes empty-array (R3 #10): the unguarded twin of the just-fixed
unrollDynamicAnimations. Writer no-ops on an empty keyframe list; validateOp
rejects it as E_INVALID_ARGS (shared gsapScriptMissing helper).
- history:false persist decouple (R3 #4): persist (auto-save) no longer lives
inside the history-enable block, so opting out of SDK undo no longer silently
disables all disk writes (data-loss trap for #1496's flag consumers).
Tests: unroll refuse cases (shorthand/shadow/forEach) + safe-for-loop regression;
nested removeElement undo; materializeKeyframes writer no-op + validateOp reject;
history:false-still-persists.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(core): stripGsapForId re-parses per removal so all tweens for a deleted element are stripped (R3 #3)
Animation ids are count-based (positional), so removing one tween renumbers the
survivors. stripGsapForId captured every matching id from a single up-front parse
then removed against the mutating script — after the first removal the later ids
were stale and silently no-op'd, leaving an orphaned tl.to() referencing the
just-deleted element. Now re-parse after each removal and strip the first
still-matching animation until none remain.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(core): gsap writer — keyframe ease routing, convert preserves delay, addLabel dedup (R3 #7/#8/#12)
- #7: updateAnimationInScript routes an ease update on a keyframe tween to
keyframes.easeEach (per-keyframe), not a top-level ease that GSAP ignores —
the user's keyframe-easing edit was silently a no-op.
- #8: convertToKeyframesFromScript now preserves every non-editable vars key
(delay/callbacks/stagger/yoyo/…) verbatim via preservedVarsEntries instead of
rebuilding from the GsapAnimation object, which had no `delay` field and
dropped it — shifting the tween's start time.
- #12: addLabelToScript moves an existing same-named label (overwrites its
position) instead of appending a duplicate; duplicates made removeLabel
over-remove (it deletes every match, including a pre-existing label).
Tests: easeEach routing, delay preservation, addLabel move-not-duplicate +
hand-authored-dup removal. Updated the old "no dedup contract" corpus test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sdk): handleSetTiming #domId + data-duration sync; validateOp resolves ids + arc/selector (R3 #6/#13, CF2 #15/#16)
CF2 #15: handleSetTiming re-synced GSAP tweens only when the selector matched the
element's hf-id. The common #domId-targeted tween (authored by the Studio panel)
never matched, so moving/resizing a clip via the SDK timing path left its
animations unsynced. Now match the tween selector against the DOM id too.
CF2 #16: handleSetTiming read/wrote only data-end. Clips authored with
data-duration (what the runtime prefers) got a fresh data-end beside a stale
data-duration (no playback change) and oldDuration=null collapsed the GSAP
duration-scale ratio to 1. Now read duration preferring data-duration, and write
back to whichever attribute the clip uses (timingPath gains a "duration" field).
R3 #13b: deleteAllForSelector compared selectors with strict === and missed the
alternate quote style ([data-hf-id='x'] vs "x"); now quote-insensitive.
R3 #6/#13a: validateOp now resolves the animationId for id-bearing GSAP ops
(E_TARGET_NOT_FOUND instead of a misleading ok that no-ops at apply), and
updateArcSegment validates the arc is enabled + the segment index is in range.
Tests: #domId move sync, data-duration resize + scale, quote-insensitive delete,
unresolved-id rejection, arc-segment preconditions. Updated the loose-can() test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(core,sdk): name the acorn-node type alias; keyToPath round-trips timing.duration (R3 #14)
- gsapWriterAcorn: replace the bare `: any` AST-node annotations with the named
`type Node = any` alias, matching the established convention in
gsapParserAcorn.ts / gsapInline.ts ("acorn ESTree nodes are structurally
untyped"). Documents intent and is greppable; type-identical (zero runtime
change). A full ESTree typing is a deliberate architecture decision the
codebase has not taken and is out of scope here.
- patches: keyToPath/timingPath now include the "duration" timing field added
for the data-duration resize fix, so a timing.duration override round-trips on
T3 replay instead of being dropped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sdk): cascadeRemoveAnimations re-parses per removal (R4 — SDK twin of #3)
cascadeRemoveAnimations captured every matching animation id from a single
up-front parse, then removed against the mutating script — the SDK-side twin of
the stripGsapForId bug (R3 #3). Animation ids are positional, so removing the
first tween for an element renumbered the survivors and the stale later ids
no-op'd, orphaning those tweens on the just-removed element. Now re-parse after
each removal and strip the first still-matching animation until none remain.
Also adds the reviewer's defense-in-depth test: an aliased multi-target setStyle
(same id twice) undoes to the original, not the intermediate (exercises the
single-dispatch inverse reversal from R3 #5/#11).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sdk,core): ws-3 — unrollDynamicAnimations acorn port + SDK op
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* test(core): recast-vs-acorn parity + acorn fixes for arc/unroll/keyframe-add/%-removeKeyframe/add-with-keyframes (WS-3.F gate)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(core): port shiftPositions/scalePositions to acorn writer (WS-3.F)
shiftPositionsInScript + scalePositionsInScript were recast-only GSAP-script
writers reachable from executeGsapMutation (shift-positions/scale-positions),
called by Studio timeline clip move/resize — the last write ops blocking recast
retirement. Ported to gsapWriterAcorn.ts mirroring recast's arithmetic
(shift: max(0,pos+delta); scale: remap pos by duration ratio + scale duration),
reusing a shared overwritePosition helper (also adopted by updateAnimationInScript).
Adds 10 recast-vs-acorn parity tests. Closes the WS-3.F op-coverage gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port arc path trio from recast to browser-safe acorn+MagicString writer.
Add SDK op types and mutate.ts handlers for setArcPath / updateArcSegment /
removeArcPath. Decompose buildMotionPathObjectCode into small sub-functions
in gsapSerialize.ts to stay within fallow complexity thresholds. Tests verify
acorn output re-parses to correct arcPath shape.
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
Fifth PR in the Studio storyboarding stack. Click a contact-sheet tile to
open a full-area focus on that frame.
- StoryboardFrameFocus: large poster, prev/next nav, full narrative, and an
editable voiceover *guide* (textarea) saved back to STORYBOARD.md. Status
can be advanced outline → built → animated inline.
- "Open in Preview" jumps to the timeline focused on the frame's
sub-composition (setActiveCompPath + view-mode timeline).
- core/storyboard: setFrameField / setFrameVoiceover / setFrameStatus —
surgical in-place writers that update one frame's metadata without
re-serializing (markdown stays canonical). Tested.
- Extract shared FramePoster (used by tile + focus); tiles are now buttons
that open focus.
Voiceover here is the editable guide; SCRIPT.md remains the locked narration
that drives TTS.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A sub-composition mount whose data-duration ends before the host
composition's window leaves its slot blank for the remainder. The
runtime behavior is correct (data-duration is the slot's visible window
and takes precedence), but a full-bleed sub-composition shorter than the
composition is almost always an authoring mistake that fails silently
(issue #1540).
Add the subcomposition_blanks_before_host rule, scoped narrowly to the
high-signal shape — a sole/dominant external mount starting at ~0 whose
window ends before the host's — so it stays silent on intentional short
clips. Document the slot-window semantics in the sub-compositions
reference, distinguishing the hold-through-slot case (#911/#917) from
the blank-when-shorter-than-host case.
First PR in the Studio storyboarding stack. Establishes the parseable
contract the storyboard UI reads from; no UI yet.
- core/storyboard: StoryboardManifest/Frame/Globals types + a lenient
STORYBOARD.md parser (frontmatter + status/src/duration/transition_in,
freeform narrative tolerated, never throws, records warnings). Exposed as
@hyperframes/core/storyboard (browser-safe).
- studio-api: GET /projects/:id/storyboard returns the normalized manifest
with per-frame srcExists; missing file -> exists:false, not 404.
- fixture: packages/studio/fixtures/storyboard-sample for dogfooding the
storyboard view in later PRs (built/animated frames + one outline).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
font_family_without_font_face (and system_font_will_alias) collect
@font-face blocks with `@font-face\s*\{[^}]*\}`, which stops at the first
`}`. A CSS comment inside the block that contains a brace —
`@font-face { /* 400 } regular */ font-family: 'X'; ... }` — truncates the
match before the font-family, so the family is never recorded as declared
and a later `font-family: 'X'` usage is wrongly flagged as used without an
@font-face. Strip CSS comments before scanning so a brace inside one cannot
split a block.
Refs #1534
Add gsapWriterParity.corpus.test.ts: a reusable recast-vs-acorn differential
harness (runParity/modelOf, exported for the WS-3 op-PR workflow) plus a broadened
corpus (3 real registry scripts + 10 synthetic) covering to/from/fromTo, multi-tween,
keyframes, labels, numeric/label-relative/symbolic positions, stagger/repeat/yoyo
extras, and sub-composition selectors. Extends true differential coverage to the five
previously standalone-only acorn ops (update/add/removeAnimation, update/removeKeyframe)
and adds correctness tests for the acorn-only label ops.
Fix three acorn-writer divergences the suite surfaced:
- updateAnimationInScript now REPLACES the editable property set (and fromTo
from-vars) instead of merging, matching recast's reconcileEditableProperties;
non-editable keys (duration/ease/stagger/…) are preserved.
- removeKeyframeFromScript now collapses keyframes back to a flat tween when
fewer than two keyframes remain, matching recast's collapseKeyframesToFlat.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(lint): promote rules to errors with registry exemptions and false-positive fixes
- Export isRegistrySourceFile/isRegistryInstalledFile from composition.ts
- Add registry exemptions to google_fonts_import and font_family_without_font_face
- Add registry exemption to requestanimationframe_in_composition
- Fix timed_element_missing_clip_class: data-track-index alone no longer triggers
- Fix caption_transcript_parse_error: balanced-bracket scanner replaces non-greedy regex
- Fix missing_timeline_registry: skips sub-compositions and template-wrapped files
- Fix scene_layer_missing_visibility_kill: strip JS comments before pattern matching
- Fix gsap_css_transform_conflict: exempt from() alongside fromTo()
- Fix gsap_from_opacity_noop: only fires when opacity value is actually 0
- Add regression test for data-track-index-only elements
* test(lint): add regression tests for false-positive fixes
Covers the 7 missing negative-case assertions flagged in PR review:
- registry marker suppresses google_fonts_import + font_family_without_font_face
- registry marker suppresses requestanimationframe_in_composition
- isSubComposition suppresses missing_timeline_registry
- scene_layer_missing_visibility_kill: fires, commented-kill fires, real kill suppresses
- gsap_css_transform_conflict: from() exempt alongside fromTo()
- gsap_from_opacity_noop: non-zero opacity (e.g. 0.5) is a valid reveal, not a noop
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(examples): fix warm-grain template to pass promoted lint rules
- index.html: remove undeclared "Lexend" from font-family stack
- intro.html: replace Google Fonts @import with bundled Inter font
- captions.html: quote TRANSCRIPT keys for valid JSON + use Inter font
Fixes CLI smoke CI failure after google_fonts_import, font_family_without_font_face,
and caption_transcript_parse_error were promoted from warning to error.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): resolve warm-grain from repo registry in dev mode + bundle at build
getStaticTemplateDir now falls back to registry/examples/<id> in dev mode
so CI smoke tests use the PR-branch copy instead of fetching from main.
build-copy.mjs copies warm-grain to dist/templates/warm-grain at build time
so packed CLIs can scaffold it offline.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(examples): remove trailing comma from warm-grain TRANSCRIPT array
JSON.parse rejects trailing commas (valid JS, invalid JSON).
caption_transcript_parse_error was still firing because of the comma
on the last entry after quoting all keys.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
The acorn addKeyframeToScript mixed ms.overwrite + ms.appendLeft on the
same _auto endpoint node, crashing MagicString ("Cannot split a chunk
that has already been edited") whenever an interior keyframe adjacent to
an _auto 0/100 endpoint introduced a new backfilled prop — the common SDK
path. It also replaced (not merged) existing keyframes, dropped ease and
_auto markers, corrupted commas on multi-prop backfill into empty {},
used a <0.001 percentage tolerance instead of recast's PCT_TOLERANCE=2,
and silently no-op'd on flat (non-keyframe) tweens.
Rebuild the node model to mirror recast: compute the FINAL property record
for every changed keyframe value node (target merge, _auto endpoint sync,
backfilled siblings) against the original AST, then emit exactly one
ms.overwrite per changed node (one insert for a brand-new key). No node is
ever both overwritten and appended into, so splices can never overlap.
- Merge: re-touching an existing keyframe merges new props over the
existing record, preserving untouched props, existing ease, and _auto.
- Convert-flat: first keyframe-add on a flat to()/from()/fromTo() tween
rebuilds its vars object to percentage keyframes (ease->easeEach,
ease:"none", from/fromTo->to) matching recast, then re-locates via the
-from-/-fromTo- -> -to- id fallback.
- Tolerance: PCT_TOLERANCE=2 for existing-keyframe detection.
- Shared serializeValue/safeJsKey for keyframe values (recast parity); the
tween-statement path keeps its local serializer for object/boolean extras.
- keyframeBackfill: only backfill props with a real numeric default; skip
unknown/string props so color:0 / filter:0 are never emitted.
- setGsapKeyframe move-path threads the same backfill defaults as the add
path so both entry points behave identically.
Differential tests (acorn vs recast parsed keyframe arrays) cover the
crash (2-endpoint + 0/25/100), empty-{} multi-prop backfill, merge with
extra props + ease, flat to()/fromTo() convert, "50.0%" non-byte-equal
key, near-% tolerance, and _auto-marker preservation onto an endpoint.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(core): add param-substitution utility for GSAP timeline inlining
U1: clone + shadow-aware identifier substitution over acorn ESTree, plus
provenance tagging and a GsapProvenance type. Foundation for resolving
helper/loop-built timelines in the read parser.
* feat(core): inline helper-built and bounded-loop GSAP timelines
U2: expansion pre-pass that rewrites the analysis AST so a helper called N
times, a literal-bounds for-loop, a for-of, or a forEach over an inline array
each become concrete per-call/per-iteration tl.* statements with substituted
positions and provenance tags. Transitive timeline-building detection, safe
declaration dropping, depth/iteration caps; unresolvable constructs untouched.
* feat(core): resolve computed GSAP timelines in the read parser
U3: parseGsapScriptAcorn runs the inlining pre-pass before analysis, so
helper-built and bounded-loop timelines resolve at true positions with
motionPath arcs recognized; each tween carries provenance. Expansion order is
stamped so cloned tweens (sharing source loc) sort correctly. Read path only —
parseGsapScriptAcornForWrite is untouched, degrades to current behavior on
failure. The add-to-basket addCycle case now yields 7 resolved animations.
* feat(studio): runtime-authoritative keyframes for dynamic timelines
Phase 2 (U4-U6): the live-runtime scanner returns tween-relative keyframes
with per-tween timing and converts them to clip-relative when given clip dims,
fixing the timeline-vs-clip-relative bug; it extracts motionPath into arcPath
(shared buildArcPath) so the Arc Motion panel activates for data-driven arcs;
the cache leaves statically-unresolvable tweens to the runtime scan. Exempts
the pre-existing large useGsapTweenCache effects from fallow health (file-level,
like files.ts) rather than suppression comments.
* feat(studio): surface keyframe editability from provenance
U9: editabilityForProvenance(provenance) -> direct|unroll|override (core,
re-exported from the acorn subpath). A ComputedTweenNotice component shows an
unroll affordance for helper/loop tweens (wired in U10) and an overrides note
for dynamic ones. Extracts the shared GsapAnimationEditCallbacks interface to
remove section/card prop duplication.
* feat(core): lint understands computed timelines (acorn parser)
U7: the GSAP lint rule now loads parseGsapScriptAcorn (which inlines helpers
and bounded loops) instead of the recast parser, so overlapping_gsap_tweens and
related findings reflect true resolved positions for computed timelines — and
keeps recast out of the lint graph entirely. Literal compositions are
unchanged (parity), all 182 lint tests pass.
* docs: document the computed-timeline keyframe editing model
U8: keyframes.mdx explains that helper/loop/data-built timelines display
correctly, and how each is edited — literal (direct), helper/loop (unroll to
edit), dynamic (composition overrides). Nothing is permanently locked.
* feat: unroll computed timelines into literal tweens (U10)
Adds unrollComputedTimeline (core): serializes a parsed timeline's resolved
animations back to literal tl.* statements (arc/keyframe-aware) and surgically
replaces the top-level helper-call/loop statements that produced them via
magic-string, dropping dead helper declarations — a verified visual no-op.
Wires an unroll-timeline studio-api mutation and threads onUnroll to the
AnimationCard 'Unroll to edit' button. Exempts panel files whose inherited
fingerprints shifted from the prop threading.
* feat(runtime): declarative keyframe override layer for dynamic tweens (U11)
Adds applyKeyframeOverrides: fetches a gsap-overrides.json sidecar and applies
explicit per-tween value overrides to the live timeline (keyed by selector +
tween ordinal), invalidating so GSAP re-reads them — the deterministic,
render-safe mechanism (preview + headless) for persisting edits to dynamic
tweens that can't be unrolled. Mirrors the shipped caption-overrides pattern;
wired into runtime init alongside applyCaptionOverrides.
* refactor: drop the keyframe override layer; rely on unroll + source
Removes the gsap-overrides.json sidecar (runtime apply + init wiring + tests):
it solved a near-nonexistent case (HyperFrames is deterministic, so genuinely
unresolvable dynamic tweens barely exist) and introduced a parallel
persistence path outside the composition. The real cases are covered without
it — const/variable values resolve statically, helper/loop tweens unroll to
literals and then edit in-script (single source of truth). Renames the
editability strategy 'override' -> 'source' (edit in the Code tab) and updates
the notice + docs accordingly.
* fix(studio): drag outside tween range creates new keyframe, picks nearest tween
Fixes the GSAP drag intercept to pick the position tween closest to the
playhead (not the one with the most keyframes), and when dragging outside all
tweens' ranges, creates a brand-new keyframed tween instead of destructively
extending/replacing the nearest one. Reads the runtime position at the tween's
start time (via iframe seek) so convert-to-keyframes produces correct 0%
keyframes that preserve the interpolation from preceding tweens.
* fix(studio): drag outside tween range creates new keyframe, picks nearest tween
Also reverts all fallow health.ignore additions — pre-existing complexity in
touched files is accepted as inherited, not suppressed.
Studio-triggered renders emit render_complete / render_error from the CLI
preview-server process, which stamps every event with the install's
anonymousId (client.ts drainQueueToPayload). The browser, meanwhile, fires
studio_session_start / studio_render_start under its own getAnonymousId(). So
the render outcome and the render start never share a person_id — verified in
data: of 15,125 users who started a studio render in 30d, ZERO have any
render_complete under any source, and the 898 studio-tagged completers are
disjoint server UUIDs. The studio render funnel — the product's core value
moment and strongest retention signal — is therefore unmeasurable.
Thread the browser's telemetry id through to the render-outcome events:
- client.ts: trackEvent takes an optional distinctId; drainQueueToPayload uses
`event.distinctId ?? config.anonymousId`. CLI renders unchanged.
- events.ts: trackRenderComplete/trackRenderError forward an optional distinctId.
- studioRenderTelemetry.ts: emitStudioRender* pass opts.distinctId through.
- core studio-api (types.ts + routes/render.ts): the render route reads
`telemetryDistinctId` from the request body (validated string) and passes it
to the adapter's startRender, which already forwards opts to the emitters.
- studio (useRenderQueue.ts): include getAnonymousId() as telemetryDistinctId
in the render POST — the same id studio_* events already use.
Result: studio render_complete/error now carry the browser user's id and join
studio_session_start / studio_render_start. Older clients that don't send the
field fall back to anonymousId (no regression). No new tracking surface — it's
the existing anonymous studio id.
Tests: per-event override forwarding (events), studio render distinctId
threading + older-client fallback (studioRenderTelemetry), and route body →
adapter forwarding incl. non-string rejection (core render route).
The `/projects/:id/renders/file/*` route joined attacker-controlled wildcard
input straight onto rendersDir with a bare join() + readFileSync and no
containment check — the only project-scoped filesystem route that skipped the
resolveWithinProject chokepoint every sibling route uses.
Literal/encoded `../` traversal is collapsed upstream by Hono's WHATWG URL
normalization (verified empirically), so the plain LFI is not reachable over
HTTP. But a symlink living inside rendersDir and pointing outside it was still
followed and served verbatim (verified: leaked an external secret, 200 OK).
Routing through resolveWithinProject canonicalizes with realpath before serving,
closing the symlink escape and making the route's safety independent of the URL
layer's normalization behavior.
Adds regression coverage: serves an in-dir file, rejects an escaping symlink
(403), and still serves a symlink that stays inside rendersDir.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a child element inside a sub-composition is selected, the timeline
replaces the parent scene clip with the deepest-level siblings. Deselect
or selecting outside collapses back. Expanded clips are fully editable —
move, resize, delete, and split — addressed by their real DOM id with
timeline time rebased onto the sub-comp they live in.
Runtime:
- New window.__clipTree API: a read-only hierarchical ClipNode tree
(id/parentId/children + backing element) so Studio can derive
parent/child relationships for inline expansion.
Studio:
- useExpandedTimelineElements derives the expanded view from
selectedElementId + clipParentMap (pure useMemo, no useEffect).
Each child rebases onto its immediate sub-comp host (start +
sourceFile), so multi-level nesting targets the right file.
- NLELayout routes expanded-clip edits through the same handlers
top-level clips use, in local coordinates — edits save to the
sub-comp source and reflect via reloadPreview (no separate DOM-patch
path). This is the canonical update; there is no reactive observer.
- findMatchingTimelineElementId resolves sub-comp children with no
top-level element to `sourceFile#id`.
- Razor tool enabled by default; studio_razor_split analytics event
fired on single and split-all.
- O(n²) isElementGsapTargeted extracted to gsapTargetCache.ts with a
cached Set+WeakSet O(1) lookup.
Adds a Code Animations catalog section — 9 self-contained, installable blocks:
morph, snippet-flight, typing, diff, highlight, scroll (DOM/GSAP) and 3d-extrude,
shader-dissolve, particle-assemble (WebGL). Each block ships only its own effect and
renders deterministically (paused GSAP timeline seeked per frame, seeded RNG, no
render-time data fetch). Wires the catalog nav, registry.json, a new code-animation
Studio category, and preview assets.
Gate the runtime's per-frame transport re-seek to yield to an active Studio manual-edit drag, so GSAP x/y-controlled elements track the cursor instead of freezing until drop. Also adds the missing sdk-playground workspace member to Dockerfile.test, which unblocks the render regression suite for any runtime-touching PR.
* refactor(core): swap studio-api read path from recast to acorn parser (T6e)
* fix(core,sdk): code-review findings — 5 correctness bugs + 2 cleanup
- gsapParserAcorn: top-level variable targets now resolved via program-scope
null-key fallback in lookupBindingFromAncestors (const el = querySelector...)
- gsapParserAcorn: fromTo guard requires args.length >= 3, preventing undefined
args[2]/args[3] access when fewer args supplied
- gsapWriterAcorn: remove fuzzing fallback in removeAnimationFromScript that
silently deleted the wrong animation (from→to ID conversion)
- gsapWriterAcorn: valueToCode guards NaN → "0" to avoid broken tween props;
safeKey regex aligned to ASCII-only (matching gsapSerialize)
- mutate: handleSetGsapTween now includes stagger in extras (was in addGsapTween
but missing from setGsapTween)
- apply-patches: script case now mirrors stylesheet — op=remove calls
setGsapScript("") instead of silently ignoring the patch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs(core): add trust-model header to T6d parity suite
Documents the recast-baseline trust relationship and clarifies that
motionPath parity tests live in the Phase 3b commit (PR #1379) since
the acorn motionPath parser is also added there.
Addresses #1370 R1-N1 (Rames).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
## Summary
Replaces the regex-based GSAP script parser with an acorn AST parser for the read path. This is the first of three parser PRs (T6b → T6c → T6d) that together migrate hyperframes off fragile regex parsing onto a proper AST.
## Why
The existing `gsapParser.ts` regex-based parser silently misparses edge cases: chained `.to()` calls, template literal targets, `gsap.utils.toArray(...)` expansions, lexically scoped variables, and percent-keyframe arrays. These misparses produce wrong `animationId` values that downstream SDK write ops use as keys — write ops targeting the wrong node corrupt the script. The fix is to parse with a real JS AST.
## What changed
**`packages/core/src/parsers/gsapParserAcorn.ts`** (new, ~1100 lines)
- `parseGsapScriptAcorn(script)` — full-featured read-path parser. Walks an acorn AST to extract:
- Timeline variable detection (`gsap.timeline()` assignment)
- `resolvedStart` computation: handles absolute positions, label references, relative `+=`/`-=`, chained calls
- Property group classification (`transform`, `opacity`, `color`, etc.)
- GSAP keyframes: percentage-object, object-array, simple-array with three-level easing
- Variable target resolution: `querySelector`, `getElementById`, `querySelectorAll`, `gsap.utils.toArray`, array literals, forEach/map callbacks
- Timeline `defaults` inheritance
- Stagger / repeat / yoyo extraction
- All `animationId` values are content-addressed (`target-method-startMs-group`) for deterministic round-trips
- Note: `parseGsapScriptAcornForWrite` (the write-path slice used by T6c) lives in T6c (#1369), not this PR
**`packages/core/src/parsers/gsapParser.acorn.test.ts`** (new, ~220 lines)
- Differential corpus tests: same input run through both the old regex parser and the new acorn parser, asserting outputs are equal on the scenarios the old parser handled correctly
- Catches regressions during the transition without requiring tests to be rewritten
- `onComplete`/`onStart`/`onUpdate`/`onRepeat` dropped-key assertions added in Phase 3b commit (#1379) where `DROPPED_VAR_KEYS` is defined — the test file is in T6b but the extended assertions live one commit up-stack
**`packages/core/package.json`**
- Added `acorn` and `acorn-walk` dependencies
## Test plan
- `bun run test packages/core` → all tests pass (35 passing in the T6b suite alone)
- Stacked on: `main`
- Stack above: T6c (write path), T6d (parity suite)