Commit Graph
100 Commits
Author SHA1 Message Date
Vance Ingalls b33a7457a7 chore: release v0.6.119 (#1620) 2026-06-21 01:03:18 -07:00
Vance Ingalls 341e65aea2 fix(slideshow): harden media controls in present decks (#1619) 2026-06-20 23:35:58 -07:00
Vance IngallsandClaude Opus 4.7 f0c4dee705 fix(slideshow): present media controls (#1601)
* fix(slideshow): harden media controls in present decks

* refactor(slideshow): clear Fallow audit findings

Decompose flagged high-CRAP functions and extract production-code
duplications so the audit gate clears.

- core/runtime/bridge.ts handler — replace the 14-branch if-chain with a
  CONTROL_HANDLERS dispatch table; flash-elements payload handling moves
  to its own helper. Behavior preserved (all existing bridge.test.ts
  cases hit the same dispatchers via the public installRuntimeControlBridge
  API).

- player/slideshow/SlideshowController syncTo — split into
  isValidSyncTarget / isCrossSlide / rerootStackTo helpers. The
  stopSlideMedia decision and the stack re-rooting are now individually
  named; the public method is a 4-line orchestrator.

- cli/commands/validate.ts run — extract emitJsonReport / emitTextReport
  so the orchestrator no longer carries the dual JSON/text branches.
  Cuts the cyclomatic complexity flagged by fallow after the
  shouldIgnoreRequestFailure signature expansion shifted the fingerprint.

- player/hyperframes-player.ts — _setIframeMediaMuted and _stopIframeMedia
  shared a `try { iframeDoc = contentDocument } catch { return }` preamble
  (clone group 15). Extract _getSameOriginIframeDocument(): Document | null
  and have both call sites consume it.

- studio/panels/SlideshowPanel.tsx — the notes controller's debounce-tail
  and explicit flush() shared the pending-drain pattern (clone group 16).
  Extract a drainPending() closure both call.

- player/hyperframes-player.test.ts — collapse the new stopMedia / muted
  tests' repeated Object.defineProperty(iframe, "contentDocument", { get })
  shape behind a stubIframeContentDocument helper.

No behavior changes — refactor only. Existing tests cover the affected
paths unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(validate): split run further; ignore test dup parity

Second Fallow pass surfaced two minor follow-ups after the first cut:

- packages/cli/src/commands/validate.ts run + emitTextReport still
  carried minor CRAP findings (43.1 / 37.1, threshold 30). Extract
  printValidationResult / formatConsoleEntry / formatTotals /
  emitFailureReport so run becomes a try/catch + delegation, well
  below the threshold; emitTextReport drops the inline format loops.

- .fallowrc.jsonc duplicates.ignore: add hyperframes-player.test.ts
  alongside the existing SlideshowPanel.test.ts entry. Same reasoning
  documented there — parallel arrange/act/assert test cases are
  intentionally self-contained for readability; collapsing them under
  shared fixtures would couple unrelated scenarios (same-origin vs
  realm media, audio-locked permutations, seek bridge variants).

No behavior changes — refactor + config-policy parity only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-19 17:17:39 -07:00
Vance Ingalls 4e32c5e0fe chore: release v0.6.114 2026-06-19 04:38:05 -07:00
Vance Ingalls f05b3f9c7c fix(slideshow): finish remaining split-PR review findings (#1594)
* fix(slideshow): address split-PR review findings on #1585

Genuinely-open findings from the #1580/#1590/#1591/#1592 reviews (the rest were
already fixed on this branch: CSP handlers, manifest version, UUID ids, float
keys, presenter 1s-timer):

core (#1580):
- isManifest rejects a non-object/array manifest (e.g. [42,null]) explicitly
- resolveSlideshow flags duplicate slideSequence ids instead of silent overwrite

player (#1590):
- present() window.open uses noopener,noreferrer (audience syncs via channel)
- BroadcastChannel name is per-deck (keyed on pathname) to avoid same-origin
  cross-talk between decks
- add observedAttributes + attributeChangedCallback so runtime sound/mode toggles
  re-render

studio (#1591/#1592):
- persistSlideshowManifest no-op gate (skip write when HTML is unchanged)
- surface persist failures (console.error) instead of silent .catch(()=>{})
- confirm before deleting a branch sequence (data-loss + dangling hotspots)

+ tests for the collision + non-object-manifest rejection. 20 core / 106 player /
53 studio pass; tsc/lint/fmt/fallow clean; deck still renders.

* fix(slideshow): finish remaining split-PR review findings

The larger items from the #1580/#1590/#1591/#1592 reviews (the rest landed in
#1585):

core (#1580):
- dedup isSceneLikeCompositionId — shared slideshow/sceneId.ts, used by both the
  lint rule and the runtime scene-window computation (no more mirror-and-drift)

player (#1590 / #1592):
- onKey: when multiple decks share a page, drop the unfocused-convenience so a
  key drives only the focused deck
- slow-iframe recovery: if the scene timeline posts after the wait times out
  (empty scenes), re-init once so sceneId slides resolve instead of being dropped

studio (#1591):
- persistSlideshowManifest validates the built island round-trips before writing
- reorderBranchSlide helper + BranchTree up/down controls (parallel to main-line
  reorder), with a branch-position indicator

+ tests for reorderBranchSlide. core 228 / player 106 / studio (panel) 46 pass;
tsc/lint/fmt/fallow clean.

* fix(player,cli): use fileURLToPath for path resolution (Windows CI)

new URL(...).pathname yields a leading-slash drive path ("/D:/...") on Windows,
which broke:
- packages/player/vitest.config.ts — the @hyperframes/core/slideshow alias
  resolved to a nonexistent path, failing the player slideshow tests on the
  Windows render-verification CI (passed on macOS/Linux where pathname is clean)
- packages/cli/src/utils/compositionServer.ts helperDir — same bug in the
  play/present bundle-path resolution

fileURLToPath converts file:// URLs to correct OS paths on all platforms.
Player slideshow tests pass; present serves + resolves bundles.

* fix(producer): fileURLToPath for the renders dir (Windows)

DEFAULT_RENDERS_DIR used new URL(import.meta.url).pathname, which is "/D:/..."
on Windows and resolves to a bogus path — affects the Windows render pipeline.
Last of the .pathname -> fileURLToPath fixes (repo-wide src sweep now clean).
2026-06-19 04:31:12 -07:00
Vance IngallsandClaude Opus 4.8 cc2220e59e fix(slideshow): address code-review findings #1580-1584 (#1585)
* fix(slideshow): address code-review findings #1580-1584

- player: bundle @hyperframes/core into the IIFE/global build (noExternal)
- player: resolve audience mode from ?mode=audience URL query, not just attr
- player: event-driven waitForScenes + loud failure when no slides resolve
- player: scope window keydown so Space/Backspace don't hijack the host page
- player: audience mirrors full position (branch + fragment) via syncTo
- player: next() reveals remaining fragments even at slide end; enterBranch ignores empty sequences
- core: harden extractScenes against null/non-object scene entries
- core: strict manifest validation; error on inverted ranges & empty hotspot targets; dedup fragments
- core/lint: accept data-end/timeline-derived scene durations (match runtime)
- core+studio: share ISLAND_TYPE + island regex from @hyperframes/core/slideshow
- studio: SlideList reflects manifest slide order; branch-slide authoring (notes/fragments/hotspots)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(player): slideshow fullscreen + presenter-view rework

- fullscreen toggle in the nav chrome (button + 'F' key); standard Fullscreen
  API on the <hyperframes-slideshow> element, icon reflects state
- presenter console: live slide on top, speaker-notes panel below, with the nav
  controls shown in-view; Present button hides once presenting (harness)
- audience (viewer) window: chrome reduced to a fullscreen-only control, no nav
- fix: audience / back() / backToMain() mirror stayed frozen on the first frame —
  a bare paused seek does not repaint some compositions. resumeSlide now plays a
  brief render-nudge (RENDER_NUDGE) past the target so the composition paints,
  then onTime pauses at the hold
- refactor: extract reusable buildNavCluster() + wireChromeButtons(); rework
  buildPresenterLayout into the bottom notes panel
- example: airbnb-deck presenter-test.html harness (Present button + 'F')

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(player): slideshow no auto-progress + presenter slide fits/pins

- navigation jumps to a static frame instead of auto-playing the timeline:
  playTo() seeks to the hold (+ a brief RENDER_NUDGE to repaint) rather than
  sustaining playback, so slides hold until the user advances
- presenter view: pin the live slide to the top and confine the player to the
  region above the notes panel, so the player CONTAINS the composition — the
  full slide stays visible (letterboxed) at any width and re-fits on resize;
  its bottom is no longer cut off by the notes panel
- tests: seek targets updated for the render-nudge offset

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(slideshow): presenter nav flash, slide-1 boundary, branch buttons

Three presenter-mode fixes from testing the airbnb deck: (1) navigation flash — seek to the exact target then play forward to repaint, instead of seeking backward (t-0.2) which painted the previous scene at boundaries; split hold into holdTarget (logical) and holdAt (target+nudge, clamped to slide.end). (2) slide-1 boundary — no-fragment slides rest at the slide midpoint, not slide.end. (3) presenter branch buttons — surface hotspots as buttons in the presenter console (the on-slide pill is lost in the letterboxed view). Also extract paintChrome() to dedupe the three chrome-render sites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(slideshow): stop presenter nav buttons flickering / dropping clicks

The presenter elapsed clock called render() every second, which rebuilt the
entire chrome (innerHTML) including the nav buttons — they flickered and any
click landing mid-rebuild was lost. The 1s tick now updates only the elapsed
text node; the nav buttons are rebuilt only on actual navigation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(slideshow): CSP-safe nav hover, UUID editor ids, manifest version

Addresses review feedback on the split stack:

- CSP: replace the 8 inline onmouseover/onmouseout handlers on the nav
  buttons with a [data-hf-nav-cluster] button:hover CSS rule (injected once
  per document). No inline event handlers → works under strict CSP.
- IDs: studio sequence/hotspot id generation used Date.now() (sub-ms
  collision on rapid clicks) — now crypto.randomUUID().
- Versioning: stamp version on the persisted manifest island (preserving an
  existing one); add the optional version field + SLIDESHOW_MANIFEST_VERSION
  to the core schema so future schema changes can migrate older islands.

These live on the review-fixes tip (consistent with the stack's fixup-on-tip
model); the touched code belongs to ss-player-b (#1590), ss-studio-a/b
(#1591/#1592), and ss-core (#1580).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(ci): fix format + fallow gates for slideshow stack

- .prettierignore: exclude generated demo compositions (registry/examples/**/*.html)
  from oxfmt — large video-pipeline output (GSAP/Three/WebGL), not hand-authored
  source. Was failing 'Format' repo-wide (pre-existing on main via #1584).
- .fallowrc: exempt SlideshowPanel.tsx (health/complexity — section fan-out) and
  the slideshowPanelHelpers.ts / SlideshowPanel.test.ts parallel-structure clones
  (duplicates.ignore). File-level config, not inline comments — inline shifts line
  numbers and breaks fallow's inherited-finding fingerprint (per existing rc note).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(slideshow): address PR review + CodeQL findings

- CodeQL #638 (parseSlideshow): complete the regex metachar escape in
  slideshowIslandRegex (was missing backslash); add JSDoc on the factory +
  lastIndex caveat (reviewer 5a/16).
- CodeQL #639/#640 + review items 13/17: remove registry/examples/airbnb-deck/
  presenter-test.html — a generated test harness (postMessage w/o origin check,
  proto-pollution) that was scope-creep into a fix PR and a 3rd duplicate island.
  Regenerate locally via the scratchpad script when testing.
- Review item 15 (docs drift in skills/slideshow/SKILL.md): lint resolves scenes
  by data-composition-id only (not .clip[id]); fragments are valid INCLUSIVE of
  [start,end], not 'strictly inside'.

IIFE bundles core confirmed (0 external @hyperframes/core refs in the slideshow
global build). format/lint/fallow green.

* feat(cli): add 'present' command — serve a deck in presenter mode

hyperframes present [dir] starts a lightweight HTTP server, wraps the
composition in <hyperframes-slideshow> with its island inlined, and opens
the browser. A real HTTP origin is required for presenter mode: present()
opens the audience window via window.open(?mode=audience) and the two sync
over BroadcastChannel — neither works from file://.

- New utils/compositionServer.ts factors the server scaffolding shared with
  'play' (resolve runtime/player/slideshow bundles, inject runtime, asset
  content-types, bind to a free port); play.ts now uses it too.
- Errors clearly if the deck has no slideshow island.
- .fallowrc: exempt the play/present command entrypoints (validation + server
  wiring) and the per-command startup/logging block from the complexity /
  duplication gates.

Verified end-to-end against registry/examples/airbnb-deck: server serves the
wrapper + assets, the component binds and renders (counter 1 / 11).

* fix(cli): present renders the deck (player sizing + self-driving serve)

Two bugs caused a black slide area:
- The <hyperframes-player> had no positioning, so its iframe collapsed to
  zero size — the (absolutely-positioned) chrome showed but the composition
  didn't. Add position:absolute; inset:0 (matches demo.html).
- The composition was served with the engine runtime injected, which leaves
  its timelines engine-paused (blank). Slideshow decks self-drive their own
  timelines (like demo.html / the standalone harness), so serve them raw.

Verified end-to-end on registry/examples/airbnb-deck: cover renders, Next
advances 1/11 -> 2/11 and slide 2 paints.

* fix(cli): present plays slideshow sound effects

The composition (in the player's sandboxed iframe) posts
{ type: 'hf-sfx', name } to the parent on nav, but the iframe is
autoplay-blocked — audio must play in the parent that owns the user gesture.
Add the parent-side hf-sfx handler (the 4 standard clips advance/fragment/
branch-enter/back, served from the deck's sfx/ under /composition/sfx/),
gesture-unlocked and mute-aware, in both presenter and audience windows.

Verified: sfx serve 200 (audio/mpeg) and Next delivers [advance, fragment]
to the parent handler.

* feat(examples): softer mellow slideshow sfx for airbnb-deck

Replace the aggressive percussive pops with gentle sine-tone cues (warm
pitches C5/G4/E5/F4, 12ms attack + exponential decay, lowpassed) — advance/
fragment/branch-enter/back. Much lighter; fragment is the most subtle.

* feat(examples): whoosh + sparkle slideshow sfx for airbnb-deck

Replace the sine-tone cues with airy, designed sounds:
- advance: a soft whoosh (band-limited pink noise, bell-shaped swell)
- back: that whoosh reversed and darkened
- fragment: a light sparkle (staggered high chime blips)
- branch-enter: whoosh + a trailing sparkle (magical entry)

* feat(examples): directional whoosh + richer branch-enter cue (airbnb-deck)

- Going backward a slide now plays the reverse whoosh (back), not advance —
  the sfx logic detects nav direction by scene order instead of firing advance
  for every scene change.
- branch-enter is now a more interesting magical cue: a faint whoosh + an
  ascending C5-E5-G5-C6 chime arpeggio + a trailing sparkle.

Verified: next then prev fires [advance, fragment, back]; no page errors.

* fix(cli): harden present sfx handler + mute-hover affordance (R2 review)

Addresses Rames R2 items 19-21:
- 20: the present audio handler reintroduced the CodeQL classes removed with
  presenter-test.html — add an origin check (same-origin composition iframe)
  and an own-property guard so a 'name' like __proto__ can't resolve to and
  mutate Object.prototype.
- 21: assetContentType used a bare index lookup (ext='__proto__' -> prototype);
  guard with Object.hasOwn.
- 19: the CSP hover rule erased the speaker button's muted color; add a
  higher-specificity [data-hf-muted] [data-hf-mute]:hover override.

Verified: hf-sfx origin matches location.origin (guard passes), advance/fragment
still fire, deck renders + advances. Items 14/18/22 deferred (minor, pre-existing).

* fix(slideshow): address remaining R2 items (14/18/22) + re-remove harness

- 14: resumeSlide now mirrors enterSlide — a no-fragment slide resumes at its
  midpoint (visible-at-rest), not frame-0; fragmented slides still resume to the
  saved fragment or slide.start. Added a dedicated test naming the heuristic.
- 18: fullscreenchange swaps only the fullscreen glyph + aria (hoisted SVGs to
  module consts) instead of re-rendering the whole chrome.
- 22: .prettierignore lists the specific generated demo compositions instead of
  blanket registry/examples/**/*.html, so hand-authored example HTML still formats.
- presenter-test.html: a stray 54a4460 git add -A had re-added the deleted
  harness (reviving CodeQL #639/#640); remove it again.

106 slideshow tests pass; tsc/lint/fallow/format clean; deck still renders.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 03:26:49 -07:00
Vance IngallsandClaude Opus 4.8 ae40498433 test(examples): slideshow demos — airbnb deck, startup pitch, fixture (#1584)
* feat(player): slideshow controller state machine

Stack-split from the original ss-player PR (#1581): the SlideshowController
state machine (stack-based slide/fragment/branch navigation) and its tests.
The <hyperframes-slideshow> web component follows in the next PR.

* feat(player): <hyperframes-slideshow> web component + presenter

Stack-split from the original ss-player PR (#1581): the <hyperframes-slideshow>
custom element (wraps <hyperframes-player>, drives the controller),
presenter/audience BroadcastChannel sync, nav chrome, and the player scenes hook.

* feat(studio): slideshow manifest persistence + panel helpers

Stack-split from the original ss-studio PR (#1582): the data layer —
setSlideshowManifest, the useSlideshowPersist hook, and panel helpers.
The editor panel UI follows in the next PR.

* feat(studio): slideshow branching editor panel UI

Stack-split from the original ss-studio PR (#1582): the SlideshowPanel /
SlideshowSubPanels editor UI, right-panel wiring, and app integration.

* docs(skill): slideshow authoring guidance + standalone harness reference

New /slideshow skill (island schema, slide rules, fragments, branching,
validation) + a standalone-harness reference doc, and a router entry in
the /hyperframes skill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(examples): slideshow demos — airbnb deck, startup pitch, fixture

Three runnable slideshow compositions: a current-Airbnb-branded remake of
the 2009 seed deck (Three.js backgrounds, GSAP entrances, hotspot branch,
HeyGen SFX), an animated startup pitch, and a minimal fixture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 01:35:23 -07:00
Vance IngallsandClaude Opus 4.8 b7a1163753 docs(skill): slideshow authoring guidance + standalone harness reference (#1583)
* feat(player): slideshow controller state machine

Stack-split from the original ss-player PR (#1581): the SlideshowController
state machine (stack-based slide/fragment/branch navigation) and its tests.
The <hyperframes-slideshow> web component follows in the next PR.

* feat(player): <hyperframes-slideshow> web component + presenter

Stack-split from the original ss-player PR (#1581): the <hyperframes-slideshow>
custom element (wraps <hyperframes-player>, drives the controller),
presenter/audience BroadcastChannel sync, nav chrome, and the player scenes hook.

* feat(studio): slideshow manifest persistence + panel helpers

Stack-split from the original ss-studio PR (#1582): the data layer —
setSlideshowManifest, the useSlideshowPersist hook, and panel helpers.
The editor panel UI follows in the next PR.

* feat(studio): slideshow branching editor panel UI

Stack-split from the original ss-studio PR (#1582): the SlideshowPanel /
SlideshowSubPanels editor UI, right-panel wiring, and app integration.

* docs(skill): slideshow authoring guidance + standalone harness reference

New /slideshow skill (island schema, slide rules, fragments, branching,
validation) + a standalone-harness reference doc, and a router entry in
the /hyperframes skill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 01:35:09 -07:00
Vance Ingalls 04a775b58a feat(studio): slideshow branching editor panel UI (#1592)
* feat(player): slideshow controller state machine

Stack-split from the original ss-player PR (#1581): the SlideshowController
state machine (stack-based slide/fragment/branch navigation) and its tests.
The <hyperframes-slideshow> web component follows in the next PR.

* feat(player): <hyperframes-slideshow> web component + presenter

Stack-split from the original ss-player PR (#1581): the <hyperframes-slideshow>
custom element (wraps <hyperframes-player>, drives the controller),
presenter/audience BroadcastChannel sync, nav chrome, and the player scenes hook.

* feat(studio): slideshow manifest persistence + panel helpers

Stack-split from the original ss-studio PR (#1582): the data layer —
setSlideshowManifest, the useSlideshowPersist hook, and panel helpers.
The editor panel UI follows in the next PR.

* feat(studio): slideshow branching editor panel UI

Stack-split from the original ss-studio PR (#1582): the SlideshowPanel /
SlideshowSubPanels editor UI, right-panel wiring, and app integration.
2026-06-19 01:34:56 -07:00
Vance Ingalls b74020fa56 feat(studio): slideshow manifest persistence + panel helpers (#1591)
* feat(player): slideshow controller state machine

Stack-split from the original ss-player PR (#1581): the SlideshowController
state machine (stack-based slide/fragment/branch navigation) and its tests.
The <hyperframes-slideshow> web component follows in the next PR.

* feat(player): <hyperframes-slideshow> web component + presenter

Stack-split from the original ss-player PR (#1581): the <hyperframes-slideshow>
custom element (wraps <hyperframes-player>, drives the controller),
presenter/audience BroadcastChannel sync, nav chrome, and the player scenes hook.

* feat(studio): slideshow manifest persistence + panel helpers

Stack-split from the original ss-studio PR (#1582): the data layer —
setSlideshowManifest, the useSlideshowPersist hook, and panel helpers.
The editor panel UI follows in the next PR.
2026-06-19 01:34:42 -07:00
Vance Ingalls 075302cd03 feat(player): <hyperframes-slideshow> web component + presenter (#1590)
* feat(player): slideshow controller state machine

Stack-split from the original ss-player PR (#1581): the SlideshowController
state machine (stack-based slide/fragment/branch navigation) and its tests.
The <hyperframes-slideshow> web component follows in the next PR.

* feat(player): <hyperframes-slideshow> web component + presenter

Stack-split from the original ss-player PR (#1581): the <hyperframes-slideshow>
custom element (wraps <hyperframes-player>, drives the controller),
presenter/audience BroadcastChannel sync, nav chrome, and the player scenes hook.
2026-06-19 01:34:20 -07:00
Vance Ingalls 6938d6acf2 feat(player): slideshow controller state machine (#1589)
Stack-split from the original ss-player PR (#1581): the SlideshowController
state machine (stack-based slide/fragment/branch navigation) and its tests.
The <hyperframes-slideshow> web component follows in the next PR.
2026-06-19 01:34:05 -07:00
Vance IngallsandClaude Opus 4.8 3861e8e9fc feat(studio): slideshow branching editor panel (#1582)
New Slideshow right-panel tab — slide list, inspector (notes + fragment
hold-points), branch tree, hotspot tool — backed by pure manifest-transform
helpers and a debounced SDK persist that writes the JSON island.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 01:33:31 -07:00
Vance IngallsandClaude Opus 4.8 7af3eb8f80 feat(player): slideshow controller + <hyperframes-slideshow> component (#1581)
DOM-free SlideshowController (discrete nav, fragment holds, branch stack)
driving the existing player; <hyperframes-slideshow> web component with a
unified mute+nav capsule (conditional prev/next), floating hotspot overlays,
presenter mode (BroadcastChannel), keyboard/touch, and a scenes getter fed
via the runtime message handler.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 01:31:27 -07:00
Vance Ingalls a38fc4e778 chore: release v0.6.113 2026-06-19 00:35:41 -07:00
Vance IngallsandClaude Opus 4.8 3a28d3f6b8 fix(release): scope tag-monotonicity guard to tags reachable from HEAD
The guard blocked on any semver-higher v* tag, including orphan tags on dead
branches (e.g. a stray `chore: release v1.0.3` never merged or published).
Such tags can't appear in the release history and shouldn't block a legitimate
release. Now only tags that are BOTH higher AND reachable from HEAD block;
extracted `findBlockingTags` with unit coverage for orphan/reachable/lower cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 00:34:58 -07:00
Vance Ingalls 8376457989 feat(core): slideshow schema, parser, and lint rule (#1580)
## Slideshow mode — 1/5: core schema, parser & lint

Foundation for slideshow mode: a composition can declare an embedded **slideshow manifest** that turns its continuous timeline into a discrete, navigable deck. This PR adds the data model, parser, and validation — no runtime/UI yet.

### What & why
A slide is just an existing scene (`data-composition-id` + `data-start`/`data-duration`) plus metadata declared in one embedded `<script type="application/hyperframes-slideshow+json">` island. Keeping the manifest *in the composition* means no new file format and no build step — slides are additive metadata over a normal composition.

### Key changes
- `slideshow/slideshow.types.ts` — `SlideshowManifest`, `SlideRef`, `SlideHotspot`, `SlideSequence` and their resolved counterparts. TTS fields (`ttsScript`/`ttsAudioUrl`/`ttsDurationMs`) are present but **reserved** (playback not built).
- `slideshow/parseSlideshow.ts` — `parseSlideshowManifest(html)` extracts the island; `resolveSlideshow(manifest, scenes)` resolves each `sceneId` to a `{start,end}` range (honouring optional `startTime`/`endTime` overrides) and returns validation errors for: unresolved sceneId, fragment outside a slide's range, hotspot targeting an unknown sequence, and overlapping main-line slides.
- `lint/rules/slideshow.ts` — surfaces those resolve errors under `hyperframes lint`; derives scenes from `data-composition-id` (matching the runtime's scene source).
- `lint/rules/core.ts` — exempts the slideshow island MIME type from the inline-script-syntax check.
- `./slideshow` subpath export (dev + publishConfig) so downstream packages import only the lightweight parser, keeping core's Node-only barrel out of their typecheck graph.

### Testing
`parseSlideshow.test.ts` + `slideshow.test.ts` (vitest) cover parse, resolution, every error path, and the lint rule.

### Stack
Bottom of a 5-PR stack: **core** → player (#1581) → studio (#1582) → skill (#1583) → examples (#1584).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-19 00:31:45 -07:00
Vance IngallsandClaude Opus 4.8 967bf9f9ed refactor(core): gate acorn GSAP writer behind cutover flag; keep recast default (WS-3F) (#1573)
* 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>
2026-06-19 00:22:14 -07:00
Vance IngallsandClaude Opus 4.8 37efbcb955 feat(sdk): image-alpha hit-test phase 1 (WS-G) (#1574)
* 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>
2026-06-19 00:12:45 -07:00
Vance IngallsandClaude Opus 4.8 dd6fad6bb2 fix(sdk): code-review follow-ups (WS-B/C/3.C, #1569/#1570/#1572) (#1588)
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>
2026-06-19 00:07:53 -07:00
Vance IngallsandClaude Opus 4.8 418f198b33 feat(sdk): ws-3c — addWithKeyframes + replaceWithKeyframes SDK ops (acorn writer) (#1572)
* 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>
2026-06-18 23:15:02 -07:00
Vance IngallsandClaude Opus 4.8 0ca01c88cf feat(sdk): addElement forward op — mint hf-id, inverse = removeElement (WS-D) (#1571)
* 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>
2026-06-18 23:05:41 -07:00
Vance IngallsandClaude Opus 4.8 f65e229663 feat(sdk): ws-c elastic timing + word-alignment resolver (WS-C) (#1570)
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>
2026-06-18 23:05:06 -07:00
Vance Ingalls d0e520dbd9 feat(sdk): ws-b variables/brand — object-valued font/image + B1 JSON model (#1569)
## 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)
2026-06-18 23:03:52 -07:00
Vance IngallsandClaude Opus 4.8 7607a714a6 ci(publish): publish @hyperframes/sdk to npm (#1587)
The SDK is version-bumped by scripts/set-version.ts (it's in the PACKAGES list)
but was never added to the publish_pkg list in publish.yml — so
@hyperframes/sdk@0.6.112 sits on the version line yet is absent from npm (404),
while core/player/engine/etc. all shipped at 0.6.112. Add the missing publish
call so the SDK ships with every release.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:55:22 -07:00
Vance Ingalls 1bab79ef4c chore: release v0.6.112 2026-06-18 01:14:30 -07:00
Vance IngallsandClaude Opus 4.8 7310223b66 feat(engine): static-frame dedup default-on + render telemetry (#1549)
* feat(engine): static-frame dedup for screenshot capture (opt-in)

Skip re-seeking + re-screenshotting frames byte-identical to their predecessor. A
frame is dedupable iff no GSAP tween or clip cut is active in it or its predecessor
(predicted from window.__timelines + clip schedule) AND an empirical anchor-compare
confirms it. Opt-in HF_STATIC_DEDUP=true, default off.

Correctness (designed for the multi-worker / distributed render paths):
- Reuse is keyed by the ABSOLUTE composition frame (derived from the frame's time),
  NOT the captureFrameCore frameIndex arg — chunked/parallel callers pass a chunk-
  relative index. Validated lossless (PSNR=inf) on both single- and multi-worker
  renders of a static-hold comp.
- verifyStaticFramesSafe checks EVERY run (no longest-first budget truncation that
  left runs armed-but-unverified), and samples each run's FIRST reused frame, its END,
  and interior points at a stride; a hard cap disables dedup rather than trust an
  unverified set.
- Conservative arming: skipped when capture mode != screenshot (BeginFrame tick
  semantics + the verifier's screenshot path wouldn't transfer), when a before-capture
  hook is set (per-frame video injection), when page-side compositing is active (shader
  / drawElement composite the plain verification screenshot can't reproduce), and when
  any data-start is a non-numeric reference expression the clip-boundary parser can't
  protect, or duration is unknown/zero.
- Session reuse (prepareCaptureSessionForReuse) resets lastFrameBuffer + dedup counter
  so a probe/prior-render buffer can't bleed into the first static frame; the armed set
  is kept (same-composition reuse). Cost calibration bypasses dedup for its sparse,
  non-contiguous sample sweep, then restores the armed set.
- HF_STATIC_DEDUP_SAMPLES is NaN-guarded.

Disqualifies on signals the GSAP predictor can't see: video, canvas/webgl, zero
tweens, running CSS/WAAPI animation. Pays on static-hold content (title cards,
slideshow/kiosk loops, data-viz pauses); no-op on continuously-animated comps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(engine): static-frame dedup default-on + render telemetry

Flip dedup from opt-in (HF_STATIC_DEDUP=true) to default-on (opt-out
HF_STATIC_DEDUP=false). Verification (verifyStaticFramesSafe) is the
safety net that keeps reuse sound at scale.

Add end-to-end dedup observability. The capture session records
enabled / armed / skipReason / predicted; these surface via
CapturePerfSummary -> a dedupPerfs accumulator (disk sequential +
parallel AND streaming sequential + parallel) -> aggregated into
RenderPerfSummary.staticDedup (OR armed, SUM frames across workers) ->
render_complete props static_dedup_{enabled,armed,skip_reason,
predicted_frames,reused_frames}. skip_reason is a low-cardinality code:
capture_mode | video_injection | page_composite | ineligible |
verification_failed.

Distributed chunks run on Linux/beginframe where dedup never arms, so
they pass a throwaway dedupPerfs sink (no per-chunk reporting).

Tests: aggregation logic (OR/SUM/skip-reason) + opt-out passthrough.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(engine): address review on dedup default-on + telemetry

Review feedback (miga-heygen) + self-review fixes:

- Retry double-count: executeDiskCaptureWithAdaptiveRetry pushed worker
  dedup perf inside the retry loop, so an adaptive retry counted frames
  twice (reused/predicted could exceed totalFrames). Reset dedupPerfs at
  the start of each attempt — retry now REPLACES rather than accumulates;
  common no-retry path is unchanged.
- Opt-out parsing: HF_STATIC_DEDUP now disables on {false,0,off}
  case/space-insensitive (was strict !== "false", so `False`/`0` silently
  kept dedup on — the kill-switch could no-op).
- Verification budget vs drift: verifyStaticFramesSafe returns
  {badFrame, budgetExhausted}; armStaticDedup reports a distinct
  `verification_budget` skip reason so a telemetry spike means "raise
  HF_STATIC_DEDUP_SAMPLES", not "compositions are non-static".
- Index idiom: captureFrameCore now uses Math.floor(time*fps + 1e-9)
  (matches quantizeTimeToFrame) so the dedup lookup agrees with the frame
  the seek lands on even for non-exact times.
- Stale "opt-in HF_STATIC_DEDUP=true" comments -> "opt-out
  HF_STATIC_DEDUP=false" across frameCapture.ts + types.ts.
- Extract pushWorkerDedupPerfs helper (perfSummary.ts), used by the disk
  and streaming parallel paths — removes the duplicated push loop and
  drops captureStreamingStage back under the complexity threshold.
- dedupPerfs is now required (not optional) on
  executeDiskCaptureWithAdaptiveRetry — a missing arg silently dropped
  telemetry.
- Test: captureStreamingStage createInput() now provides the required
  dedupPerfs field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(engine): address deferred dedup-review items

- Derivable state: drop session.staticDedupArmed/staticDedupPredicted;
  derive both from session.staticFrames in getCapturePerfSummary
  (armed ⟺ non-empty set, predicted === size) so they can't desync.
- Config altitude: HF_STATIC_DEDUP now resolves into
  EngineConfig.staticFrameDedup (resolveConfig, opt-out on {false,0,off}),
  alongside forceScreenshot/browserGpuMode — armStaticDedup reads config
  instead of process.env. Default-on preserved (missing config → enabled).
- Lossy aggregation: aggregateDedup now reports DISTINCT skip reasons
  (sorted, `|`-joined) across diverging unarmed workers instead of just
  the first.
- discardWarmupCapture: also snapshot/restore staticDedupCount and
  lastFrameBuffer so a warmup capture can't leak a phantom reuse or a
  stale buffer anchor into the real summary.
- Convention: perfSummary-dedup.test builds its job via createRenderJob
  instead of `as unknown as RenderJob`.
- Docs: verification_budget added to skip-reason lists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 01:12:05 -07:00
Vance IngallsandClaude Opus 4.8 5fb5153e4e fix(studio): shadow resolves bare leaf via dispatch path, not getElement (#1552)
The resolver-parity shadow tripwire decided element_not_found via
Composition.getElement, which is canonical-only for a bare id by design
(removeElement/getElement must agree on the same instance — the
session.subcomp "ambiguous bare id" suite). But the cutover persist path
dispatches the studio's bare data-hf-id and resolves it via resolveScoped,
which locates the leaf anywhere in the document (canonical preferred, else
first match). So getElement under-resolved a bare leaf living inside an
inlined sub-composition (scopedId "host/leaf"), and the shadow emitted a
false element_not_found the real dispatch path never hits — ~445 such
events in PostHog, all one user editing an inlined yt-lower-third.

Add resolveSnapshot in the shadow mirroring resolveScoped, used at all three
element_not_found sites. getElement is unchanged (its contract is correct).
Regression tests cover the inlined-sub-comp leaf case.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 00:48:04 -07:00
Vance IngallsandClaude Opus 4.8 28bfe09f21 feat(sdk): ws-3 — reorderElements op (batch z-index update) (#1502)
* 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>
2026-06-17 23:51:47 -07:00
Vance Ingalls de87f3932e chore: release v0.6.111 2026-06-17 22:28:29 -07:00
Vance IngallsandClaude Opus 4.8 933b88ec33 feat(studio): resolver-parity shadow tripwire (decoupled telemetry) (#1547)
* feat(studio): resolver-parity shadow tripwire (decoupled telemetry)

New sdkResolverShadow.ts module: checks whether the SDK session resolves
the same element id the server path would address, then verifies value
parity after in-memory dispatch. Emits sdk_resolver_shadow telemetry on
divergence. Decoupled from STUDIO_SDK_CUTOVER_ENABLED via its own flag
STUDIO_SDK_RESOLVER_SHADOW_ENABLED (default false).

Headline signal: element_not_found — the resolver divergence class that
caused the v0.6.110 regression. Writer-parity suite (#1533) cannot see
this class; this tripwire exists specifically to catch it.

All 12 acceptance-test-plan items pass (A1-A3, B4-B6, C7-C10, D11, E12).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(studio): default STUDIO_SDK_RESOLVER_SHADOW_ENABLED to true

Tripwire should run out of the box — operators opt out, not in.

* fix(studio): resolver shadow must not mutate the live session (restore via inverse patches)

The shadow runs on the SAME sdkSession the cutover path uses, one line before
sdkCutoverPersist. sdkResolverShadowCheck dispatched the edit into that session
to read values back but never undid it — so with the shadow enabled the edit was
pre-applied, and sdkCutoverPersist then saw before === after and silently fell
back to the server path. Enabling the tripwire disabled cutover.

Fix: capture the inverse patches of the shadow dispatch (session.on("patch")) and
applyPatches them to restore the session before returning, on every path
(success, dispatch_error, element_not_found after dispatch). The session ends the
check exactly as it started; cutover's before/after diff is unaffected.

Tests: B5 now asserts the live session is restored (color back to original, not
left on the shadow value) and B5b proves a cutover-style before/dispatch/after
diff still fires after a preceding shadow run. The earlier B5 used two separate
sessions and so never exercised the shared-session path the bug lived in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(studio): extend resolver shadow to timing/delete/gsap-add chokepoints

The shadow only ran on the DOM-edit path (inline-style/text/attribute via
onTrySdkPersist) — blind to the rest of the cutover surface, which is where the
resolver bugs that motivated it actually live (v0.6.110 was a GSAP property op;
CF2 #15/#16 were timing-resolver bugs). On-for-everyone telemetry that only sees
style/text/attr edits misses the riskiest paths.

Adds a read-only element-resolution tripwire (recordResolverParity) — emits the
headline `element_not_found` signal when the SDK can't resolve a target the
server path is addressing, with NO dispatch/mutation. Wired before the cutover
gate (decoupled) in the element-targeted chokepoints: sdkTimingPersist,
sdkDeletePersist, and sdkGsapTweenPersist's add op.

To avoid a circular import (sdkResolverShadow imported patchOpsToSdkEditOps from
sdkCutover; sdkCutover now imports recordResolverParity from sdkResolverShadow),
patchOpsToSdkEditOps moves to a neutral sdkOpMapping.ts that both import from.

animationId-resolving GSAP ops (set/remove tween, keyframe ops, deleteAllForSelector)
resolve an animation, not an element, so element-resolution parity doesn't apply —
left as a follow-up (separate animation-resolution signal).

Tests: recordResolverParity emit-on-divergence / parity-no-op / flag-off-no-op /
read-only (no mutation). Full studio suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(studio): resolver shadow covers animationId GSAP ops (animation_not_found)

Extends the tripwire to the GSAP-edit surface that resolves an animationId
rather than an element: setGsapTween/removeGsapTween, addGsapKeyframe,
removeGsapKeyframe, removeGsapProperty, removeAllKeyframes, convertToKeyframes.

Adds recordAnimationResolverParity — read-only, emits the new `animation_not_found`
kind when the SDK can't resolve the animationId the server GSAP path is
addressing. The SDK's resolvable animation ids are the located ids attached to
elements (buildAnimationIdMap), so a target absent from every element's
animationIds is a resolver divergence. No dispatch, no mutation.

Wired centrally in dispatchGsapOpAndPersist via an optional resolverTarget arg
(runs before its cutover gate); sdkGsapTweenPersist records inline before its
own leading gate (set/remove → animation parity, add → element parity).
deleteAllForSelector resolves by selector, not an id — left out.

Tests: animation_not_found on unresolved id / parity no-op on a real located id /
flag-off no-op. Full studio suite green; no circular dep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(studio): resolver shadow review fixes — divergence-only emit + restore in finally

Addresses PR #1547 review (Miga, Rames):

- #1 (medium): runResolverShadow emitted `sdk_resolver_shadow` on every edit,
  including parity (mismatchCount 0) — a PostHog event per style/text/attr edit
  at default-ON. Now emits only on divergence, matching recordResolverParity /
  recordAnimationResolverParity. Parity is silent across all three paths.
- restore() moved into a `finally` in sdkResolverShadowCheck: if checkOpValue
  throws between dispatch and restore, the patch listener no longer leaks and the
  shared session is always undone (the cutover-coupling failure mode this module
  guards against). dispatch errors still return dispatch_error.
- Comment on why batch is compatible with per-op inverse capture (a future SDK
  refactor that coalesces batch must keep emitting inverse patches).

Tests: A2 now forces a divergence to emit; A2b pins parity-is-silent; A4 covers
null/undefined hfId no-op. 26 shadow tests, full studio suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 22:25:29 -07:00
Vance IngallsandClaude Opus 4.8 e57e75b9b4 fix(sdk,studio): R5 cutover review fixes (on top of #1539) (#1545)
* 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>
2026-06-17 17:15:16 -07:00
Vance IngallsandClaude Opus 4.8 8c981a451a fix(studio): restore timeline move/resize fallback parity (review #1466) (#1539)
* 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>
2026-06-17 16:55:34 -07:00
09cefc1bb7 feat(sdk,core): ws-3 — unrollDynamicAnimations acorn port + SDK op (#1501)
* 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>
2026-06-17 16:54:04 -07:00
Vance IngallsandMiguel Ángel 1612d18fdf feat(sdk): stage 6 — arc path ops (setArcPath, updateArcSegment, removeArcPath) (#1500)
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>
2026-06-17 16:53:02 -07:00
a746db6017 feat(sdk,core): ws-3 prerequisites — acorn keyframe-collapse foundation + removeAllKeyframes (#1499)
* feat(sdk,core): ws-3 prerequisites — acorn keyframe-collapse foundation + removeAllKeyframes

P1: gsapWriter.parity.test.ts — recast-vs-acorn parity harness (reparse-equivalence).
P2: move pure keyframe-conversion transforms (resolveConversionProps, cssIdentityValue)
    to recast-free gsapSerialize.ts so the acorn/SDK path can share them.
P3: MagicString splice primitives in gsapWriterAcorn.ts (buildVarsObjectCode, overwriteVarsArg).
P4: reference vertical slice — removeAllKeyframesFromScript ported to acorn writer +
    removeAllKeyframes SDK op (types/mutate/can) + Studio cutover (useGsapKeyframeOps),
    replacing the server-authoritative ponytail stub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sdk,core): ws-3 — convertToKeyframes acorn port + SDK op + Studio cutover

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* feat(sdk,core): ws-3 — materializeKeyframes + splitIntoPropertyGroups acorn ports + SDK ops

- acorn: buildKeyframeObjectCode, materializeKeyframesFromScript, addAnimationWithKeyframesToScript
- acorn: splitIntoPropertyGroupsFromScript with filterGroupKeyframes/filterGroupProperties helpers
- parity tests: materialize (2 positive + 1 no-op) and split (2 positive + 2 no-op) suites
- SDK types: materializeKeyframes + splitIntoPropertyGroups EditOp variants
- mutate.ts: handlers + can() gates for both new ops
- mutate.gsap.test.ts: 6 new tests (53 total passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* feat(sdk,core): ws-3 — splitAnimationsInScript acorn port + SDK op

- acorn: updateAnimationSelectorInScript, insertInheritedStateSetInScript helpers
- acorn: splitAnimationsInScript exported (parity with recast version)
- parity: 4 new fixtures (3 cases + no-op) — 23 total parity tests
- SDK types: splitAnimations EditOp variant
- mutate.ts: handleSplitAnimations + can() gate
- mutate.gsap.test.ts: 3 new tests (56 total passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-17 16:51:28 -07:00
ceb815c318 feat(sdk,studio): ws-1.2 — percentage-based removeGsapKeyframe (#1498)
* feat(sdk,studio): ws-1.2 — percentage-based removeGsapKeyframe

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* feat(sdk,studio): ws-1.3 — removeGsapProperty SDK op + Studio hook cutover

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* feat(sdk,studio): ws-1.4 — deleteAllForSelector SDK op + Studio hook cutover

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* fix(core): cascade-remove GSAP tweens in removeElementFromHtml (WS-2)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

---------

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-17 16:49:47 -07:00
Vance IngallsandMiguel Ángel a5016ed416 feat(sdk,studio): ws-1.1 — add set method to GsapTweenSpec; route addGsapAnimation(set) through sdk (#1497)
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-17 16:48:20 -07:00
Vance IngallsandMiguel Ángel e35846176e feat(sdk,studio): ws-4 — add history:false option; disable unused sdk undo in studio (#1496)
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-17 16:47:12 -07:00
Vance IngallsandMiguel Ángel 53717a77f4 feat(sdk): ws-a2 — applyDraft/commitPreview/cancelPreview → moveElement op (#1490)
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-17 16:46:04 -07:00
b96e8a3072 feat(sdk): ws-a1 — iframe preview adapter (hit-test + selection) (#1489)
* feat(studio): stage 7 step 3c — sdk cutover for inline-style ops

Introduces sdkCutoverPersist(): when STUDIO_SDK_CUTOVER_ENABLED is set,
inline-style PatchOps are routed through the SDK session's in-memory document
model instead of the server patch-element API. The SDK serialize() result is
written back through the same writeProjectFile + editHistory.recordEdit path,
so the on-disk output is identical to the legacy route.

- packages/studio/src/utils/sdkCutover.ts (new): sdkCutoverPersist() +
  shouldUseSdkCutover() guard; domEditSaveTimestampRef.current is stamped on
  each write to suppress the echo file-change reload.
- packages/studio/src/components/editor/manualEditingAvailability.ts: adds
  STUDIO_SDK_CUTOVER_ENABLED flag (default false); changes
  STUDIO_SDK_SHADOW_ENABLED default to false now that cutover is available.
- packages/studio/src/hooks/useSdkSession.ts: adds optional
  domEditSaveTimestampRef param; self-write suppress window (SELF_WRITE_SUPPRESS_MS)
  gates file-change reloads so SDK writes don't echo back as external edits.
- packages/studio/src/App.tsx: passes domEditSaveTimestampRef to useSdkSession
  so the suppress window can gate reloads triggered by SDK cutover writes.
- Test coverage: sdkCutover.test.ts (new, 141 lines) + useDomEditSession.test.ts
  (new, 50 lines) — guard function + happy-path assertions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(studio): force-reload sdk session after undo/redo bypasses suppress window

writeHistoryFile arms the 2 s self-write suppress window, so the
file-change event for an undo/redo write is swallowed and the SDK
in-memory doc stays on pre-undo content. Expose forceReload() from
useSdkSession (s7.4) and call it in useAppHotkeys after a successful
undo/redo that touched the active composition path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* feat(studio): s7.5 — delete shadow scaffolding; keep cutover flag (dark launch)

Removes the SDK shadow telemetry: STUDIO_SDK_SHADOW_ENABLED, sdkShadow.ts +
sdkShadowGsapFidelity/GsapKeyframe/Numeric and their tests, the runShadow*
call-sites across the GSAP/timeline hooks, and the onDomEditPersisted shadow
callback in useDomEditSession. Moves patchOpsToSdkEditOps into sdkCutover.ts.

KEEPS STUDIO_SDK_CUTOVER_ENABLED as a dark-launch kill-switch — default false,
enable per-environment via VITE_STUDIO_SDK_CUTOVER_ENABLED=true. shouldUseSdkCutover
stays flag-gated. The stack can merge with zero behavior change; cutover is
validated by flipping the flag, not by removing it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* fix(studio): wire onTrySdkPersist to sdkCutoverPersist (cutover was unwired)

Stage 7 s7.5 removed the feature flag and declared cutover 'always-on',
but onTrySdkPersist was never actually passed to useDomEditCommits — the
sdkCutoverPersist function was dead code in production.

Thread sdkSession through useDomEditSession params, build the
onTrySdkPersist closure there (all CutoverDeps are already in scope),
and pass sdkSession from App.tsx. Style/text/attribute/html-attribute
commits now route through SDK dispatch instead of the server patch path.

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* feat(studio): route element delete through SDK removeElement (§3.1)

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* feat(studio): route timeline trim/move through SDK setTiming (§3.2)

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* chore(studio): document CSS-path position cut-over, GSAP-path intentionally deferred (§3.3)

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* feat(studio): route GSAP tween add/update/delete through SDK (§3.5 PR1)

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* feat(studio): route GSAP keyframe add through SDK (§3.5 PR2)

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* fix(studio,core): resolve SDK-cutover review findings

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* feat(sdk): ws-a1 — iframe preview adapter (hit-test + selection)

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-17 16:44:56 -07:00
Vance IngallsandMiguel Ángel 377b0368bd fix(studio,core): resolve SDK-cutover review findings (#1471)
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-17 16:44:45 -07:00
Vance IngallsandMiguel Ángel 7ca4490328 feat(studio): route GSAP keyframe add through SDK (§3.5 PR2) (#1470)
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-17 16:43:34 -07:00
Vance IngallsandMiguel Ángel 592f7c775d feat(studio): route GSAP tween add/update/delete through SDK (§3.5 PR1) (#1469)
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-17 16:42:02 -07:00
e65c3c7918 chore(studio): document CSS-path position cut-over; GSAP-path deferred (§3.3) (#1467)
* feat(studio): stage 7 step 3c — sdk cutover for inline-style ops

Introduces sdkCutoverPersist(): when STUDIO_SDK_CUTOVER_ENABLED is set,
inline-style PatchOps are routed through the SDK session's in-memory document
model instead of the server patch-element API. The SDK serialize() result is
written back through the same writeProjectFile + editHistory.recordEdit path,
so the on-disk output is identical to the legacy route.

- packages/studio/src/utils/sdkCutover.ts (new): sdkCutoverPersist() +
  shouldUseSdkCutover() guard; domEditSaveTimestampRef.current is stamped on
  each write to suppress the echo file-change reload.
- packages/studio/src/components/editor/manualEditingAvailability.ts: adds
  STUDIO_SDK_CUTOVER_ENABLED flag (default false); changes
  STUDIO_SDK_SHADOW_ENABLED default to false now that cutover is available.
- packages/studio/src/hooks/useSdkSession.ts: adds optional
  domEditSaveTimestampRef param; self-write suppress window (SELF_WRITE_SUPPRESS_MS)
  gates file-change reloads so SDK writes don't echo back as external edits.
- packages/studio/src/App.tsx: passes domEditSaveTimestampRef to useSdkSession
  so the suppress window can gate reloads triggered by SDK cutover writes.
- Test coverage: sdkCutover.test.ts (new, 141 lines) + useDomEditSession.test.ts
  (new, 50 lines) — guard function + happy-path assertions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(studio): force-reload sdk session after undo/redo bypasses suppress window

writeHistoryFile arms the 2 s self-write suppress window, so the
file-change event for an undo/redo write is swallowed and the SDK
in-memory doc stays on pre-undo content. Expose forceReload() from
useSdkSession (s7.4) and call it in useAppHotkeys after a successful
undo/redo that touched the active composition path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* feat(studio): s7.5 — delete shadow scaffolding; keep cutover flag (dark launch)

Removes the SDK shadow telemetry: STUDIO_SDK_SHADOW_ENABLED, sdkShadow.ts +
sdkShadowGsapFidelity/GsapKeyframe/Numeric and their tests, the runShadow*
call-sites across the GSAP/timeline hooks, and the onDomEditPersisted shadow
callback in useDomEditSession. Moves patchOpsToSdkEditOps into sdkCutover.ts.

KEEPS STUDIO_SDK_CUTOVER_ENABLED as a dark-launch kill-switch — default false,
enable per-environment via VITE_STUDIO_SDK_CUTOVER_ENABLED=true. shouldUseSdkCutover
stays flag-gated. The stack can merge with zero behavior change; cutover is
validated by flipping the flag, not by removing it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* fix(studio): wire onTrySdkPersist to sdkCutoverPersist (cutover was unwired)

Stage 7 s7.5 removed the feature flag and declared cutover 'always-on',
but onTrySdkPersist was never actually passed to useDomEditCommits — the
sdkCutoverPersist function was dead code in production.

Thread sdkSession through useDomEditSession params, build the
onTrySdkPersist closure there (all CutoverDeps are already in scope),
and pass sdkSession from App.tsx. Style/text/attribute/html-attribute
commits now route through SDK dispatch instead of the server patch path.

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* feat(studio): route element delete through SDK removeElement (§3.1)

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* feat(studio): route timeline trim/move through SDK setTiming (§3.2)

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* chore(studio): document CSS-path position cut-over, GSAP-path intentionally deferred (§3.3)

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-17 16:40:29 -07:00
Vance IngallsandMiguel Ángel 39f37e8aa8 feat(studio): route timeline trim/move through SDK setTiming (§3.2) (#1466)
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-17 16:40:07 -07:00
Vance IngallsandMiguel Ángel bce571c2a1 feat(studio): route element delete through SDK removeElement (§3.1) (#1465)
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-17 16:38:38 -07:00
Vance IngallsandMiguel Ángel 8585fffc92 fix(studio): wire onTrySdkPersist to sdkCutoverPersist (cutover was unwired) (#1463)
Stage 7 s7.5 removed the feature flag and declared cutover 'always-on',
but onTrySdkPersist was never actually passed to useDomEditCommits — the
sdkCutoverPersist function was dead code in production.

Thread sdkSession through useDomEditSession params, build the
onTrySdkPersist closure there (all CutoverDeps are already in scope),
and pass sdkSession from App.tsx. Style/text/attribute/html-attribute
commits now route through SDK dispatch instead of the server patch path.

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-17 16:31:57 -07:00
Vance IngallsandMiguel Ángel ca1a8a6879 feat(studio): s7.5 — delete shadow scaffolding; keep cutover flag (dark launch) (#1462)
Removes the SDK shadow telemetry: STUDIO_SDK_SHADOW_ENABLED, sdkShadow.ts +
sdkShadowGsapFidelity/GsapKeyframe/Numeric and their tests, the runShadow*
call-sites across the GSAP/timeline hooks, and the onDomEditPersisted shadow
callback in useDomEditSession. Moves patchOpsToSdkEditOps into sdkCutover.ts.

KEEPS STUDIO_SDK_CUTOVER_ENABLED as a dark-launch kill-switch — default false,
enable per-environment via VITE_STUDIO_SDK_CUTOVER_ENABLED=true. shouldUseSdkCutover
stays flag-gated. The stack can merge with zero behavior change; cutover is
validated by flipping the flag, not by removing it.

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-17 16:27:03 -07:00
Vance IngallsandMiguel Ángel 0ca1a8a9d1 fix(studio): force-reload sdk session after undo/redo bypasses suppress window (#1524)
writeHistoryFile arms the 2 s self-write suppress window, so the
file-change event for an undo/redo write is swallowed and the SDK
in-memory doc stays on pre-undo content. Expose forceReload() from
useSdkSession (s7.4) and call it in useAppHotkeys after a successful
undo/redo that touched the active composition path.

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-17 16:20:52 -07:00
Vance IngallsandClaude Sonnet 4.6 ab7145ad9e feat(studio): stage 7 step 3c — sdk cutover for inline-style ops (#1522)
Introduces sdkCutoverPersist(): when STUDIO_SDK_CUTOVER_ENABLED is set,
inline-style PatchOps are routed through the SDK session's in-memory document
model instead of the server patch-element API. The SDK serialize() result is
written back through the same writeProjectFile + editHistory.recordEdit path,
so the on-disk output is identical to the legacy route.

- packages/studio/src/utils/sdkCutover.ts (new): sdkCutoverPersist() +
  shouldUseSdkCutover() guard; domEditSaveTimestampRef.current is stamped on
  each write to suppress the echo file-change reload.
- packages/studio/src/components/editor/manualEditingAvailability.ts: adds
  STUDIO_SDK_CUTOVER_ENABLED flag (default false); changes
  STUDIO_SDK_SHADOW_ENABLED default to false now that cutover is available.
- packages/studio/src/hooks/useSdkSession.ts: adds optional
  domEditSaveTimestampRef param; self-write suppress window (SELF_WRITE_SUPPRESS_MS)
  gates file-change reloads so SDK writes don't echo back as external edits.
- packages/studio/src/App.tsx: passes domEditSaveTimestampRef to useSdkSession
  so the suppress window can gate reloads triggered by SDK cutover writes.
- Test coverage: sdkCutover.test.ts (new, 141 lines) + useDomEditSession.test.ts
  (new, 50 lines) — guard function + happy-path assertions.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 16:10:07 -07:00
Vance IngallsandClaude Opus 4.8 c040e4973a test(core): recast-vs-acorn differential suite for GSAP writer ops (+ fixes) (#1533)
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>
2026-06-17 10:12:21 -07:00
Vance IngallsandClaude Opus 4.8 6e32142334 fix(sdk): resolve composition-id targets + emit canonical data-hf-id for GSAP tweens (#1526)
A sub-composition ROOT is addressed by its data-composition-id, but the SDK's
whole element<->tween attribution is data-hf-id based, so the prior fix's
[data-composition-id] selector was invisible to three readers (validateOp/can,
selectorMatchesId -> setTiming + removeElement cascade, buildAnimationIdMap ->
getElement.animationIds), diverging can from apply and orphaning tweens.

Root fix: make composition ids first-class resolvable addresses and emit the
canonical selector everywhere.

- resolveScoped (model.ts): for a bare id with no data-hf-id match, fall back to
  [data-composition-id]. data-hf-id keeps precedence; scoped-path and canonical
  behavior intact. Fixes validateOp gating, findById/getElement, and every op
  handler for comp-root targets in one place.
- gsapTargetSelector (mutate.ts): resolve the target and emit
  [data-hf-id="<resolved host hf-id>"] (canonical). Normal targets unchanged;
  comp-root targets resolve via comp-id -> host -> host hf-id. Defensive
  [data-composition-id] only when the resolved element has no hf-id.
- setTiming syncs the GSAP tween via the resolved element's data-hf-id so a
  comp-root target matches its host tween; removeElement cascade already covers
  the host hf-id via collectSubtreeHfIds.
- export escapeHfId; escape both the querySelector probe and the emitted
  selector string.

Tests: comp-id resolveScoped fallback + precedence (session.subcomp), canonical
selector, validateOp accept, setTiming sync, removeElement cascade, and
getElement.animationIds for comp-root tweens (mutate.gsap). The prior test only
called applyOp, masking all of this.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 23:08:08 -07:00
Vance IngallsandClaude Opus 4.8 9cc3550f7e fix(release): stop advising 'git push --tags' in release next-steps (#1521)
set-version's stable-release next-steps printed `git push origin main --tags`,
which pushes every local tag and fails the whole push on any pre-existing tag
(it broke the v0.6.107 release). #1517 fixed CONTRIBUTING.md + annotated the tag
but missed this console message. Now prints `git push origin main` +
`git push origin v<version>`, matching the pre-release branch.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:26:49 -07:00
Vance Ingalls 1028f52aa2 chore: release v0.6.109 2026-06-16 21:16:45 -07:00
Vance IngallsandClaude Sonnet 4.6 386df23a74 fix(lint): promote rules to errors with registry exemptions and false-positive fixes (#1495)
* 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>
2026-06-16 21:13:00 -07:00
Vance Ingalls cc7c206e9c chore: release v0.6.108 2026-06-16 17:56:34 -07:00
Vance IngallsandClaude Opus 4.8 b5ad518957 fix(core,sdk): rebuild acorn GSAP keyframe writer for recast parity (#1520)
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>
2026-06-16 17:54:46 -07:00
Vance IngallsandClaude Opus 4.8 ca3cab340b fix(release): annotate version tag + correct release push instructions (#1517)
- set-version: create the release tag with `git tag -a -m` instead of a
  lightweight `git tag`, which fails ("no tag message?") when a contributor has
  tag.forceSignAnnotated / required-annotation set globally — it silently broke
  the v0.6.107 tag step.
- CONTRIBUTING: replace `git push origin main --tags` (pushes every local tag →
  whole push rejected on any pre-existing collision) with pushing the specific
  tag, and document the monotonicity guard (stale higher tag blocks tagging).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:30:15 -07:00
Vance Ingalls 6778ad13ef chore: release v0.6.107 2026-06-16 12:49:26 -07:00
Vance IngallsandClaude Opus 4.8 0e5caa453a refactor(studio): dedup shadow numeric-equal + GSAP script extraction (#1516)
* fix(studio): serialize GSAP script commits per file (shadow request race)

Rapid GSAP edits (ease/duration/keyframe/property) fired overlapping
read-modify-write POSTs to one script file — coalesceKey only dedupes edit
history, not requests. The gsap_fidelity shadow then diffed an op against
whichever POST's scriptText resolved, which could predate that op → false
"expected null, actual power2.out" mismatches. Server persists correctly; a
pure client request-pairing race.

Adds createKeyedSerializer (per-key promise chain, rejection-safe, self-
cleaning). commitMutation now serializes every GSAP-script commit per target
file by default (key `gsap-file:<path>`) — covering all op types and all
animations, not just one meta family — so same-file POSTs can't interleave.
Distinct files run concurrently; an explicit serializeKey still overrides.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(studio): dedup shadow numeric-equal + GSAP script extraction

Code-review cleanup (no behavior change):
- Extract the relative-epsilon float compare into shared sdkShadowNumeric.relEqual,
  used by both timing parity (sdkShadow) and GSAP value fidelity (numericEqual) —
  was duplicated verbatim, risking divergent tuning.
- Export extractGsapScript from sdkShadowGsapFidelity and import it in the keyframe
  shadow instead of the byte-identical clone (the regex + marker set must stay in
  sync with document.ts; one copy is safer).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:41:25 -07:00
Vance IngallsandClaude Opus 4.8 4ee57d5505 fix(studio): serialize GSAP script commits per file (shadow request race) (#1512)
Rapid GSAP edits (ease/duration/keyframe/property) fired overlapping
read-modify-write POSTs to one script file — coalesceKey only dedupes edit
history, not requests. The gsap_fidelity shadow then diffed an op against
whichever POST's scriptText resolved, which could predate that op → false
"expected null, actual power2.out" mismatches. Server persists correctly; a
pure client request-pairing race.

Adds createKeyedSerializer (per-key promise chain, rejection-safe, self-
cleaning). commitMutation now serializes every GSAP-script commit per target
file by default (key `gsap-file:<path>`) — covering all op types and all
animations, not just one meta family — so same-file POSTs can't interleave.
Distinct files run concurrently; an explicit serializeKey still overrides.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:36:33 -07:00
Vance IngallsandClaude Opus 4.8 cc055f318d fix(sdk): agree removeElement/getElement on duplicate bare ids (#1511)
* fix(sdk): setStyle removes hyphenated properties (was kebab/camel key mismatch)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sdk): agree removeElement/getElement on duplicate bare ids

A bare hf-id duplicated across a sub-composition element and a top-level
element resolved to different instances: removeElement → resolveScoped →
querySelector (document-order-first, the inner sub-comp dup) while getElement
preferred the canonical match (scopedId === id, the top-level dup). So
removeElement(bareId) removed the inner instance and getElement(bareId) still
found the surviving top-level one — they disagreed.

resolveScoped now resolves an ambiguous BARE id to the canonical (top-level)
instance via isCanonicalScope (walks ancestors for isNewHostBoundary), falling
back to document order when no canonical match exists — matching getElement.
Fully-scoped paths (hf-host/hf-dup) and non-duplicated bare ids are unchanged.

Surfaced by SDK shadow parity (op:delete expected removed, actual present).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:30:54 -07:00
Vance IngallsandClaude Opus 4.8 066ea798b4 fix(sdk): setStyle removes hyphenated properties (was kebab/camel key mismatch) (#1510)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:30:35 -07:00
Vance IngallsandClaude Opus 4.8 4b4a3eb63d feat(studio): shadow telemetry for GSAP keyframe ops (gsap_keyframe) (#1509)
* fix(studio): suppress shadow-parity false positives in timing + text

runShadowTiming: compare start/duration with a relative epsilon (1e-6)
instead of exact equality so float-precision drift (3.1 vs
3.0999999999999996, 21.36 vs 21.360000000000014) no longer flags; a real
difference (3.1 vs 3.5) still flags. trackIndex stays exact.

property:text resolver: trim both sides (snapshot.text is already trimmed)
and collapse empty-string vs absent (null) text so trailing-whitespace and
empty-vs-null no longer flag. Genuine text differences are unaffected; the
per-keystroke length lag is a caller-side debounce concern.

Adds tests for both fixes plus regression tests documenting two REAL SDK
divergences the shadow correctly surfaces (transform-origin removal no-op;
duplicate-bare-id delete resolution) — flagged, not fixed here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(studio): shadow telemetry for GSAP keyframe ops (gsap_keyframe)

Wire the SDK shadow-parity telemetry to cover GSAP keyframe add/remove,
the primary unwired cutover signal, plus a defensive unmapped-PatchOperation
guard.

New packages/studio/src/utils/sdkShadowGsapKeyframe.ts:
- ShadowKeyframeOp + keyframeOpToEditOp: maps studio percentage-based keyframe
  ops to SDK EditOps. add -> addGsapKeyframe{position:percentage}; remove ->
  removeGsapKeyframe{keyframeIndex}, resolving percentage -> index against the
  pre-op script with ~0.001 tolerance and a no-op-on-ambiguity guard for
  duplicate-percentage keyframes (PR #1498 landmine).
- gsapKeyframeFidelityMismatches: reuses gsapFidelityMismatches for the
  tween-level diff and layers a keyframe-array comparison (which the base diff
  doesn't inspect), matched by GSAP animation id.
- runShadowGsapKeyframeFidelity: serialize-diff runner emitting op tag
  gsap_keyframe (no keyframe reader on ElementSnapshot, so no existence path).

useGsapKeyframeOps synthesizes shadowKeyframeOp for addKeyframe /
addKeyframeBatch / removeKeyframe; the commit chokepoint dispatches the
keyframe-fidelity diff alongside the existing tween-fidelity path.

sdkShadow.ts: runShadowDispatch now emits dispatched:false reason:unmapped_type
if a future PatchOperation type ever escapes patchOpsToSdkEditOps, so the gap
surfaces in telemetry instead of vanishing.

Tests: sdkShadowGsapKeyframe.test.ts (18) covers index resolution, op mapping,
the ambiguity guard, the keyframe-aware diff, the runner, and the unmapped-type
guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:27:48 -07:00
Vance IngallsandClaude Opus 4.8 5aca3ad770 fix(studio): suppress shadow-parity false positives in timing + text (#1508)
runShadowTiming: compare start/duration with a relative epsilon (1e-6)
instead of exact equality so float-precision drift (3.1 vs
3.0999999999999996, 21.36 vs 21.360000000000014) no longer flags; a real
difference (3.1 vs 3.5) still flags. trackIndex stays exact.

property:text resolver: trim both sides (snapshot.text is already trimmed)
and collapse empty-string vs absent (null) text so trailing-whitespace and
empty-vs-null no longer flag. Genuine text differences are unaffected; the
per-keystroke length lag is a caller-side debounce concern.

Adds tests for both fixes plus regression tests documenting two REAL SDK
divergences the shadow correctly surfaces (transform-origin removal no-op;
duplicate-bare-id delete resolution) — flagged, not fixed here.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:26:58 -07:00
Vance Ingalls c096ff3afa fix(studio): kill false-positive shadow GSAP fidelity mismatches (#1507)
## What
Kills two false-positive classes in the SDK shadow GSAP value-fidelity diff (`sdkShadowGsapFidelity.ts`).

1. **Float precision** — `numericEqual` compared exactly, so SDK-computed `3.0999999999999996` vs server `3.1` flagged as drift. Now a relative epsilon (`abs(a-b) <= 1e-6 * max(1,|a|,|b|)`); real `2` vs `1` still flags.
2. **Selector-form divergence** — `[data-hf-id="X"]` (SDK writer) vs `.class`/`#id` (server writer) for the same element produced phantom `present`/`absent` pairs. `makeSelectorResolver` now keys tweens by resolved element (incl. nodes with no `data-hf-id`), unifying the forms.

## Why
Surfaced by production SDK-shadow parity telemetry — `gsap_fidelity` was the noisiest real-traffic op; both are diff-harness artifacts, not SDK drift.

## Tests
Epsilon (clean + real-drift) + selector-unification for `#id`/`.class`/`[data-hf-id]`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-16 12:19:16 -07:00
Vance Ingalls 479184ecf0 chore: release v0.6.104 2026-06-16 02:58:48 -07:00
Vance IngallsandClaude Opus 4.8 42696f0af4 fix(studio): make SDK shadow telemetry fire + be correct (5 fixes, E2E-verified) (#1491)
* fix(studio): open SDK shadow session in master view (was never opening)

useSdkSession(projectId, activeCompPath) received activeCompPath=null in the
master/entry view — the studio's convention where null means index.html
(isMasterView = !activeCompPath || activeCompPath === "index.html"). The hook's
guard `if (!projectId || !activeCompPath) return` then bailed, so the SDK
session never opened in the default editing surface. Result: sdkSession was
null there → every shadow tap (onDomEditPersisted, onElementDeleted, timing,
gsap) was undefined/no-op → zero sdk_shadow_dispatch telemetry for master-view
edits (the common case). Shadow only fired when a sub-comp was explicitly
opened (which sets activeCompPath).

Resolve null → "index.html" (matching the existing convention used by
isMasterView and blockInstaller) so the session opens in master view.

Verified live: instrumenting the hook showed phase "skipped_no_ids"
(activeCompPath null) before, "opened" after.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(studio): shadow property parity — read camelCase style key, not kebab

The inline-style parity resolver read flat.styles[op.property] with the
kebab-case PatchOperation key ("background-color"), but ElementSnapshot
inlineStyles are camelCase ("backgroundColor"), so the read-back was always
null → a false value_mismatch on every hyphenated CSS property. Single-word
props (color, opacity) coincide, so unit tests missed it.

Found live: a color edit on a box emitted op:property mismatchCount:1 with
{property:"background-color", expected:"rgb(255,79,88)", actual:null}. Convert
kebab→camel for the read-back (fall back to the raw key).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(studio): emit op:delete shadow for timeline-clip deletes

The Delete/Backspace hotkey routes to handleTimelineElementDelete whenever a
timeline element is selected (useAppHotkeys: `if (selectedElementId) {
handleTimelineElementDelete(el); return; }`), returning before the
shadow-wired handleDomEditElementDelete. Every clip is a timeline element, so
clip deletes — the common case — emitted no op:delete; the delete shadow only
fired for a non-timed DOM selection.

Add runShadowDelete(sdkSession, element.hfId) to handleTimelineElementDelete's
success path, mirroring the move/resize timing taps.

Verified live (browser-use): deleting a clip now emits
sdk_shadow_dispatch op:delete dispatched:true mismatchCount:0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(studio): match GSAP fidelity tweens by resolved element, not raw selector

gsap_fidelity keyed tweens by id (targetSelector-method-position). On tween
ADD, the SDK writer emits [data-hf-id="X"] selectors while the server emits
class selectors (.x) for the same element — different ids → false
present/absent mismatch (mc:2) on every add. Update/remove were clean (the
tween already existed with one consistent selector).

Key by resolved element (selector → data-hf-id via the pre-op DOM) + method +
position, so equivalent tweens match and only real value drift registers.
Falls back to raw selector when resolution isn't possible.

Found live (browser-use): adding a tween emitted gsap_fidelity mc:2 with
{[data-hf-id="hf-b"]-to-0 present-only} + {.b-to-0 present-only}.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(studio): don't shadow studio-internal data-hf-* marker attributes

Property-path parity false-mismatched on canvas-drag (path-offset) edits, which
emit attribute ops like {property:"data-hf-studio-path-offset"}:
1. The name was built as `data-${op.property}` → double-prefix
   "data-data-hf-studio-path-offset".
2. The SDK model excludes all data-hf-* attributes, so even the right name
   reads back null → false value_mismatch.

attrName() prefixes only when needed; isShadowableOp() drops data-hf-* attribute
ops (studio-internal markers the SDK can't represent), filtered in
sdkShadowDispatch before dispatch + parity.

Code-confirmed via handleDomPathOffsetCommit → commitPositionPatchToHtml →
persistDomEditOperations → onDomEditPersisted; live repro blocked because the
test comp's elements were GSAP-animated (drags route to the GSAP path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(studio): document tweenKey + selector-resolver ceilings (PR review)

Per review (Rames, non-blocking): name the two silent fail-modes in the GSAP
fidelity diff rather than build speculative disambiguators (not observed in
studio-emitted templates).

- tweenKey: coincident tweens (same element+method+position) collapse, last
  wins. Props can't join the key — a matched pair must share a key for the
  field-diff to run. Upgrade path: property-name hash.
- makeSelectorResolver: first-match heuristic; ambiguous shared-class selectors
  may misunify. Upgrade path: querySelectorAll + uniqueness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:56:12 -07:00
Vance IngallsandClaude Opus 4.8 7593aac5ef feat(sdk,studio): populate animationIds; shadow GSAP update/delete + value fidelity (#1474)
* feat(sdk,studio): populate animationIds; shadow GSAP update/delete

Closes the GSAP shadow gaps. The server's animationId was assumed to live in a
separate id-space — it does not: the studio-api read path (T6e) and the SDK
both derive tween ids as targetSelector-method-position from the same acorn
parser, so server ids are dispatchable in the SDK as-is.

SDK: populate ElementSnapshot.animationIds (was a hardcoded stub) from
parseGsapScriptAcornForWrite().located, resolving each tween's targetSelector
to element hf-ids. Makes the snapshot truthful and enables real GSAP parity.

Studio: shadow deleteGsapAnimation (removeGsapTween) and updateGsapMeta
(setGsapTween) using the server animationId directly. GSAP add/remove parity
now verifies via animationIds (present after add, gone after remove). set is
existence-only — the SDK still has no per-tween property reader (value fidelity
would need serialize()-script round-trip diffing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(studio): GSAP value fidelity via serialize round-trip diff

Closes the last shadow gap: GSAP value fidelity. Existence parity confirmed a
tween was created/removed but not that its values (duration/ease/position/
properties) matched the server, since the SDK has no per-tween property reader.

runShadowGsapFidelity opens a fresh SDK doc from the server's pre-op file
(result.before), applies the same typed op, serializes, and structurally diffs
the SDK's GSAP script against the server's resulting script (result.scriptText).
Both are re-parsed via parseGsapScriptAcorn, so formatting/whitespace never
produces false positives — only real value drift does. gsapFidelityMismatches
reports per-field drift and tween presence/absence.

Wired at the commitMutation chokepoint (the only place with the server's
before+after scripts); handlers pass the typed ShadowGsapOp via
CommitMutationOptions.shadowGsapOp. Emits sdk_shadow_dispatch op:gsap_fidelity.
Complements the existing live existence shadow (op:gsap).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(studio,sdk): address shadow code-review findings

- gsapFidelityMismatches: canonical comparison (sort property keys, numeric-
  coerce position/duration/values). Server (addAnimationToScript) and SDK
  (gsapWriterAcorn) are different writers; non-canonical compare flagged
  key-order / number-vs-string differences as false value drift.
- document.ts buildAnimationIdMap: memoize the acorn parse by script text
  (single-entry). getElements() invalidates on every dispatch, so shadow's
  frequent dispatches were re-parsing the full GSAP AST each rebuild. Selector
  resolution still runs per-call (depends on live DOM).
- runShadowGsapFidelity: early-bail when serverScript/beforeHtml is empty —
  skip the costly openComposition.
- useSafeGsapCommitMutation: import the shared CommitMutationOptions/
  CommitMutation instead of a stale local duplicate (was missing shadowGsapOp).
- align extractGsapScript marker set across sdkShadow.ts and document.ts
  (gsap || __timelines || ScrollTrigger) so both pick the same script.

Tests: +2 canonical-compare cases (key-order, number-vs-string → no drift).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(studio,sdk): fallow gate for #1474 (fidelity diff + test clones)

- suppress moderate CRAP on gsapFidelityMismatches and the runShadowGsapTween
  parity arrow (comparison/parity functions are inherently branchy)
- suppress two pre-existing test clones in session.test.ts surfaced by the
  added animationIds tests (TestPreviewAdapter stub, selectionchange setup)

Rebased onto the updated #1473 (no-persist shadow session); inherits the
persist-race fix and prior fallow suppressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(studio): extract GSAP fidelity to its own module (file-size gate)

sdkShadow.ts hit 602 lines (CI File size check: max 600). Move the GSAP
value-fidelity diff (gsapFidelityMismatches, runShadowGsapFidelity, and their
private helpers) into sdkShadowGsapFidelity.ts; re-export from sdkShadow.ts so
the import surface is unchanged. sdkShadow.ts now 430 lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(studio,sdk): address #1474 review feedback

- CodeQL js/bad-tag-filter: the GSAP <script> extraction + test regexes now
  match </script\s*> (whitespace-before-close variant). 3 alerts resolved.
- Wiring (Miguel): extract resolveGsapFidelityArgs — a pure, narrowing gate for
  the commitMutation chokepoint (no non-null assertions) — and unit-test the
  fire/skip conditions (session, op, before, scriptText). Replaces the inline
  guard so the wiring decision is covered without rendering the hook.
- Property-handler scope (Rames): comment at the chokepoint documenting that
  only meta-level ops (add/update-meta/delete) carry shadowGsapOp today;
  per-property and keyframe handlers are a deliberate follow-up. Also why
  scriptText can be null.
- Test coverage (Rames): multi-tween-per-element and shared-selector
  cross-element animationIds cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(studio): CodeQL js/bad-tag-filter — match </script[^>]*> close tags

`</script\s*>` still tripped CodeQL on attribute-junk closes like
`</script foo>` (HTML5 ignores junk before `>`). Widen the close-tag match to
`</script[^>]*>` in the GSAP-script extraction and the test regexes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:40:28 -07:00
Vance IngallsandClaude Opus 4.8 8f15e9f09b feat(studio): extend SDK shadow to delete/timing/gsap-add + default on (#1473)
* feat(studio): default SDK shadow dispatch on for parity telemetry

Shadow mode keeps the server patch path authoritative (no user-visible
change) and emits sdk_shadow_dispatch parity signal. Default it on so we
collect addressing/serialize-drift telemetry from all traffic before any
cutover. Disable via VITE_STUDIO_SDK_SHADOW_ENABLED=false.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(studio): shadow parity for delete/timing/gsap ops + wire delete

Extends shadow visibility past the property-edit path. Adds a can()-first
shadow core (pure addressing/validity pre-check, works even for GSAP which
has no snapshot value) plus runShadowDelete/runShadowTiming/runShadowGsapTween.
Parity coverage: delete = getElement null (full); timing = snapshot
start/duration/trackIndex (full); gsap = can()+dispatch+returned-id only
(animationIds is a stub, tween values are script-level — full fidelity needs
serialize() round-trip diffing, out of scope).

Wires the delete runner end-to-end via an onElementDeleted callback
(useDomEditSession → useDomEditCommits → useElementLifecycleOps), fired after
the server delete succeeds. Server stays authoritative. Timing/GSAP wiring
follows (each needs threading sdkSession into useTimelineEditing /
useGsapScriptCommits).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(studio): wire timing + GSAP-add shadow dispatch

Timing: thread sdkSession into useTimelineEditing; fire runShadowTiming after
move/resize persist (server authoritative). Moved the useSdkSession call above
useTimelineEditing so both share the single session (no duplicate).

GSAP: thread sdkSession through useGsapScriptCommits → useGsapAnimationOps;
shadow addGsapAnimation via runShadowGsapTween after the server add. Only the
add path is shadowed — delete/update key on the server's animationId, which
doesn't resolve in the SDK's independent id-space (would emit false
cannot_dispatch). "set" has no SDK method, so it's skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(studio): address #1473 review — no-persist shadow session + fallow gate

Blocker (Rames): the shadow runners dispatched on the live persisted SDK
session, so each shadow op fired the persist queue → an HTTP write of the SDK's
serialize() output, clobbering the studio's authoritative write (default-on
shipped this). Fix: open the shadow session WITHOUT persist — it reads from the
server but never writes back. Shadow dispatches mutate the in-memory model only
and are discarded on the next reload-on-change. Cutover (Step 3c+) must re-add
persist together with self-write suppression. No persist consumer exists in
this stack (cutover is not in main), so this is safe and keeps default-on.

Fallow CI gate (Miguel):
- drop unused `export` on RecordEditInput (dead-type)
- suppress pre-existing CRAP with reasons: commitMutation, addGsapAnimation;
  file-level complexity on useTimelineEditing (shadow .then() branches nudge
  several callbacks over threshold — telemetry-only)
- suppress 3 pre-existing clones surfaced by adjacent edits (save-error
  formatter, prop-drilling passthrough, file-change reload handler)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(studio): scrub user content from shadow property-path telemetry

Addresses #1473 review concern (Rames): inline-style and text-content edits
put user content into the sdk_shadow_dispatch mismatch expected/actual fields.
Redact before emit — text-content values fully redacted (length only), others
length-capped at 64. The in-memory parity result keeps raw values, so the
parity logic and tests are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:08:29 -07:00
69aa595f38 feat(studio): stage 7 step 3b — SDK shadow dispatch parity mode (#1450)
* feat(studio): stage 7 step 3b — SDK shadow dispatch parity mode

Wire onDomEditPersisted callback from useDomEditCommits into useDomEditSession,
calling reportShadowDispatch (flag-gated via VITE_STUDIO_SDK_SHADOW_ENABLED) to
dispatch equivalent SDK ops alongside the server patch path and emit
sdk_shadow_dispatch telemetry with mismatch details.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(studio/sdkShadow): catch dispatch errors, return dispatch_error mismatch

Wrap the dispatch loop in try/catch so a throwing SDK dispatch never
propagates to Studio UX. Returns dispatched:false with kind="dispatch_error"
and the error message for telemetry. One new TDD test (RED→GREEN verified).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(studio): batch shadow dispatch, rename runShadowDispatch, add PatchOperation import

Wrap the shadow dispatch loop in session.batch() so a mid-loop throw
cannot leave the SDK session in a partially-applied state. Without the
batch boundary, one failing op would update some elements but not
others, diverging the shadow session from the real one.

Rename reportShadowDispatch → runShadowDispatch to eliminate the
misleading 'report' prefix — the function mutates the SDK session, it
is not read-only. Update the only caller (useDomEditSession).

Add missing PatchOperation import to useDomEditCommits (the type was
already used in the onDomEditPersisted interface but never imported).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* docs(studio/sdkShadow): note persist:error drift risk in parity comparisons

Also remove unused re-exports from useDomEditCommits (GSAP_CSS_FALLBACK_BLOCKED_MESSAGE
and PersistDomEditOperations — fallow confirmed 0 consumers) and suppress the
Vite ?raw import in sdk-playground that fallow can't resolve statically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-15 15:38:35 -07:00
5fe87cc39b feat(sdk,studio): stage 7 step 3a — persistPath + SDK session reload-on-change (#1449)
* feat(sdk,studio): stage 7 step 3a — persistPath + SDK session reload-on-change

Stage 7 Step 3a — SDK plumbing for routing Studio commits through the SDK
session. No behavior change: the session stays idle (no op routed yet).

SDK:
- Add OpenCompositionOptions.persistPath; thread to createPersistQueue so the
  persist queue writes back to the composition's real path instead of the
  "composition.html" default (blocker A).

Studio (useSdkSession):
- Pass persistPath = activeCompPath so a future dispatch persists the right file.
- Re-open the session when the active composition file changes on disk (HMR
  hf:file-change / SSE file-change), scoped to activeCompPath, so the in-memory
  linkedom document never goes stale under code-editor/agent/server edits
  (blocker C). Re-opening is additive while the session is idle; 3c must add
  self-write suppression once dispatch writes.

Tests: SDK persistPath default + override; shouldReloadSdkSession path-match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(sdk): document persistPath as immutable for session lifetime

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-15 14:03:08 -07:00
92e2c8ce6a feat(studio): stage 7 step 1 — wire SDK session into Studio (#1443)
* feat(studio): stage 7 step 1 — wire SDK session into Studio

Creates useSdkSession hook: fetches active composition HTML, opens an
SDK Composition backed by createHttpAdapter, disposes on comp/project change.
Session is idle (no dispatch routed yet) — Step 3 wires edit ops through it.

Also removes createFsAdapter from SDK main entry (Node-only; subpath-only:
@hyperframes/sdk/adapters/fs). Required for Studio typecheck to pass when
importing @hyperframes/sdk — fs.ts uses node:fs/promises which Studio's
tsconfig does not include.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(studio): stage 7 step 2 — mirror canvas selection into SDK session

useSdkSelectionSync: effect that calls session.setSelection(hfIds) whenever
domEditSelection or domEditGroupSelections changes. Maps each entry's hfId;
skips entries without one. Pure additive — no existing hook modified.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(studio): use adapter.read() in useSdkSession bootstrap

Build the HttpAdapter first, then call adapter.read(activeCompPath)
instead of duplicating URL construction with a raw fetch. Eliminates
the /files/encode duplication already in HttpAdapter.read().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(studio): flush in-flight http writes before disposing SDK session

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* fix(studio): dispose SDK session if cleanup fires during openComposition

Reviewer found a race: if the effect cleanup runs while openComposition is
awaited, comp is null so cleanup is a no-op, but the composition is then
set and never disposed. Add an explicit check after the await so any
composition opened after cancellation is disposed immediately.

Also wire the missing useSdkSession call in App.tsx (sdkSession was
referenced but never declared — pre-existing typecheck failure), move
the stableRenderQueue memo into useRenderQueue so App.tsx stays under
the 600-line architecture gate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-15 14:00:39 -07:00
30ce1f35a2 feat(sdk): stage 7 step 2 — setSelection API (#1442)
* feat(sdk): stage 7 step 2 — setSelection API

Adds setSelection(ids: string[]) to Composition interface and CompositionImpl.
Fires selectionchange; does not touch undo stack or patch stream.
11 contract tests: get/set/clear, event firing, copy semantics, no undo/patch side-effects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sdk): guard setSelection against same-id no-ops

Skip event dispatch when ids are identical (same length, same order)
to prevent double-firing selectionchange from callers that call
setSelection with the same list. Two new tests (RED→GREEN verified).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sdk): de-duplicate ids in setSelection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* fix(sdk): document PreviewAdapter.on("selection") as stage 8 prep

Reviewer noted it is dead surface in this stack — no caller uses it.
Add comment explaining it is wired up in stage 8 when the preview host
pushes selection events up to the SDK session.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-15 13:55:53 -07:00
Vance Ingalls c19898e799 feat(sdk): stage 7 step 1 — http persist adapter (#1441)
## What

Adds `createHttpAdapter` — a browser-native `PersistAdapter` that reads and writes composition files through the Studio dev-server's `/api/projects/:id/files/...` endpoints using the Fetch API. Exported as a subpath: `@hyperframes/sdk/adapters/http`.

## Why

The SDK's `PersistAdapter` interface previously had filesystem (`fs`) and in-memory (`memory`) implementations, both Node-only. Studio runs in the browser and needs to persist compositions back to the dev server. This adapter is the browser-compatible plug that lets `openComposition` work in a Studio context without Node I/O.

## How

- `HttpAdapter` implements `PersistAdapter`: `read` → GET, `write` → PUT with per-path queue to serialize concurrent writes to the same file (at-most-once in-flight per path)
- `flush()` waits for all in-flight queues to drain
- `listVersions` / `loadFrom` proxy the server's version history endpoints
- `on('persist:error')` fires on network/non-2xx failures without throwing; callers can surface errors non-fatally
- Retry is caller's responsibility; the adapter does not retry

## Test plan

- `http.test.ts`: read/write round-trip with MSW, concurrent write serialization, persist:error event on 503, flush drains queue
- Contract suite (`persistAdapter.contract.test.ts`) passes for the http adapter against a mock server
2026-06-15 13:52:20 -07:00
b158870d8f feat(sdk): stage 6 — sub-composition scoped ids (F9) (#1434)
* feat(sdk): stage 6 — sub-composition scoped ids (F9)

Adds fully-qualified scoped ids for addressing elements inside inlined
sub-compositions, so callers can target "hf-HOST/hf-LEAF" unambiguously
even when bare hf-ids collide across sub-composition boundaries.

Changes:
- model.ts: resolveScoped() traverses id segments through nested subtrees;
  isNewHostBoundary() detects host boundaries (dcf ≠ parent dcf handles
  outerHTML innerRoot edge case)
- types.ts: HyperFramesElement gains scopedId field
- document.ts: buildElement carries scopePrefix, propagates childPrefix
  at host boundaries; buildRoots starts with ""
- patches.ts: RFC 6902 escapeIdForPath / decodePathSegment for scoped ids
  containing "/"; all path builders and pathToKey/keyToPath updated
- session.ts: getElement() matches by scopedId; find() returns scopedIds;
  orphan cleanup decodes RFC 6902 before key comparison, preserves removal
  markers, purges property sub-keys for both bare and scoped ids
- mutate.ts: all element handlers use resolveScoped instead of findById;
  handleRemoveElement collects full subtree hf-ids before removal for
  complete GSAP animation cascade (Q3 fix); validateOp uses resolveScoped

20 new contract tests in session.subcomp.test.ts covering resolveScoped,
scopedId propagation, dispatch to scoped targets, RFC 6902 patch encoding,
override-set key format, orphan purge, and serialize stability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sdk): add find({ composition }) filter — Stage 6 WS-C completion

Closes the last headless-testable Stage 6 gap (F9 workstream C).

`find({ composition: "hf-host" })` returns all scopedIds whose prefix
matches the given host id — i.e. every element mounted inside that
sub-composition, at any depth. Combinable with other FindQuery fields
(tag, text, name, track). 3 new contract tests in session.subcomp.test.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sdk): addGsapTween resolves scoped id to bare leaf; validateOp checks target exists

- handleAddGsapTween: strip host prefix for scoped ids (hf-host/hf-leaf →
  selector [data-hf-id="hf-leaf"]) — DOM element carries only the leaf part
- validateOp addGsapTween: call resolveScoped to surface E_TARGET_NOT_FOUND
  before the GSAP script checks (previously can() returned ok for missing targets)
- patches.ts pathToKey: remove dead ?? null (decodePathSegment never returns undefined)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-15 02:21:36 -07:00
f10f3425a5 feat(sdk): stage 5 — export adapter factories from package root (#1432)
* feat(sdk): stage 4 — canUndo/canRedo, removeElement GSAP cascade, override-set cleanup

* docs(sdk): document cascadeRemoveAnimations bare-id v1 limitation for scoped ids

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* chore(sdk): remove sdk-status-report.txt from source tree

Internal planning artifact should not be committed to the repo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* feat(sdk): stage 5 — export adapter factories from package root

Expose the concrete adapter factories so consumers no longer reach into deep
adapter paths:
- createHeadlessAdapter — no-op PreviewAdapter for agents/CI/SSR (no browser)
- createMemoryAdapter — in-memory PersistAdapter for tests/headless open
- createFsAdapter (+ FsAdapterOptions) — node fs PersistAdapter for local dev

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>
2026-06-15 02:12:51 -07:00
9627e03fa7 feat(sdk): stage 4 — canUndo/canRedo, removeElement GSAP cascade, override-set cleanup (#1431)
* feat(sdk): stage 4 — canUndo/canRedo, removeElement GSAP cascade, override-set cleanup

* docs(sdk): document cascadeRemoveAnimations bare-id v1 limitation for scoped ids

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* chore(sdk): remove sdk-status-report.txt from source tree

Internal planning artifact should not be committed to the repo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

---------

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-15 02:07:39 -07:00
5ecaac1fcb feat(sdk): can() returns CanResult; T4 dispatch-boundary tests (#1426)
* feat(sdk): can() returns CanResult; T4 dispatch-boundary tests

* fix(sdk): 8 code-review correctness fixes

- setGsapScript: remove element when newScript="" (fixes undo/redo duplicate-script bug)
- parseDeclarations: track quotes so ; inside CSS values (data URIs) doesn't split
- handleRemoveGsapKeyframe: guard against duplicate-percentage ambiguity (return EMPTY)
- resolveKeyframe: return kfs so callers can check uniqueness
- handleSetClassStyle: emit op:"add" (not "replace") when no prior <style> element
- FsAdapter listVersions: Number(f.split("_")[0]) — was NaN due to underscore in key
- FsAdapter doWrite: split try/catch so appendVersion failure doesn't fire error handlers
- FileAdapter playground: add content:"" field to satisfy PersistVersionEntry contract

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sdk): export CanResult from package root so callers can switch on result.code

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-15 02:02:37 -07:00
0a30011abd fix(sdk): fs adapter flush() tracks in-flight writes; add to T13 contract suite (#1425)
* fix(sdk): fs adapter flush() tracks in-flight writes; add to T13 contract suite

* fix(sdk): document flush() first-error rejection semantics

Promise.all rejects on first write failure; errors also surface via
persist:error event channel per write.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

---------

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-15 01:57:54 -07:00
577a689860 feat(sdk): file-backed fs adapter + setTiming GSAP sync; sdk-playground workspace (#1458)
* feat(sdk): file-backed fs adapter + setTiming GSAP-script sync; add sdk-playground

* fix(sdk): address PR #1423 review — oxfmt, PersistVersionEntry contract, race, comments

- bunx oxfmt packages/sdk-playground/index.html (unblocks CI)
- PersistVersionEntry.content is now optional; HTTP adapter omits it for lazy-load
- fs adapter: monotonic key (Date.now-NNNN) + per-path write serialization via promise chain
- mutate.ts: fix wrong comment on GSAP sync reason; add caveat to "pre-parse once" note

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* fix(core): oxfmt gsapSerialize.ts — unblocks Preflight across stack

Pre-existing format issue on the base; fixing here to unblock CI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* chore: update bun.lock for sdk-playground workspace

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 01:55:19 -07:00
Vance IngallsandClaude Sonnet 4.6 b8fa4b5dd2 refactor(core): swap studio-api read path from recast to acorn parser (T6e) (#1392)
* 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>
2026-06-15 00:49:27 -07:00
Vance Ingalls 6dcbb5530e feat(sdk,core): phase 3b — 8 gsap/label ops + setClassStyle (#1379) 2026-06-15 00:46:17 -07:00
Vance Ingalls 8b56e558c6 feat(core): parse-parity suite for acorn parser (T6d) (#1370) 2026-06-15 00:42:47 -07:00
Vance Ingalls 0fbda8acff feat(core): acorn GSAP write path — magic-string offset-splice (T6c) (#1369) 2026-06-15 00:38:10 -07:00
Vance Ingalls be4a28ae72 feat(core): acorn GSAP read path with T6b differential corpus tests (#1368)
## 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)
2026-06-15 00:33:08 -07:00
e6da47d8f8 feat(studio): drag keyframes with live beat snapping (#1439)
* feat(studio): drag keyframes with beat snapping

Keyframe diamonds are draggable with live preview and snap to the music
beat grid (requires VITE_STUDIO_ENABLE_KEYFRAMES=1).

Drag model: a tween start point trims the front (end fixed), an end point
resizes (start fixed), an intermediate keyframe moves within the tween
(adjacent segments resize, others untouched; start/end moves remap the
intermediates to preserve their absolute times). The keyframe snaps to the
nearest beat within ~8px, centered exactly on the dot.

Reliability: the commit resolves the dragged element's selection + parsed
animations on demand (awaited) instead of relying on the async DOM-edit
session, picks the tween whose window contains the keyframe's original
time among same-group tweens, and holds the dropped position optimistically
until the cache round-trip lands. Cache clip% precision raised to 0.001%
so the marker lands exactly where dropped.

Pure match/plan logic + unit tests in editor/keyframeMove.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* fix(studio): harden keyframe drag commit (review follow-ups)

- pickKeyframeTween no longer falls back to ALL animations on a selector
  mismatch — it only picks among the dragged element's own tweens, so a
  class/compound-selector mismatch can't edit a different element. No
  match → no-op.
- computeKeyframeMovePlan bails to a no-op when a keyframe-array tween's
  dragged keyframe can't be located (stale cache / precision drift) instead
  of falling through to an end-point resize that silently rescaled the
  whole tween and re-timed every keyframe.
- usePopulateKeyframeCacheForFile clipPct now uses 0.001% precision
  (matching useGsapAnimationsForElement) so beat-snapped keyframes from the
  file-wide cache also center on the dot and the two caches agree.
- The optimistic drag hold only releases once the cache reflects the
  committed position (a keyframe near the held %), so an unrelated cache
  rebuild no longer flashes the diamond back to its old spot.
- A drag's document listeners are cleaned up on unmount, so an unmount
  mid-drag (clip delete / comp switch / zoom-out) no longer leaks them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* feat(studio): shrink + lower keyframe diamonds under the beat strip

When a clip's track shows the beat-dot strip (the top band), its keyframe
diamonds and connecting lines render at 45% size and centered in the
region below the band, so they don't collide with the dots. Full size and
vertically centered otherwise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* fix(studio): ignore keyframe re-drag during the optimistic-hold window

After a drop, the diamond is held at its dropped position (via effPct) until the
file round-trip lands, but `pct` passed to handlePointerDown still comes from
props (the pre-drop position). Re-grabbing the same keyframe in that window
would track the drag from a stale origin and commit against the wrong tween (or
no-op via the stale-cache guard). Skip starting a drag while a hold is pending;
it clears on the cache match (≤2s fallback). Click selection is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

---------

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-14 17:23:54 -07:00
d9f69f61e7 feat(studio,cli): music beat detection with timeline guides + headless beats CLI (#1424)
* feat(studio,cli): music beat detection with timeline guides + headless beats CLI

Beat detection for music tracks: the Studio draws beat guides on the active
track, beats are user-editable and persist to a project file, and a new
`hyperframes beats` CLI generates that file headlessly before the Studio opens.

Detection lives in @hyperframes/core/beats (shared by Studio + CLI): an energy
onset detector cross-validated with bpm-detective, regularized to an octave-
aligned grid, silence-gated, with per-beat loudness. Music-only — an
<audio data-timeline-role="music"> is analyzed; voiceover is excluded.

Studio: green beat lines + draggable dots on the selected track; add at playhead,
drag to move, double-click to delete (audio scrubs); edits persist to
beats/<audio>.json and are undoable (interleaved with file history).

CLI: `hyperframes beats [dir]` runs the same detection in headless Chrome
(prebuilt browser bundle in dist) and writes the beat file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* feat(studio): timeline beat-grid + zoom UX refinements

- Center-anchored magnify: zooming via the toolbar/slider keeps the time
  at the viewport center fixed instead of anchoring at the left. Pinch
  still anchors at the cursor.
- Move-snap to beats: dragging a clip snaps whichever edge (start or end)
  is nearest a beat, matching the existing resize-edge snapping.
- Beat lines on track backgrounds: faint full-height beat lines now paint
  behind the clips on every track lane (brightness scales with loudness);
  the green dots stay on the active track's top bar.
- Waveform follows zoom: bars fill the full clip width and resample the
  windowed peaks, so the waveform stretches with zoom instead of stopping
  partway across a widened clip.
- Beat dots centered in the top bar: align the dot band to the clip top
  (CLIP_Y) so the dots sit centered in the dark bar instead of being
  bisected by the clip's top border.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* fix(studio): preserve media sourceDuration across element re-derivation

Moving a non-music clip re-derived the timeline elements into fresh
objects whose sourceDuration the DOM scan hadn't loaded yet. The async
probe skips srcs already in its cache, so the value was silently
dropped — trimFractions then returned no window and the trimmed music
waveform reset to the full source pinned at the track start.

Re-apply the cached probe duration synchronously on every derivation
(applyCachedSourceDurations) and extract the async probe loop into
probeMissingSourceDurations to keep useTimelinePlayer within the file
size limit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* feat(studio): skip beat-snap on the music track, highlight move-snap target

The music track defines the beats, so moving or trimming it no longer
snaps to its own beats (isMusicTrack guard on both the move and resize
snap paths).

Moving another clip snapped only on drop with no cue. snapMoveStartToBeat
now also returns the beat it will snap to; BeatBackgroundLines draws that
beat's line as a bright neon-green glow while the clip's edge is within
the snap region, so the target is visible before drop.

Also drops .commitmsg.tmp, accidentally committed via git add -A.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* feat(studio): hide playhead while dragging a beat; default beat dots to music track

- Dragging a beat dot now hides the playhead guideline (new beatDragging
  store flag set on beat pointer down/up) so its line doesn't track the
  scrub and clutter the beat being moved.
- Beat dots render on the selected track, falling back to the music track
  when nothing is selected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* fix(core): remove polynomial-ReDoS regex from audioRelPathForSrc

CodeQL js/polynomial-redos: the lazy `.+?` followed by an optional
trailing `[?#].*$` backtracks polynomially on crafted `/preview/...`
inputs. Parse the preview-relative path with indexOf/slice instead, and
strip the query/hash with a single linear char-class search. Behavior is
unchanged for all preview/absolute/blob/data/bare inputs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* fix(studio,core,cli): review hardening for beat detection + timeline UX

- playerStore.reset() now clears beat state (analysis, edits, undo/redo,
  persist) so a project switch can't apply the previous project's beats,
  undo stack, or file-writer to the new one.
- removeUserBeat returns the same reference on a no-op, and delete/move beat
  actions skip committing when nothing changed — no more phantom undo
  entries / debounced writes for no-op edits.
- regularizeBeats bails to raw onsets when the (octave-misread) tempo would
  produce a sub-125ms grid, avoiding a tens-of-thousands-of-beats freeze.
- parseBeats clamps strength to [0,1] and rejects non-finite time/strength,
  so a hand-edited file can't feed NaN into the gamma curve (Math.pow on a
  negative base) and blank out beat markers.
- Start-edge beat-snap now also requires duration >= minDuration, matching
  the end-edge guard, so a rightward snap can't collapse the clip.
- Center-anchor zoom effect always consumes its skip flag, so a pinch that
  produced no pps change can't leave it stranded and skip the next zoom.
- Headless beats analyzer projects to {beatTimes,beatStrengths,bpm,confidence}
  before returning, so page.evaluate no longer serializes the full decoded
  PCM (channelData) across the CDP boundary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* fix(core): gate parseBeats on schema version

parseBeats accepted any object with a beats array, so a future v2 beat file
(with changed semantics) would be parsed silently as v1. Reject anything whose
version is not 1, treating an unknown version like an absent/invalid file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

---------

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-14 17:17:13 -07:00
a95e49dbda fix(core,player,studio): bound trimmed audio playback to the clip window (#1430)
* fix(player): bound the parent audio proxy to its clip window

When iframe autoplay is blocked, audible playback is promoted to a parent-frame
audio proxy. The proxy read the clip's data-start/data-duration once at adopt
time and mirrorTime() only skipped (never paused) the element outside that
window — so a trimmed/moved music clip kept playing the full source past its
on-timeline end, even though the iframe element was correctly paused.

Fix: the proxy keeps a reference to its source iframe element and re-reads
data-start/data-duration each mirror tick (live trims/moves apply), pauses the
proxy when the playhead leaves [start, start+duration), and resumes it when the
playhead re-enters during parent-owned playback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* fix(core,studio): bound trimmed audio playback to the clip window

Trimmed audio played to the source file's natural end instead of
stopping at the clip edge, on every audio path:

- WebAudio (the audible path in Studio): schedulePlayback now passes
  the clip's data-duration as the third start() arg, so the decoded
  buffer stops at the trimmed edge instead of running to the file end.
- Runtime element gating: the duration resolver caps each clip by its
  own data-duration (min of source length, host window, authored
  duration), so a trimmed <audio>/<video> element pauses at its edge.

Studio trim UX:

- Resize live-patches the media-start/playback-start offset, so a
  start-edge drag trims into the source instead of only repositioning
  the clip.
- AudioWaveform windows the rendered peaks to the trimmed slice so the
  waveform tracks the clip edges.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* fix(player,core): gate proxy playback to the live clip window

Review follow-ups on the parent-audio-proxy / WebAudio bound:
- seekAll now re-reads live source bounds (_refreshEntryBounds) before
  gating, so a paused scrub right after a trim/move uses the current clip
  window instead of the adopt-time one.
- playAll and clip adoption only start a proxy when the playhead is inside
  the clip's window (_playEntryIfActive), so bulk starts / promotion no
  longer blip audio for clips outside their window until the next tick.
- The WebAudio buffer is now bounded by the host-composition window too
  (matching resolveDurationSeconds), so a sub-composition-nested clip stops
  at the same edge on the WebAudio and HTMLMedia paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* fix(core,player): reschedule bounded WebAudio on rate change; guard NaN bounds

A bounded WebAudio source's wall-clock length is baked into start()'s duration
arg (in buffer-sample seconds) at its scheduling rate. Mutating playbackRate in
place on a later rate change does not rescale that bound, so a trimmed clip ends
early (fast) or late (slow). setRate now reports whether the rate changed and
exposes hasBoundedActiveSources(); the runtime stopAll()+reschedules active
clips at the new rate when any bounded source is live. The per-clip schedule
loop is extracted to a shared closure so play() and the rate path agree.

Also guard _refreshEntryBounds against a non-numeric duration attribute parsing
to NaN, which would make every window check false and let the proxy play past
its clip end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

---------

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-14 17:14:49 -07:00
Vance IngallsandClaude Fable 5 7a99ccec6d fix(core): honor root data-duration when GSAP timeline ends short (#1378)
* fix(core): honor root data-duration when GSAP timeline ends short

The authored-duration floor only counted child composition clips, never
the root element's own data-duration. A composition whose GSAP timeline
ended even 0.1s short of its declared data-duration reported the shorter
timeline length from player.getDuration() — and the studio's adapter
selection (docDuration <= adapterDur) then silently rejected the
audio-capable runtime player, downgrading preview playback to the
seek-scrubbing adapter, which never starts media elements or WebAudio.
Result: total audio silence with zero errors anywhere.

- include the root's declared data-duration in
  resolveAuthoredCompositionDurationFloorSeconds, making data-duration
  the source of truth for playable length (per the documented contract)
- console.warn in the studio when playback falls back to the
  seek-driven adapter, since the downgrade loses audio invisibly

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(studio): release static-seek adapter on native win, warn once on downgrade

Review findings on the previous commit, all in the static-seek fallback
path of useTimelinePlayer.getAdapter:

- A cached static-seek adapter was never paused when adapter selection
  later resolved a native adapter (the early returns bypass the fallback
  branch entirely), leaving its private rAF loop seeking the player while
  the native transport also drives it. The core data-duration fix makes
  this switch path much more common. releaseStaticSeekCache() now runs
  at every native-adapter return and at unmount.
- The downgrade warning fired on every cache miss — and the cache key can
  never hold for __timelines compositions because wrapTimeline() returns
  a fresh object per call, so it fired every rAF tick. It now warns once
  per downgrade streak (re-armed when a native adapter takes over).
- The warning interpolated adapterDur (the native __player duration,
  0 when absent) instead of the selected adapter's duration, and used a
  one-off "[hyperframes-studio]" prefix instead of the file's
  "[useTimelinePlayer]" convention.

The fallback cache logic moved to playbackAdapter.ts (with unit tests for
warn-once, cache identity, and pause-on-replace/release), which also
keeps useTimelinePlayer.ts inside the studio 600-line limit. Also
corrected a stale "no DOM reads" comment on the runtime transport tick —
the duration floor has always queried the DOM per call, and now also
reads the root's declared data-duration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:01:39 -07:00
Vance IngallsandClaude Sonnet 4.6 a0ee97210b fix(sdk,core): css tokenizer, override-set replay, setattribute safety, persist errors (#1350)
* fix(sdk,core): css tokenizer, override-set replay, setattribute safety, persist errors

* test(sdk,ci): smoke test + explicit sdk-tests CI gate

Smoke test covers the full public surface:
  openComposition → setStyle/setText/dispatch(moveElement) → serialize
  applyPatches + ORIGIN_APPLY_PATCHES tagging
  batch() coalescing + transactional rollback on throw
  undo/redo round-trip
  persist adapter write + persist:error surfacing
  T3 embedded mode: override-set apply on open + getOverrides round-trip

Adds sdk-tests CI job so SDK coverage is explicitly named and required —
prevents a repeat of the demo-next vitest-never-ran incident.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sdk): export adapter types, awaitable flush(), never-coalesce mode

- Export PersistAdapter, PreviewAdapter, PersistVersionEntry from package
  root — callers can now write typed fakes without reaching into internals
- Add flush(): Promise<void> to Composition interface + CompositionImpl —
  app-close handlers can await a clean drain of the persist queue
- coalesceMs <= 0 disables coalescing entirely in createHistory — enables
  deterministic test scenarios without per-entry timestamp manipulation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(sdk): p2 edge cases — setText no-text-node, override-remove non-existent, flush in smoke

- setText on element with no prior text node (firstTextIdx=-1 path)
- applyOverrideSet null removal on non-existent prop is a no-op (no throw)
- smoke persist test uses comp.flush() instead of setTimeout
- can() JSDoc clarifies Phase 3b false-return is intentional feature-detection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* ci: trigger regression suite

* fix(ci): add packages/sdk/package.json to Dockerfile.test workspace copy

bun install --frozen-lockfile fails in the regression Docker build because
the lockfile references the sdk workspace member but its package.json was
not copied into the image before the install step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 14:07:49 -07:00
Vance IngallsandClaude Fable 5 69d67f1d69 fix(studio): watch external project dirs so preview ETag invalidates (#1347)
* feat(sdk): scaffold @hyperframes/sdk — engine layer (model, RFC 6902 patches, mutate, apply-patches)

* fix(sdk): make engine-layer PR self-contained — trim index.ts, guard indexed access

- index.ts no longer exports document/session/history/persist-queue (those
  modules land in the next stacked PR); branch now typechecks standalone
- setOwnText: optional-chain children[i] access (TS2532 under
  noUncheckedIndexedAccess)
- fallow suppressions for buildPatchEvent + adapters/types.ts — consumers
  arrive in #1325

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): fail loudly on Phase 3b ops; add sdk to root build pipeline

- applyOp throws UnsupportedOpError (code E_UNSUPPORTED_OP) for the 9
  parser-backed ops instead of silently no-opping — callers must never
  believe an animation edit succeeded when nothing was mutated
- validateOp returns false for Phase 3b ops so can() feature-detects
- root package.json build filter now includes @hyperframes/sdk (package is
  dist-only; top-level build previously produced no SDK artifacts).
  publish.yml intentionally NOT updated — sdk stays unpublished until
  Phase 3 completes.

Adversarial-review findings F3 + F4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): cross-realm origin sentinel, dual width/height channel, contract docs

Round-2 review (Rames/Miguel) on the engine layer:

- ORIGIN_APPLY_PATCHES: unique symbol → namespaced string
  ('@hyperframes/sdk:applyPatches'). Symbols are realm-local — they don't
  survive postMessage/structured-clone, which T3 embedded hosts may forward
  patch events across. Namespaced string keeps collision risk negligible.
- setCompositionMetadata width/height: runtime treats data-width/data-height
  as a forced override of inline style (init.ts applyCompositionSizing).
  Style is always written; the data-* attr is updated when already present
  so the edit isn't clobbered on load. Absent attrs stay absent — inverses
  stay exact. Mirrored in the patch applier; 3 new tests.
- JsonPatchOp documented as the emit-only RFC 6902 subset
  (add/remove/replace); applier header notes move/copy/test are ignored.
- SdkDocument.html documented as a build-time snapshot (serialize() is the
  live state).
- patches.ts path-grammar comment fixed: timing/{start|end|trackIndex}.

NOT changed (with reasons, see PR reply): moveElement left/top matches
Studio's own inline-style commit convention (sourcePatcher); package version
follows the repo-wide single-version policy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): moveElement writes data-x/data-y, not left/top CSS

HF elements use data-x/data-y for positioning (read by htmlParser.ts,
emitted by hyperframes generator). CSS left/top is not the runtime convention.

Adds inverse round-trip test for prior position restore.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: update bun.lock after sdk package registration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(sdk): session API, optional history + persist-queue, adapters — Phase 3a complete

* fix(sdk): address review — live-DOM query cache, single parse, style parse dedup

- getElements/getElement/find now walk the live linkedom DOM via buildRoots
  with a lazily-built cache invalidated on dispatch/applyPatches — no
  serialize→ensureHfIds→parseHTML round trip per query
- openComposition parses once (parseMutable); dropped discarded _doc
  constructor param and the redundant buildDocument call
- document.ts buildElement reuses model.ts getElementStyles — removes
  duplicated parseInlineStyles (also fixes custom-prop camelCase mangling)
- JSDoc note: empty batch() still fires change handlers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): restore full public exports now session/document modules exist

index.ts re-exports document/session/history/persist-queue (trimmed in the
engine-layer PR to keep it self-contained); drops the temporary fallow
suppressions whose consumers now exist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): coalesce history by patch paths; replay override-set on open

Adversarial-review findings F1 + F2:

- history: coalescing now requires identical patch paths in addition to
  op types + origin + window. Previously two rapid setStyle calls on
  DIFFERENT elements merged into one entry carrying the second forward +
  first inverse — undo then reverted the wrong element and stranded the
  latest edit. Slider drags on one property still coalesce.
- T3 init: openComposition({ overrides }) now replays the stored
  override-set onto the freshly-parsed base before exposing the session
  (new keyToPath inverse mapping + applyOverrideSet). Previously the
  overrides were copied into the map but never applied — reopening an
  embedded composition showed and serialized the base template.
- examples: GSAP calls now feature-detect with can() (Phase 3b ops throw
  UnsupportedOpError as of the engine-layer fix); UnsupportedOpError
  re-exported from the package entry.
- 8 new session tests: coalesce same-path / cross-element / cross-prop,
  override round-trip (style/text/attr/timing/removal/restore-base).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): transactional batch rollback, sorted coalesce key, root-priority unify

Round-2 review (Rames/Miguel) on the session layer:

- batch() is now transactional: on throw, accumulated inverse patches are
  replayed in reverse and the override-set snapshot restored — the model is
  exactly as it was at batch entry. Previously a throwing batch left the DOM
  partially mutated with no patch trail, no history entry, no recovery path.
  2 new tests (model unchanged + undo is no-op after throwing batch).
- history coalesce key sorts opTypes — same op-type set coalesces regardless
  of dispatch order within a batch.
- applyPatches comment documents that emitted PatchEvents carry an empty
  inversePatches array (hosts keep their own inverse log).
- document.ts extractDimensions/extractDuration now use the engine's
  findRoot — dimension extraction and mutations agree on the root element
  ([data-hf-root] > #stage > first child). Dimensions prefer the runtime's
  data-width/data-height forced-override attrs, falling back to inline style.
- ownText documented: snapshot .text is trimmed display text; setText writes
  verbatim.

Deferred to follow-up (acknowledged, not ship-blocking): persist-queue flush
error surfacing, debounce window, path default, history ring-buffer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(lint): add gsap_studio_edit_blocked rule for manual timeline + GSAP element targeting

* fix(studio,core): persist manual position edits for GSAP-owned elements

- sourceMutation: linkedom CSSStyleDeclaration silently drops CSS custom
  properties and transform longhands via setProperty; patch the style
  attribute string directly so --hf-studio-offset-* and translate survive
  the server round-trip (positions never reached disk before this)
- gsapAnimatesTransform(): GSAP owns the full transform stack when it tweens
  ANY transform prop (scale, rotation, ...), not just x/y — it folds CSS
  translate into its cache once at init, zeroes the longhand once, and never
  re-reads it
- applyStudioPathOffset: for GSAP-owned elements keep translate:none live and
  sync the offset into GSAP's cache via gsap.set; writing the longhand
  double-applied the offset (disappearing elements, scrub snap-back)
- buildPathOffsetPatches: emit the var() translate expression explicitly so
  the persisted file re-folds on reload (live inline is none)
- StudioPathOffsetSnapshot: capture/restore GSAP x/y — the drag-response
  probe mutates GSAP's cache, which inline-style restore cannot undo (click
  made elements jump by the probe distance)
- reapplyPathOffsets: skip GSAP-owned elements (was x/y-only) to stop
  seek-time double-apply
- STUDIO_GSAP_DRAG_INTERCEPT flag (default off): keyframe drag intercept is
  opt-in until its recording path is hardened; commits take the CSS persist
  path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(studio): watch external project dirs so preview ETag invalidates

Project dirs are symlinked into data/projects from anywhere on disk, but the
preview signature cache was only invalidated by Vite's watcher, whose roots
don't cover external paths. Edits hit disk while the cached ETag kept
serving 304s — the browser showed a stale preview after refresh and edits
looked lost. Register each project dir with the watcher when its signature
is first cached.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:32:05 -07:00
Vance IngallsandClaude Fable 5 fc3ab76ce8 fix(studio,core): persist manual position edits for GSAP-owned elements (#1346)
* feat(sdk): scaffold @hyperframes/sdk — engine layer (model, RFC 6902 patches, mutate, apply-patches)

* fix(sdk): make engine-layer PR self-contained — trim index.ts, guard indexed access

- index.ts no longer exports document/session/history/persist-queue (those
  modules land in the next stacked PR); branch now typechecks standalone
- setOwnText: optional-chain children[i] access (TS2532 under
  noUncheckedIndexedAccess)
- fallow suppressions for buildPatchEvent + adapters/types.ts — consumers
  arrive in #1325

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): fail loudly on Phase 3b ops; add sdk to root build pipeline

- applyOp throws UnsupportedOpError (code E_UNSUPPORTED_OP) for the 9
  parser-backed ops instead of silently no-opping — callers must never
  believe an animation edit succeeded when nothing was mutated
- validateOp returns false for Phase 3b ops so can() feature-detects
- root package.json build filter now includes @hyperframes/sdk (package is
  dist-only; top-level build previously produced no SDK artifacts).
  publish.yml intentionally NOT updated — sdk stays unpublished until
  Phase 3 completes.

Adversarial-review findings F3 + F4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): cross-realm origin sentinel, dual width/height channel, contract docs

Round-2 review (Rames/Miguel) on the engine layer:

- ORIGIN_APPLY_PATCHES: unique symbol → namespaced string
  ('@hyperframes/sdk:applyPatches'). Symbols are realm-local — they don't
  survive postMessage/structured-clone, which T3 embedded hosts may forward
  patch events across. Namespaced string keeps collision risk negligible.
- setCompositionMetadata width/height: runtime treats data-width/data-height
  as a forced override of inline style (init.ts applyCompositionSizing).
  Style is always written; the data-* attr is updated when already present
  so the edit isn't clobbered on load. Absent attrs stay absent — inverses
  stay exact. Mirrored in the patch applier; 3 new tests.
- JsonPatchOp documented as the emit-only RFC 6902 subset
  (add/remove/replace); applier header notes move/copy/test are ignored.
- SdkDocument.html documented as a build-time snapshot (serialize() is the
  live state).
- patches.ts path-grammar comment fixed: timing/{start|end|trackIndex}.

NOT changed (with reasons, see PR reply): moveElement left/top matches
Studio's own inline-style commit convention (sourcePatcher); package version
follows the repo-wide single-version policy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): moveElement writes data-x/data-y, not left/top CSS

HF elements use data-x/data-y for positioning (read by htmlParser.ts,
emitted by hyperframes generator). CSS left/top is not the runtime convention.

Adds inverse round-trip test for prior position restore.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: update bun.lock after sdk package registration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(sdk): session API, optional history + persist-queue, adapters — Phase 3a complete

* fix(sdk): address review — live-DOM query cache, single parse, style parse dedup

- getElements/getElement/find now walk the live linkedom DOM via buildRoots
  with a lazily-built cache invalidated on dispatch/applyPatches — no
  serialize→ensureHfIds→parseHTML round trip per query
- openComposition parses once (parseMutable); dropped discarded _doc
  constructor param and the redundant buildDocument call
- document.ts buildElement reuses model.ts getElementStyles — removes
  duplicated parseInlineStyles (also fixes custom-prop camelCase mangling)
- JSDoc note: empty batch() still fires change handlers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): restore full public exports now session/document modules exist

index.ts re-exports document/session/history/persist-queue (trimmed in the
engine-layer PR to keep it self-contained); drops the temporary fallow
suppressions whose consumers now exist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): coalesce history by patch paths; replay override-set on open

Adversarial-review findings F1 + F2:

- history: coalescing now requires identical patch paths in addition to
  op types + origin + window. Previously two rapid setStyle calls on
  DIFFERENT elements merged into one entry carrying the second forward +
  first inverse — undo then reverted the wrong element and stranded the
  latest edit. Slider drags on one property still coalesce.
- T3 init: openComposition({ overrides }) now replays the stored
  override-set onto the freshly-parsed base before exposing the session
  (new keyToPath inverse mapping + applyOverrideSet). Previously the
  overrides were copied into the map but never applied — reopening an
  embedded composition showed and serialized the base template.
- examples: GSAP calls now feature-detect with can() (Phase 3b ops throw
  UnsupportedOpError as of the engine-layer fix); UnsupportedOpError
  re-exported from the package entry.
- 8 new session tests: coalesce same-path / cross-element / cross-prop,
  override round-trip (style/text/attr/timing/removal/restore-base).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): transactional batch rollback, sorted coalesce key, root-priority unify

Round-2 review (Rames/Miguel) on the session layer:

- batch() is now transactional: on throw, accumulated inverse patches are
  replayed in reverse and the override-set snapshot restored — the model is
  exactly as it was at batch entry. Previously a throwing batch left the DOM
  partially mutated with no patch trail, no history entry, no recovery path.
  2 new tests (model unchanged + undo is no-op after throwing batch).
- history coalesce key sorts opTypes — same op-type set coalesces regardless
  of dispatch order within a batch.
- applyPatches comment documents that emitted PatchEvents carry an empty
  inversePatches array (hosts keep their own inverse log).
- document.ts extractDimensions/extractDuration now use the engine's
  findRoot — dimension extraction and mutations agree on the root element
  ([data-hf-root] > #stage > first child). Dimensions prefer the runtime's
  data-width/data-height forced-override attrs, falling back to inline style.
- ownText documented: snapshot .text is trimmed display text; setText writes
  verbatim.

Deferred to follow-up (acknowledged, not ship-blocking): persist-queue flush
error surfacing, debounce window, path default, history ring-buffer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(lint): add gsap_studio_edit_blocked rule for manual timeline + GSAP element targeting

* fix(studio,core): persist manual position edits for GSAP-owned elements

- sourceMutation: linkedom CSSStyleDeclaration silently drops CSS custom
  properties and transform longhands via setProperty; patch the style
  attribute string directly so --hf-studio-offset-* and translate survive
  the server round-trip (positions never reached disk before this)
- gsapAnimatesTransform(): GSAP owns the full transform stack when it tweens
  ANY transform prop (scale, rotation, ...), not just x/y — it folds CSS
  translate into its cache once at init, zeroes the longhand once, and never
  re-reads it
- applyStudioPathOffset: for GSAP-owned elements keep translate:none live and
  sync the offset into GSAP's cache via gsap.set; writing the longhand
  double-applied the offset (disappearing elements, scrub snap-back)
- buildPathOffsetPatches: emit the var() translate expression explicitly so
  the persisted file re-folds on reload (live inline is none)
- StudioPathOffsetSnapshot: capture/restore GSAP x/y — the drag-response
  probe mutates GSAP's cache, which inline-style restore cannot undo (click
  made elements jump by the probe distance)
- reapplyPathOffsets: skip GSAP-owned elements (was x/y-only) to stop
  seek-time double-apply
- STUDIO_GSAP_DRAG_INTERCEPT flag (default off): keyframe drag intercept is
  opt-in until its recording path is hardened; commits take the CSS persist
  path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(studio): remove duplicate flag declaration, trim useDomEditCommits to 600 lines

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:30:14 -07:00
Vance IngallsandClaude Fable 5 511665b93a feat(lint): add gsap_studio_edit_blocked rule for manual timeline + GSAP element targeting (#1345)
* feat(sdk): scaffold @hyperframes/sdk — engine layer (model, RFC 6902 patches, mutate, apply-patches)

* fix(sdk): make engine-layer PR self-contained — trim index.ts, guard indexed access

- index.ts no longer exports document/session/history/persist-queue (those
  modules land in the next stacked PR); branch now typechecks standalone
- setOwnText: optional-chain children[i] access (TS2532 under
  noUncheckedIndexedAccess)
- fallow suppressions for buildPatchEvent + adapters/types.ts — consumers
  arrive in #1325

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): fail loudly on Phase 3b ops; add sdk to root build pipeline

- applyOp throws UnsupportedOpError (code E_UNSUPPORTED_OP) for the 9
  parser-backed ops instead of silently no-opping — callers must never
  believe an animation edit succeeded when nothing was mutated
- validateOp returns false for Phase 3b ops so can() feature-detects
- root package.json build filter now includes @hyperframes/sdk (package is
  dist-only; top-level build previously produced no SDK artifacts).
  publish.yml intentionally NOT updated — sdk stays unpublished until
  Phase 3 completes.

Adversarial-review findings F3 + F4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): cross-realm origin sentinel, dual width/height channel, contract docs

Round-2 review (Rames/Miguel) on the engine layer:

- ORIGIN_APPLY_PATCHES: unique symbol → namespaced string
  ('@hyperframes/sdk:applyPatches'). Symbols are realm-local — they don't
  survive postMessage/structured-clone, which T3 embedded hosts may forward
  patch events across. Namespaced string keeps collision risk negligible.
- setCompositionMetadata width/height: runtime treats data-width/data-height
  as a forced override of inline style (init.ts applyCompositionSizing).
  Style is always written; the data-* attr is updated when already present
  so the edit isn't clobbered on load. Absent attrs stay absent — inverses
  stay exact. Mirrored in the patch applier; 3 new tests.
- JsonPatchOp documented as the emit-only RFC 6902 subset
  (add/remove/replace); applier header notes move/copy/test are ignored.
- SdkDocument.html documented as a build-time snapshot (serialize() is the
  live state).
- patches.ts path-grammar comment fixed: timing/{start|end|trackIndex}.

NOT changed (with reasons, see PR reply): moveElement left/top matches
Studio's own inline-style commit convention (sourcePatcher); package version
follows the repo-wide single-version policy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): moveElement writes data-x/data-y, not left/top CSS

HF elements use data-x/data-y for positioning (read by htmlParser.ts,
emitted by hyperframes generator). CSS left/top is not the runtime convention.

Adds inverse round-trip test for prior position restore.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: update bun.lock after sdk package registration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(sdk): session API, optional history + persist-queue, adapters — Phase 3a complete

* fix(sdk): address review — live-DOM query cache, single parse, style parse dedup

- getElements/getElement/find now walk the live linkedom DOM via buildRoots
  with a lazily-built cache invalidated on dispatch/applyPatches — no
  serialize→ensureHfIds→parseHTML round trip per query
- openComposition parses once (parseMutable); dropped discarded _doc
  constructor param and the redundant buildDocument call
- document.ts buildElement reuses model.ts getElementStyles — removes
  duplicated parseInlineStyles (also fixes custom-prop camelCase mangling)
- JSDoc note: empty batch() still fires change handlers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): restore full public exports now session/document modules exist

index.ts re-exports document/session/history/persist-queue (trimmed in the
engine-layer PR to keep it self-contained); drops the temporary fallow
suppressions whose consumers now exist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): coalesce history by patch paths; replay override-set on open

Adversarial-review findings F1 + F2:

- history: coalescing now requires identical patch paths in addition to
  op types + origin + window. Previously two rapid setStyle calls on
  DIFFERENT elements merged into one entry carrying the second forward +
  first inverse — undo then reverted the wrong element and stranded the
  latest edit. Slider drags on one property still coalesce.
- T3 init: openComposition({ overrides }) now replays the stored
  override-set onto the freshly-parsed base before exposing the session
  (new keyToPath inverse mapping + applyOverrideSet). Previously the
  overrides were copied into the map but never applied — reopening an
  embedded composition showed and serialized the base template.
- examples: GSAP calls now feature-detect with can() (Phase 3b ops throw
  UnsupportedOpError as of the engine-layer fix); UnsupportedOpError
  re-exported from the package entry.
- 8 new session tests: coalesce same-path / cross-element / cross-prop,
  override round-trip (style/text/attr/timing/removal/restore-base).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): transactional batch rollback, sorted coalesce key, root-priority unify

Round-2 review (Rames/Miguel) on the session layer:

- batch() is now transactional: on throw, accumulated inverse patches are
  replayed in reverse and the override-set snapshot restored — the model is
  exactly as it was at batch entry. Previously a throwing batch left the DOM
  partially mutated with no patch trail, no history entry, no recovery path.
  2 new tests (model unchanged + undo is no-op after throwing batch).
- history coalesce key sorts opTypes — same op-type set coalesces regardless
  of dispatch order within a batch.
- applyPatches comment documents that emitted PatchEvents carry an empty
  inversePatches array (hosts keep their own inverse log).
- document.ts extractDimensions/extractDuration now use the engine's
  findRoot — dimension extraction and mutations agree on the root element
  ([data-hf-root] > #stage > first child). Dimensions prefer the runtime's
  data-width/data-height forced-override attrs, falling back to inline style.
- ownText documented: snapshot .text is trimmed display text; setText writes
  verbatim.

Deferred to follow-up (acknowledged, not ship-blocking): persist-queue flush
error surfacing, debounce window, path default, history ring-buffer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(lint): add gsap_studio_edit_blocked rule for manual timeline + GSAP element targeting

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:23:36 -07:00
Vance IngallsandClaude Fable 5 7010edac85 feat(sdk): session API, optional history + persist-queue, adapters — Phase 3a complete (#1325)
* feat(sdk): session API, optional history + persist-queue, adapters — Phase 3a complete

* fix(sdk): address review — live-DOM query cache, single parse, style parse dedup

- getElements/getElement/find now walk the live linkedom DOM via buildRoots
  with a lazily-built cache invalidated on dispatch/applyPatches — no
  serialize→ensureHfIds→parseHTML round trip per query
- openComposition parses once (parseMutable); dropped discarded _doc
  constructor param and the redundant buildDocument call
- document.ts buildElement reuses model.ts getElementStyles — removes
  duplicated parseInlineStyles (also fixes custom-prop camelCase mangling)
- JSDoc note: empty batch() still fires change handlers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): restore full public exports now session/document modules exist

index.ts re-exports document/session/history/persist-queue (trimmed in the
engine-layer PR to keep it self-contained); drops the temporary fallow
suppressions whose consumers now exist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): coalesce history by patch paths; replay override-set on open

Adversarial-review findings F1 + F2:

- history: coalescing now requires identical patch paths in addition to
  op types + origin + window. Previously two rapid setStyle calls on
  DIFFERENT elements merged into one entry carrying the second forward +
  first inverse — undo then reverted the wrong element and stranded the
  latest edit. Slider drags on one property still coalesce.
- T3 init: openComposition({ overrides }) now replays the stored
  override-set onto the freshly-parsed base before exposing the session
  (new keyToPath inverse mapping + applyOverrideSet). Previously the
  overrides were copied into the map but never applied — reopening an
  embedded composition showed and serialized the base template.
- examples: GSAP calls now feature-detect with can() (Phase 3b ops throw
  UnsupportedOpError as of the engine-layer fix); UnsupportedOpError
  re-exported from the package entry.
- 8 new session tests: coalesce same-path / cross-element / cross-prop,
  override round-trip (style/text/attr/timing/removal/restore-base).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): transactional batch rollback, sorted coalesce key, root-priority unify

Round-2 review (Rames/Miguel) on the session layer:

- batch() is now transactional: on throw, accumulated inverse patches are
  replayed in reverse and the override-set snapshot restored — the model is
  exactly as it was at batch entry. Previously a throwing batch left the DOM
  partially mutated with no patch trail, no history entry, no recovery path.
  2 new tests (model unchanged + undo is no-op after throwing batch).
- history coalesce key sorts opTypes — same op-type set coalesces regardless
  of dispatch order within a batch.
- applyPatches comment documents that emitted PatchEvents carry an empty
  inversePatches array (hosts keep their own inverse log).
- document.ts extractDimensions/extractDuration now use the engine's
  findRoot — dimension extraction and mutations agree on the root element
  ([data-hf-root] > #stage > first child). Dimensions prefer the runtime's
  data-width/data-height forced-override attrs, falling back to inline style.
- ownText documented: snapshot .text is trimmed display text; setText writes
  verbatim.

Deferred to follow-up (acknowledged, not ship-blocking): persist-queue flush
error surfacing, debounce window, path default, history ring-buffer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:23:09 -07:00
Vance IngallsandClaude Sonnet 4.6 22bb6737c5 feat(sdk): scaffold @hyperframes/sdk — engine layer (model, RFC 6902 patches, mutate, apply-patches) (#1324)
* feat(sdk): scaffold @hyperframes/sdk — engine layer (model, RFC 6902 patches, mutate, apply-patches)

* fix(sdk): make engine-layer PR self-contained — trim index.ts, guard indexed access

- index.ts no longer exports document/session/history/persist-queue (those
  modules land in the next stacked PR); branch now typechecks standalone
- setOwnText: optional-chain children[i] access (TS2532 under
  noUncheckedIndexedAccess)
- fallow suppressions for buildPatchEvent + adapters/types.ts — consumers
  arrive in #1325

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): fail loudly on Phase 3b ops; add sdk to root build pipeline

- applyOp throws UnsupportedOpError (code E_UNSUPPORTED_OP) for the 9
  parser-backed ops instead of silently no-opping — callers must never
  believe an animation edit succeeded when nothing was mutated
- validateOp returns false for Phase 3b ops so can() feature-detects
- root package.json build filter now includes @hyperframes/sdk (package is
  dist-only; top-level build previously produced no SDK artifacts).
  publish.yml intentionally NOT updated — sdk stays unpublished until
  Phase 3 completes.

Adversarial-review findings F3 + F4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): cross-realm origin sentinel, dual width/height channel, contract docs

Round-2 review (Rames/Miguel) on the engine layer:

- ORIGIN_APPLY_PATCHES: unique symbol → namespaced string
  ('@hyperframes/sdk:applyPatches'). Symbols are realm-local — they don't
  survive postMessage/structured-clone, which T3 embedded hosts may forward
  patch events across. Namespaced string keeps collision risk negligible.
- setCompositionMetadata width/height: runtime treats data-width/data-height
  as a forced override of inline style (init.ts applyCompositionSizing).
  Style is always written; the data-* attr is updated when already present
  so the edit isn't clobbered on load. Absent attrs stay absent — inverses
  stay exact. Mirrored in the patch applier; 3 new tests.
- JsonPatchOp documented as the emit-only RFC 6902 subset
  (add/remove/replace); applier header notes move/copy/test are ignored.
- SdkDocument.html documented as a build-time snapshot (serialize() is the
  live state).
- patches.ts path-grammar comment fixed: timing/{start|end|trackIndex}.

NOT changed (with reasons, see PR reply): moveElement left/top matches
Studio's own inline-style commit convention (sourcePatcher); package version
follows the repo-wide single-version policy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): moveElement writes data-x/data-y, not left/top CSS

HF elements use data-x/data-y for positioning (read by htmlParser.ts,
emitted by hyperframes generator). CSS left/top is not the runtime convention.

Adds inverse round-trip test for prior position restore.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: update bun.lock after sdk package registration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:19:51 -07:00
Vance Ingalls 2c64f99694 feat(core): expose hf-ids as subpath export for @hyperframes/sdk (#1323)
## Summary

Exposes `hf-ids` as a dedicated subpath export from `@hyperframes/core` so `@hyperframes/sdk` can import ID-stamping logic without pulling in the full core bundle.

- Adds `"exports"` entry for `./hf-ids` in `packages/core/package.json`
- No change to the existing top-level export — no breaking change for existing consumers

## Why

`@hyperframes/sdk` needs `parseMutable`/`stampHfIds` from core. A subpath export isolates that boundary and keeps the SDK bundle lean.

## Test plan
- [ ] `bun run build` — both packages build without errors
- [ ] `bun test packages/sdk` — import resolves correctly

🤖 Generated with [Claude Code](https://claude.ai/claude-code)
2026-06-11 11:58:43 -07:00
Vance IngallsandClaude Sonnet 4.6 0923bc0787 feat(studio): carry hfId on TimelineElement, wire through buildPatchTarget (R7, T5b) (#1299)
* feat(studio): carry hfId on TimelineElement, wire through buildPatchTarget (R7, T5b)

* refactor(studio): extract readHfId helper, fix empty-string normalization, add comments (R7 review)

- Extract readHfId(el) to domEditingLayers.ts — centralizes `?.trim() || undefined`
  normalisation; guards against empty-string data-hf-id reaching findTagByTarget
- Wire readHfId into domEditingLayers.ts and useDomEditCommits.ts (the one site
  that still used `?? undefined` instead of `|| undefined`)
- Re-export readHfId through domEditing.ts public API
- Add readHfId unit tests: present, absent, empty-string, whitespace-only
- Add comment on PatchTarget: runtime validation lives in findTagByTarget, type is docs-only
- Suppress pre-existing unused re-exports in timelineDOM.ts (backward-compat re-exports
  brought into fallow scope by the T5b hfId changes)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(core): clear data-hf-id on split clone to prevent dual-match (R7 review)

cloneNode(true) copies all attributes including data-hf-id. Without clearing it,
both halves of a split share the same hf-id; the server's findByHfId picks the first
match and silently patches the wrong clip. Remove the attribute from the clone so
write-back re-mints a fresh id on the next preview load.

Adds a test: splitElementInHtml — hfId clone isolation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(studio): add hfId to DomEditLayerItem + getDomLayerPatchTarget return type (R7 review)

- Add hfId to DomEditLayerItem interface (domEditingTypes.ts) so layer item
  construction in collectDomEditLayerItems compiles
- Widen getDomLayerPatchTarget return type to include hfId + populate it from
  data-hf-id attribute (domEditingElement.ts)
- Widen findDomEditSelectionTarget to check hfId-first when no id/selector
- Widen Pick types in domEditOverlayGeometry.ts and useGsapScriptCommits.ts
- Add hfId to buildMissingCompositionElements element construction
- Add hfId-targeted test coverage in domEditing.test.ts,
  domEditOverlayGeometry.test.ts, timelineIframeHelpers.test.ts
- Update hfIds.test.ts KNOWN LIMITATION labels — write-back landed in R7 T1-2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:15:27 -07:00
Vance Ingalls 82c754f27a feat(studio): wire hfId through DOM-edit patch targets, activate hfId lookup path (R7, T5a) (#1297) 2026-06-09 14:19:46 -07:00