* feat(core): add pure resolveEditingAffordances (edit capabilities + section applicability)
* fix(core): replace prohibited as-cast and !-assertions in isIdentityTransform
* refactor(studio): consume core resolveEditingAffordances; drop duplicated capability + section logic
- affordances.ts: add matrix3d identity-transform branch (was missing, caused test regression)
- domEditingLayers: add domEditSelectionToFacts mapper; resolveDomEditCapabilities is now a thin
wrapper over core (kept for backward-compat — tests + barrel import it); isTextEditableSelection
delegates to core sections.text; drop parsePx + isIdentityTransform imports (now in core)
- PropertyPanel: import resolveEditingAffordances + domEditSelectionToFacts; compute sections once;
replace isMediaElement/isColorGradingCapableElement/timing inline check with sections.*
- propertyPanelMediaSection: delete isMediaElement (no remaining callers)
- propertyPanelColorGradingSection: delete isColorGradingCapableElement (no remaining callers)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(sdk): add browser-only resolveElementAffordances adapter over core
* fix(sdk): add position to inlineStyles, replace ! assertion with guard in test
- Add missing 'position' key to inlineStyles in affordances.ts to match computedStyles
- Replace non-null assertion (doc.defaultView!) with proper null guard in test
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
* fix(editing): resolve code-review findings on affordances feature
Max-effort review (8 verified findings) fixes:
Correctness regressions (studio behavior):
- SVG selection crash: dropped `classNames` from EditableElementFacts
entirely (it was never read by the resolver), which removes the
`.className.split()` calls that throw on SVGElement (className is an
SVGAnimatedString, not a string). Masked in tests by happy-dom.
- Timing panel hidden for GSAP-only layers: domEditSelectionToFacts now
takes animationCount from the caller; PropertyPanel feeds the live
gsapAnimations prop (selection.gsapAnimations is never populated).
Cleanups:
- Removed dead inline `position` key from SDK adapter (core reads position
only from computedStyles).
- Added sections-only `resolveEditingSections` export; PropertyPanel uses it
so panel re-renders no longer re-run the capability geometry parse.
- Declared happy-dom in packages/sdk devDependencies (was root-hoist only).
- Deduped the two capability fact-construction sites behind a shared
capabilityFacts() helper.
- parsePx now has a single source of truth in core; studio domEditingDom
re-exports it so the copies can't drift. isIdentityTransform is now
core-internal (studio's only consumer moved to core in the prior task).
bun.lock also reconciles stale 0.7.17->0.7.21 package versions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
## Summary
Extracts the GSAP parser/writer suite, HTML parser, hf-ids, spring-ease, and the shared composition data types out of `@hyperframes/core/src/parsers/` into a new, independently-publishable **`@hyperframes/parsers`** package.
This is the foundation of the [#1749](https://github.com/heygen-com/hyperframes/issues/1749) effort: make HyperFrames' parsing/linting/validation usable as plain libraries in a Node app, without shelling out to the CLI. Parsers is the standalone base every other extracted package builds on.
**Part 1 of 3** — splits #1754 into independently-reviewable pieces. Parts 2 (lint) and 3 (studio-server) stack on this branch.
## What moves
| | |
|---|---|
| Source moved out of core | **~9,900 LOC** (`src/parsers/` → `packages/parsers/src/`) |
| Total lines removed from core (incl. tests + goldens) | ~19,600 |
| Files relocated | 39 |
| Tests carried over | **660 passing** (5 skipped, 3 todo) |
The big movers: `gsapParser` / `gsapParserAcorn` (the recast + acorn dual parsers), `gsapWriterAcorn`, `gsapSerialize`, `gsapUnroll`, `htmlParser`, `hfIds`, `springEase`, `stableIds`, plus the `__goldens__` corpus.
## Bundle footprint of the new package
| Artifact | Size |
|---|---|
| `dist/` (unpacked) | 1.7 MB |
| npm tarball (packed) | 409 KB |
| `dist/index.js` | 90 KB (**~21 KB gzipped**) |
| Heaviest entries | `gsapWriterAcorn.js` 93 KB · `gsapParser.js` 91 KB |
Most of the weight is the GSAP AST machinery (recast/babel/acorn). It's tree-shakeable via subpath entries (`@hyperframes/parsers/hf-ids`, `/gsap-constants`, etc.) so a consumer that only needs `hf-ids` (2 KB) doesn't pull the parsers.
## How `@hyperframes/core` changes
The interesting part: **core sheds its entire AST toolchain.**
| core `dependencies` | before | after |
|---|---|---|
| count | 9 | 6 |
| removed | — | `@babel/parser`, `acorn`, `acorn-walk`, `magic-string`, `recast` |
| added | — | `@hyperframes/parsers`, `linkedom` |
Before this PR, importing `@hyperframes/core` at all dragged in babel + recast + acorn just to construct types. Now those live behind `@hyperframes/parsers`, and a consumer that only wants core's runtime/compiler types never resolves the parser stack. Core keeps thin `@deprecated` re-export stubs at the old subpaths (`@hyperframes/core/gsap-parser`, `/gsap-constants`, …) so nothing downstream breaks.
## Design notes
- **`"bun"` export condition before `"node"`** in every package export. Bun resolves the TypeScript source directly (no pre-built `dist/`), while Node/tsx/Docker contexts fall through to `"node"` → `dist/`. This keeps the dev loop zero-build while published artifacts stay Node-consumable.
- `@hyperframes/parsers` is **standalone** — zero `@hyperframes/*` dependencies — so it can be the base of the stack.
## Test plan
- [x] `bun run --filter @hyperframes/parsers test` — 660 tests pass
- [x] `bun run --filter @hyperframes/sdk test` — 382 tests pass
- [x] `bun run build` — full monorepo build succeeds
- [x] Fallow audit passes on CI
* refactor(core): retire recast/babel, route all GSAP mutations to acorn (WS-E/3.F)
- Delete gsapParser.ts (2595-line recast-based parser/writer)
- Delete gsapParser.test.ts, gsapParser.stress.test.ts, gsapParser.test-helpers.ts
- Add gsapParserExports.ts: re-export umbrella for gsap-parser subpath
- Move SplitAnimationsOptions/SplitAnimationsResult to gsapSerialize.ts
- executeGsapMutation: async->sync, static acorn imports replace loadGsapParser()
- Fix 3 function name mismatches in files.ts switch cases
- generators/hyperframes.ts: imports from gsapSerialize (blocker resolved)
- gsapWriterAcorn.ts: SplitAnimationsOptions from gsapSerialize
- Parity tests: recast oracle removed; acorn-only regression (14 pass)
- Remove recast and @babel/parser from core/package.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(sdk): harden mutation handlers + widen variable API (code-review)
Self-contained review fixes for the SDK-hotspot stack (#1569–#1573). The
dispatch path (_dispatch → applyOp) never runs validateOp, so the new
WS-D/WS-3.C guards were advisory-only; re-enforce them in the handlers.
- addElement: null-guard the resolved parent (no more `as Element` masking a
null → crash on unknown parent id); reject <script> and multi-root fragments
via parseInsertableFragment instead of inserting raw markup / silently
dropping extra roots.
- addWithKeyframes / replaceWithKeyframes: bail on empty keyframes (no
degenerate `keyframes: {}` tween) and when the animationId resolves to
nothing (no silent degrade-to-add leaving a duplicate tween).
- isObjectVariableValue: exclude arrays so an array override value can't be
misclassified as a font/image object and written into the variable model.
- Composition.setVariableValue: widen the public interface signature to
`… | FontValue | ImageValue` to match the impl + EditOp (B2 object-valued
variables were unreachable via the typed API).
- mutate.gsap.test.ts: import addKeyframeToScript from gsap-writer-acorn —
the gsap-parser subpath no longer re-exports write fns after recast retire,
so the test threw at runtime (red suite).
- Dedup: export EXCLUDED_TAGS from hfIds.ts and drop the verbatim
HF_EXCLUDED_TAGS copy in mutate.ts.
Adds guard regression tests. SDK 340/340, core hfIds 13/13, build green,
fallow --gate new-only clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sdk): variable-model dedup + undo/scoped-parent correctness; test honesty (code-review)
Second batch of review fixes for the SDK-hotspot stack.
- Variable model (#7, #13): extract readVariableDefault/writeVariableDefault into
a shared engine/variableModel.ts used by both mutate.ts (forward) and
apply-patches.ts (replay), so the model shape can't diverge. Add
clearVariableDefault and make a `variable` remove patch DELETE the decl's
`default` key — the exact inverse of a first-set on a default-less variable.
Previously undo of such a set no-op'd and stranded the value.
- addElement scoped parent (#8): record the caller's id verbatim
(scoped "hf-host/hf-leaf" path or composition id) as the patch parentId
instead of the bare data-hf-id, so redo/replay re-resolves the SAME parent via
resolveScoped rather than the canonical top-level dup (or document.body).
- resolveTimings honesty (#5): correct the header + test that claimed a live
"preview == render" parity — neither path consumes the resolver yet (anchor
inputs are Pacific/backend-deferred). It's a pure-function property, not a
current guarantee.
- GSAP writer parity (#12): the recast oracle was deleted in WS-3.F, leaving the
WS-3.C keyframe ops comparing acorn output to itself. Pin them as golden inline
snapshots and drop the now-dead recast scaffolding (replaceWithKfRecast,
removeAnimRecast alias). Remaining pre-WS-3.C parity blocks noted as follow-up.
Adds regression tests (undo of default-less variable; scoped-parent redo).
SDK 342/342, core timingResolver+parity green, build + fallow --gate new-only clean.
Not changed (need design / out of scope): #9 pre-#1569 persisted-override CSS
replay (moot for unreleased data; proper fix is render-time CSS derivation),
#11 replaceWithKeyframes stale positional id (mitigated by the missing-id no-op
guard + type doc; full fix needs non-positional ids).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sdk): replay CSS-prop derivation for legacy var overrides; stale-id selector guard (code-review)
Final review-fix batch — the two items deferred from the prior pass.
- #9 legacy variable-override CSS: applyOverrideSet now derives the `--{id}`
CSS custom prop from any scalar `var.{id}` override on replay (and removes it
for a null override). Sets written before the model/CSS split carried only
`var.{id}`; without this, replaying them updated the JSON model but left
`var(--{id})` bindings rendering the schema default. Replay-path only — the
undo path (applyOne) is untouched, so #1569's separate-patch undo correctness
is preserved. Object (font/image) values are never CSS, so they are skipped.
- #11 stale positional id: replaceWithKeyframes now requires the located
animation to still target the caller's `targetSelector`. Position-derived ids
re-point after structural edits; a stale id resolving to a DIFFERENT element's
tween previously got silently replaced. It now bails (no-op) unless the id
still points at the expected selector.
Adds regression tests (legacy var.{id}-only override restores CSS; object
override writes no CSS; stale-id-wrong-selector replace is a no-op).
SDK 345/345, build + fallow --gate new-only clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(core): gate acorn GSAP writer behind cutover flag; keep recast default (WS-3F)
Product decision pivot: acorn no longer replaces recast as the GSAP writer.
Recast remains the default server writer; acorn runs only when
STUDIO_SDK_CUTOVER_ENABLED=true (or =1) is set server-side — the same env
flag name as the client Vite var, so a single switch flips both sides.
Changes:
- Restore gsapParser.ts (recast writer) + test/stress/helper files deleted by 3F
- Restore @babel/parser + recast deps in packages/core/package.json
- Add isAcornGsapWriterEnabled() + loadGsapParser() to files.ts (lines 59-82)
- Split executeGsapMutation into async dispatcher + executeGsapMutationRecast
(recast, async via loadGsapParser) + executeGsapMutationAcorn (acorn, sync)
- Dispatcher defaults to recast; acorn branch taken only when flag is on
- Restore gsapWriter.parity.test.ts, gsapWriterParity.acorn.test.ts, and
gsapWriterParity.corpus.test.ts to true recast-vs-acorn differential suites
(not acorn-vs-itself)
- Exempt gsapParser.ts in .fallowrc.jsonc health.ignore + ignoreExports
(pre-existing complexity + barrel re-exports consumed outside diff scope)
- Add fallow-ignore-file code-duplication to files.ts (intentional parallel
switch bodies for two writers)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(sdk): image-alpha hit-test phase 1 (WS-G)
Extends the WS-A1 iframe adapter with image-alpha hit-testing:
- Replace `elementFromPoint` with `elementsFromPoint` (z-stack) so
a transparent-image hit falls through to the layer behind.
- For `<img>` hits: map client point → natural-pixel coords via a
pure `mapPointToImagePixel` fn (object-fit cover/contain/fill aware);
draw to an offscreen canvas once (cached by `currentSrc`); sample
alpha via pure `alphaIsOpaque`. Transparent pixel → miss, continue
the stack.
- Cross-origin images that taint the canvas → SecurityError fallback
→ treat pixel as opaque (never drop an unverifiable hit).
- Phase 2 (per-pixel alpha via `drawElement`) NOT built; gated on a
perf spike per plan.
Tests: alphaIsOpaque thresholds, mapPointToImagePixel (fill/cover/contain
+ out-of-box→null), z-stack fallthrough, taint→opaque fallback, non-image
WS-A1 regression.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sdk): correct image-alpha hit-test edge cases (WS-G review)
- map within the content box, not the border box (object-fit positions
the image inside border+padding; getBoundingClientRect was off for a
bordered/padded <img>)
- normalize vertical-first object-position keyword pairs ("bottom left")
- guard natural.width/height===0 in cover/contain (Infinity/NaN scale)
- fall back to elementFromPoint when elementsFromPoint is unavailable
- guard `instanceof win.HTMLImageElement` when the constructor is absent
- bound _imgCanvasCache with a FIFO cap so it can't leak one canvas per src
- drop the redundant taint-probe getImageData; the real pixel read already
surfaces lazy taint
- one opacity walk per candidate: the hf node is on the already-checked
ancestor chain, so the resolver no longer re-walks for visibility
- remove dead `fit==="fill"||` clause and unused resolveToken param
- tests: none-fit, object-position keyword/px/reversed-pair, zero-natural,
no-throw-without-HTMLImageElement + elementsFromPoint fallback
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sdk): image-alpha review fixes (WS-G) — transform/taint/cache-key/memory
Addresses the phase-1 gaps flagged in review (was documentation-only):
- CSS rotation/skew on the image or an ancestor now fails safe to opaque instead
of sampling the wrong pixel (getBoundingClientRect is axis-aligned). Full
transform-inverse mapping stays phase 2. No-op where DOMMatrix is unavailable.
- Cross-origin canvas taint now warns once per src (was silent) so the
fall-back-to-opaque path is visible, not "hit-test feels wrong".
- Canvas cache keyed on src + natural dimensions (was src only) so a
srcset/responsive re-render of the same URL doesn't reuse a stale canvas.
- Pathological-size guard: images above a pixel budget skip alpha-testing
(opaque) to bound OffscreenCanvas memory.
- Docs: border/padding clicks fall through (intentional) noted on imageAlphaOpaqueAt.
Tests: removed the duplicate a=0 threshold case; added transparent-over-
transparent-over-div fallthrough. iframe.test.ts 59/59. tsc/oxlint/oxfmt green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses the still-outstanding review concerns from merged PRs #1569 / #1570 /
#1572 not already hoisted into #1573.
WS-B (#1569):
- validateVariables requires discriminant fields for object-valued font/image
({name,source} / {url}); a {name:42} font or {foo:42} image previously passed
runtime validation and surfaced as a bogus font-family / missing image.
- Dropped ImageValue's [key:string]:unknown index signature (let any {url}-shaped
object through, swallowed typos); explicit alt?/fit? instead.
- Documented the OverrideSet widening for SDK consumers.
WS-C (#1570):
- getElementTimings caches parsed GSAP labels by exact script text (avoids a full
acorn re-parse per read; content-key invalidates on edit).
- Documented end-inclusive label window + best-effort extractGsapLabels catch.
WS-3.C (#1572):
- Added typed Composition.addWithKeyframes / replaceWithKeyframes (was asymmetric
with addGsapTween; Studio had to use raw dispatch).
- Extracted shared KeyframeSpec type; documented position as seconds/number-only.
Gates: build + core 18/18 + sdk 19/19 + oxlint + oxfmt + fallow + typecheck all green.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sdk): ws-b variables/brand — object-valued font/image + B1 JSON model
B1 — setVariableValue now drives the runtime JSON model
(data-composition-variables) so preview == render. CSS custom prop is
kept as a secondary compat write for compositions that CSS-bind directly
to --{id}.
B2 — object-valued font ({name, source}) and image ({url}) variable
types added core-to-SDK. Object values write to the JSON model only;
scalars write both model + explicit CSS style patches. Explicit style-path
patches in forward/inverse ensure apply-patches.ts handles each path type
purely (model vs CSS), so inverse patches restore exact pre-call state
without ambiguity.
Changed files:
packages/core/src/core.types.ts — font/image to CompositionVariableType + interfaces
packages/core/src/lint/rules/composition.ts — accept font/image in lint message
packages/core/src/parsers/htmlParser.ts — validate font/image variable declarations
packages/core/src/parsers/htmlParser.test.ts — tests for new variable types
packages/core/src/runtime/validateVariables.ts — checkType for font/image
packages/sdk/src/types.ts — FontValue/ImageValue; widen OverrideSet + EditOp
packages/sdk/src/index.ts — re-export FontValue/ImageValue
packages/sdk/src/session.ts — widen setVariableValue signature
packages/sdk/src/engine/patches.ts — valueChange helper for object-valued patches
packages/sdk/src/engine/mutate.ts — handleSetVariableValue: B1+B2 with explicit CSS patches
packages/sdk/src/engine/apply-patches.ts — variable case: model-only (CSS via explicit patch)
packages/sdk/src/engine/mutate.test.ts — B1+B2 round-trip + inverse tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sdk): ws-c elastic timing + word-alignment resolver (WS-C)
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>
* feat(sdk): addElement forward op — mint hf-id, inverse = removeElement (WS-D)
Implements WS-D: the addElement EditOp and session.addElement() typed method.
- types.ts: addElement op (parent/index/html) added to EditOp union;
addElement(parent, index, html): HfId added to Composition interface
- mutate.ts: handleAddElement inserts a single-root HTML fragment at
parent+index, minting ids against the LIVE document's existing id set
(not a fresh fragment set) via collectDocumentHfIds + mintFragmentIds;
forward = patchAdd, inverse = patchRemove; MutationResult.meta.newId
carries the minted root id
- mutate.ts: validateOp case rejects missing parent, negative index,
empty html, zero-element html, and <script> in html
- session.ts: typed addElement(parent, index, html) returns minted id
via result.meta.newId
- mutate.test.ts: 16 tests covering insert position, append semantics,
id uniqueness, content-collision rehash, nested fragments, forward/
inverse symmetry, undo, add/undo/redo stability, parent:null body
insertion, serialize round-trip, and all five validateOp rejection codes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sdk): ws-3c — addWithKeyframes + replaceWithKeyframes SDK ops (acorn writer)
Port add-with-keyframes / replace-with-keyframes from the server recast path
to the acorn/magic-string writer and expose them as typed SDK EditOps.
- gsapWriterAcorn.ts: extend buildKeyframeObjectCode and
addAnimationWithKeyframesToScript to accept `auto?: boolean` on keyframes
(emits `_auto: 1` matching the recast writer)
- types.ts: add `addWithKeyframes` and `replaceWithKeyframes` to EditOp union
- mutate.ts: add handleAddWithKeyframes, handleReplaceWithKeyframes, and
applyGsapWithKeyframesOp dispatch sub-function; validateOp cases for both
- sdkCutover.ts: add sdkAddWithKeyframesPersist + sdkReplaceWithKeyframesPersist
(shared via dispatchWithKeyframes to eliminate clone)
- useGsapAnimationOps.ts: wire addWithKeyframes + replaceWithKeyframes
callbacks with SDK-first / server fallback pattern
- gsapWriter.parity.test.ts: add parity tests for _auto endpoint round-trip and
replaceWithKeyframes (remove + addWithKeyframes) differential golden harness;
import removeAnimationFromScript from both writers
Landmine note: tween IDs are position-derived — replaceWithKeyframes removes the
old tween (renumbering survivors) then inserts the replacement at the end; the
MutationResult patch pair restores the whole GSAP script on undo, not a per-ID
inverse, so ID-held references in callers must re-parse after structural edits.
Gate: WS-3.F (retire recast / executeGsapMutation) — NOT started here.
Remaining Studio callers (gsapDragCommit, gsapRuntimeBridge, useGestureCommit,
useEnableKeyframes) remain on the server commitMutation path until WS-3.F.
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): ws-c elastic timing + word-alignment resolver (WS-C)
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>
* feat(sdk): addElement forward op — mint hf-id, inverse = removeElement (WS-D)
Implements WS-D: the addElement EditOp and session.addElement() typed method.
- types.ts: addElement op (parent/index/html) added to EditOp union;
addElement(parent, index, html): HfId added to Composition interface
- mutate.ts: handleAddElement inserts a single-root HTML fragment at
parent+index, minting ids against the LIVE document's existing id set
(not a fresh fragment set) via collectDocumentHfIds + mintFragmentIds;
forward = patchAdd, inverse = patchRemove; MutationResult.meta.newId
carries the minted root id
- mutate.ts: validateOp case rejects missing parent, negative index,
empty html, zero-element html, and <script> in html
- session.ts: typed addElement(parent, index, html) returns minted id
via result.meta.newId
- mutate.test.ts: 16 tests covering insert position, append semantics,
id uniqueness, content-collision rehash, nested fragments, forward/
inverse symmetry, undo, add/undo/redo stability, parent:null body
insertion, serialize round-trip, and all five validateOp rejection codes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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)
* feat(sdk): ws-3 — reorderElements op (batch z-index update)
Adds the reorderElements EditOp: each entry sets inline zIndex on one element.
Last-write-wins per target so a duplicated target collapses to a single zIndex
patch. Positioning is unchanged — z-index only takes effect on non-static
elements, so the caller must ensure the target is positioned.
Also fixes single-dispatch undo to reverse the inverse patch list (parity with
batch()): an op emitting multiple patches whose undo order matters — a duplicated
reorderElements target, an aliased multi-target, or a nested parent+child
removeElement — must undo in reverse application order, or undo lands on an
intermediate value / drops a subtree.
validateOp resolves every entry target (E_TARGET_NOT_FOUND for unknown ids;
empty entries is a clean no-op). Tests cover set/inverse/validate/duplicate-target.
Rebuilt standalone on main (reorderElements only depends on handleSetStyle).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(sdk): remove unused HTTP persist adapter
The HTTP PersistAdapter (createHttpAdapter) was dead weight after the studio
cutover went single-writer (ws-4): Studio's writeProjectFile is the sole writer
and useSdkSession opens with no persist queue, so the adapter's write/flush/
listVersions/loadFrom were never used — only read() was, to fetch the
composition source. Replace those two read() calls with a direct optional fetch
(GET /files/<path>?optional=1) and drop the adapter + its export-map entries.
Saved for later re-introduction (when a non-Studio SDK host needs server-backed
persist) at docs/hyperframes/plans/sdk-http-adapter/ (outside the repo).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(studio): resolver shadow for z-index reorder targets
The z-index reorder commit takes the server path (no SDK persist), but the
resolver-shadow tripwire is decoupled from cutover — so it should still record
whether the SDK resolves each reordered element (reorderElements' targets), the
same as timing/delete already do before their cutover gate. This gives wild
resolver-parity telemetry on z-index targets before z-index reorder is cut over.
Threads an onReorderShadow callback (sdkSession-bound, mirrors onTrySdkDelete)
from useDomEditSession → useDomEditCommits → useElementLifecycleOps, called with
the reordered elements' hf-ids in handleDomZIndexReorderCommit. Read-only,
divergence-only, never throws — same contract as recordResolverParity elsewhere.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(studio): guard project-file read path against traversal (CodeQL CSRF)
readProjectFileOptional interpolated a user-influenced composition path into the
fetch URL, which CodeQL flagged as client-side request forgery. Reject NUL/`..`
up front (mirrors the existing guard in timelineEditingHelpers) and
encodeURIComponent the projectId too, so both values stay confined to single
segments of the same-origin URL. Unsafe path → undefined (graceful for the
optional read).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sdk,studio): R5 cutover review fixes — fromTo dest, timing sync, parity
Confirmed correctness findings from the R5 review of the SDK cutover stack,
applied on top of #1539:
- fromTo add via cutover dropped its destination: handleAddGsapTween read only
`toProperties`; now falls back to `properties` like every other method.
- handleSetTiming GSAP sync: a clip with no data-start skipped the shift (now
treats start as 0, matching the server path) and a blank/non-numeric
data-start wrote position: NaN (now sanitized).
- handleSetTiming no longer appends an absolute position to an auto-sequenced
(implicit-position) tween, which collapsed staggers.
- handleSetTiming keeps data-end in sync when a clip carries BOTH data-duration
and data-end (a stale data-end inverted the clip).
- string/relative tween positions ("+=0.5", "<") documented as a known ceiling.
- opacity/autoAlpha property seed no longer falsy-zero (`|| 1`): an element at
opacity 0 seeds 0, not 1.
- optimistic add-keyframe cache tolerance aligned to the writer's PCT_TOLERANCE
(2%) so a near-neighbour keyframe no longer shows then vanishes on reload.
- DOM-patch finiteness validation runs before the SDK cutover path.
- attribute ops mapping to a reserved data-* name decline the cutover up front
instead of throwing inside dispatch.
Regression tests added for each SDK-side fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(studio): close two gaps in the reserved-attr cutover gate
- Lowercase the mapped attribute name before the reserved check, matching the
SDK's validateSetAttribute (which lowercases), so a case-variant reserved
name is declined up front instead of throwing inside dispatch.
- Also gate `html-attribute` ops (raw, non-prefixed names), not just bare
`attribute` ops. Both the emitter and the gate now derive the name via one
shared `sdkAttrName` helper so they can't drift.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(studio): match keyframe remove-path tolerance to the writer (mirror of add)
The optimistic remove-keyframe cache filtered with `> 0.001`, dropping only a
near-exact match, while the writer removes within PCT_TOLERANCE (2). Removing
at e.g. 49% dropped a 50% keyframe on disk but left it in the cache — a phantom
that vanished on reload, the inverted twin of the add-path tolerance fix.
Now filters with `> 2` to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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>