feat(sdk): session API, optional history + persist-queue, adapters — Phase 3a complete#1325
Conversation
0d7ea0c to
f4199a8
Compare
4bd9dd9 to
049cff6
Compare
miguel-heygen
left a comment
There was a problem hiding this comment.
Session API is well-composed: the batch depth-tracking, coalesce window, promise-chain mutex in the persist queue, and ORIGIN_APPLY_PATCHES guard all work correctly. The adapter contract test suite is the right pattern — parameterized so future adapters get coverage for free.
P2 — getElements() re-parse is implemented here (same issue as #1324)
The concrete implementation is in session.ts:204:
getElements(): ElementSnapshot[] {
return flatElements(buildDocument(serializeDocument(this.parsed)).roots);
}serializeDocument → ensureHfIds → parseHTML → tree walk on every call. getElement() and find() both delegate here, so the headless agent calling:
const timedEls = comp.getElements().filter(...);
// + another filter in normalizeTiming…does two full parses. This is the hot path for agent workloads.
Fix: cache the built element tree, set a private dirty = false flag, flip it in dispatch() / batch() finally block / applyPatches(), rebuild lazily in getElements(). Or — better — walk the live linkedom DOM directly in buildElement instead of round-tripping through HTML.
P3 — _doc parameter in constructor confirms the double-parse waste
openComposition() calls buildDocument(html) and parseMutable(html) — two parses of the same string. The doc result is immediately discarded (_doc parameter). Even with the cache fix above, you still want to derive the initial element tree from parseMutable's already-parsed DOM rather than re-parsing a second time.
P3 — empty batch() fires changeHandlers
An empty fn() (no dispatches inside) results in:
// batchForward.length === 0, !threw → fires change
if (!threw) this.changeHandlers.forEach((h) => h());Consistent with the dispatch() no-op path, but this means comp.batch(() => {}) notifies all change subscribers. Likely fine for Phase 3b no-op ops, but worth a JSDoc note so consumers don't wrap speculative operations in a batch expecting silence on no-op.
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at the head of this PR (builds on #1324). Session API + history + persist-queue + adapters + examples. The session lifecycle and adapter contracts are well-thought-out; the persist queue and history modules are appropriately minimal. A couple of real semantic bugs worth fixing, plus interaction concerns with the embedded (T3) mode.
Strengths
- Three lifecycle modes (standalone T1/T2, embedded T3, headless) cleanly distinguished by
OpenCompositionOptionsshape.openComposition(session.ts:372-396) routes viaisEmbeddedflag derived from whetheroverridesis supplied. - Adapter contract test pattern (
persistAdapter.contract.test.ts) is the right shape — parameterized over implementations, runs against memory immediately, ready to extend to fs/S3/HTTP. "Write once, protect all" comment captures the intent perfectly. - Single-in-flight write mutex in persist-queue via Promise chaining (
persist-queue.ts:33,47) — correct pattern, avoids interleaved writes overwriting each other. coalesceMs-window history coalescing with sliding-timestamp semantics — sensible for the typing-into-text-field case where you want a single undo entry rather than per-character.- Three end-to-end examples (headless-agent, react-embed, vanilla-editor) — demonstrates all three use modes. Good documentation pattern.
Blockers
-
batch()does NOT revert mid-batch DOM mutations on error (session.ts:233-269). When the callback throws, the batch:- Discards accumulated patches (line 261-264). ✓
- But the DOM mutations from the dispatched ops already happened on
this.parsed.document. ✗ - The model is now in a partial state with no patch trail to undo it.
So a host that does:
comp.batch(() => { comp.setStyle('hf-a', { color: 'red' }); throw new Error('user cancelled'); });
ends up with
hf-a.style.color = 'red'permanently set in the model but no history entry, no patch emission, no way to revert. Worst-case-for-correctness.Fix options:
- (a) Snapshot the live document at batch entry; restore on throw.
- (b) Defer DOM mutations until batch commit — apply ops on batch exit, not during dispatches.
- (c) Auto-apply inverse patches accumulated during the batch on throw.
(c) is the smallest patch (pun unintended) — at line 240's catch, walk
this.batchInverse.reverse()and apply each to the document before re-throwing. Tests for "batch throw → model unchanged" would pin this. -
get/Element(id)andgetElements()rebuild the entire SdkDocument from a re-serialization on every call (session.ts:146-150):getElements(): ElementSnapshot[] { return flatElements(buildDocument(serializeDocument(this.parsed)).roots); }
This serializes the entire document to HTML, runs
ensureHfIdsagain, parses it back into linkedom, walks the whole tree. For an editor UI that callsgetElement(id)per-frame during drag preview (likely!), this is multi-millisecond on any non-trivial composition. Direct walk overthis.parsed.documentwould be ~100× faster.Fix: implement
getElement(id)viafindById(this.parsed.document, id)directly, then build a singleHyperFramesElementfrom the live DOM. Same forgetElements()— walkthis.parsed.documentdirectly. ThebuildElementfunction indocument.tsalready does this; reuse it.
Concerns
-
openCompositioncallsensureHfIdstwice (session.ts:376-377):const doc = buildDocument(html); // calls ensureHfIds internally const parsed = parseMutable(html); // calls ensureHfIds internally
Both calls produce identical output (content-keyed FNV-1a, no random — verified in
core/parsers/hfIds.ts), so this is correct but wasteful. For large templates, ensureHfIds + linkedom parse is the hottest part ofopenComposition. Refactor to do it once:const stamped = ensureHfIds(html); const parsed = parseMutable_stamped(stamped); // skip the internal ensureHfIds call const doc = buildDocument_from_parsed(parsed);
Or have
parseMutableaccept an optional{ skipStamp: true }flag. -
SdkDocumentis captured inCompositionImplconstructor but never used (session.ts:79)._doc: SdkDocumentis the first constructor arg, prefixed with_indicating "intentionally unused." If it's truly unused, drop it from the constructor signature and stop passing it fromopenComposition(line 378). If it's intended for future use, add a comment explaining what for. -
Persist queue: error swallowed silently in
doWrite(persist-queue.ts:51-53):try { await adapter.write(path, content); } catch { // error already surfaced via persist:error on the adapter }
The contract relies on
adapter.on('persist:error')being correctly wired by the adapter implementation. If an adapter forgets to emitpersist:erroron write failure (easy mistake), the SDK silently loses data with no signal to the host. Worth at least loggingconsole.warnin the catch as a debug aid, or asserting incontract.test.tsthat every adapter actually emits the error event (currently the contract test "skips" adapters without fault injection — seepersistAdapter.contract.test.ts:81-93). -
History coalescing key is brittle.
shouldCoalesce(history.ts:58-63) joinsopTypeswith commas. If a batch contains["setStyle", "setText"]and a subsequent single dispatch is["setText", "setStyle"](same set, different order), they DON'T coalesce because the joined strings differ. Sort the opTypes before join, or use a Set comparison. -
applyPatches()emits emptyinversePatches(session.ts:345):const event = buildPatchEvent(patches, [], origin, opTypes);
Coherent with the design (applyPatches doesn't enter history via the
isTrackedfilter), but if a host subscribes to'patch'and tries to maintain its own inverse stack from emissions, applyPatches events break that stream. Document this in theapplyPatchesJSDoc explicitly: "PatchEvent.inversePatches is always empty for applyPatches-origin events; hosts maintaining an external inverse log must compute inverses from their own state."
Nits
-
flush()in PersistQueueModule doesn't surface errors (persist-queue.ts:63-69).doWrite()swallows them, soawait pq.flush()resolves even if the write failed. For an app-close handler ("flush before quit"), the host has no signal that the data didn't make it. Consider makingflush()reject with the last error, or return a{ ok: boolean, error?: ... }shape. -
createPersistQueuesetTimeout(0)(persist-queue.ts:38) is a microtask-ish debounce. For high-frequency changes (drag preview at 60 FPS), this still queues ~16 writes per second since each change clears the prior timer but immediately schedules a new one. The intent seems to be "coalesce same-tick changes" but the actual behavior is "1-tick debounce." If the goal is "batch up writes during rapid edits," use a larger delay (50-200ms) with a max-wait ceiling. If the goal is genuinely "1-tick debounce," fine — comment explaining. -
dispose()doesn't await an in-flight write (persist-queue.ts:71-78). Ifpq.dispose()is called while a write is in flight, thedisposedflag is set and the in-flight write completes (it'll log nothing to listeners since they're unsubscribed). For cleanup correctness this is probably fine; for tests that want to verify "no further writes after dispose," considerawait writeChainin dispose. -
createPersistQueuepathconstructor option (persist-queue.ts:30) defaults to"composition.html". For an SDK that may be embedded in apps managing multiple compositions, a hardcoded default that overlaps with other instances is a footgun. Make it required, or surface a console.warn on default. -
createHistorymaxEntriesdefault 100 withundoStack.shift()(history.ts:89) —shift()is O(n). For 100 entries this doesn't matter; ifmaxEntriesis ever bumped to 10000, swap to a ring-buffer pattern. Pre-emptive nit; not a real concern at the current defaults. -
session.on('persist:error')flow (session.ts:302-310) routes through the adapter'son('persist:error'). Good. But if the persist module is detached (attachPersistQueuenever called) AND a host registerson('persist:error'), the registration silently does nothing (thethis.persist?.on(...)` chain is nullish). Worth a debug log or even a warn. -
adapters/headless.tsat 24 lines is presumably a no-op adapter for the headless mode. Worth a one-line comment in the file header noting "all methods are no-ops — for agent-only / non-persisting use cases."
What I verified
- Walked
session.ts,history.ts,persist-queue.ts,adapters/types.ts,adapters/persistAdapter.contract.test.tsend-to-end. - Traced the dispatch → mutate → patch → history → persist chain for a single setStyle op.
- Traced the
batch()error path against the catch/finally insession.ts:240-267and concluded blocker #1. - Traced
applyPatches()flow againstisTrackedinhistory.ts:52-56— coherent (applyPatches origin skipped from history). - Stamp consistency:
ensureHfIdsis deterministic (percore/parsers/hfIds.ts), so the double-call inopenCompositionis wasteful but not corrupting. - CI mid-flight at review time; nothing failing yet.
What I didn't verify
- The three example files (
vanilla-editor.ts,react-embed.ts,headless-agent.ts) — read them only for surface, didn't trace. - Notion PRD / Shape Review (auth-gated).
- React-embed's interop with React's lifecycle — would want to verify
dispose()is reachable from useEffect cleanup. - Stress-test of the persist queue under high-frequency changes — concern #5 / nit #4 surface here.
Two real bugs (batch revert + getElement perf), several contract/doc gaps. The architectural shape is right — the SDK lifecycle is coherent, the three modes are well-distinguished, the adapter contract is honest. Worth a round-2 to address the blockers; the nits and concerns are accumulating-paint-points more than ship-stoppers.
— Review by Rames D Jusso
|
All three addressed in cd85c20:
36/36 tests pass, lint/format clean. |
cd85c20 to
1f27880
Compare
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Re-reviewed at 1f278807 (delta from f4199a8e — 4 files, +42/-38, plus rebases). Round-2 closes blocker #2 (live-DOM query), concern #3 (single-parse), nit #4 (unused _doc), Magi's P3 (style-parse dedup), and Magi's nit on batch() empty-fn behavior. One blocker still open (batch() doesn't revert DOM on throw).
Cleared
✅ Live-DOM query + invalidation cache (blocker #2). The new buildRoots(document) in document.ts:131-141 walks the live linkedom DOM directly — no serialize → ensureHfIds → re-parse round trip. CompositionImpl.getElements() (session.ts:147-150) caches the flattened snapshot in elementsCache: ElementSnapshot[] | null and invalidates on every dispatch (line 215) and applyPatches (line 346). UI doing per-frame getElement(id) during drag is now ~O(tree-walk) on first call after mutation, ~O(id-lookup) within the same mutation window. Significant perf improvement. Defensive copy via [...this.elementsCache] on read prevents external mutation of the cached array. Clean.
✅ Single parse in openComposition (concern #3). parseMutable(html) runs once; the SdkDocument-shaped derivation moved into the lazy query path. Saves a full ensureHfIds + linkedom parse on every session open. Comment at session.ts:389-390 documents the choice. ✓
✅ _doc: SdkDocument constructor parameter dropped (nit #4). CompositionImpl(parsed, opts) is the signature now — cleaner. ✓
✅ Magi P3 — style-parse dedup. document.ts no longer has its own parseInlineStyles; it imports getElementStyles from engine/model.ts. Single canonical kebab → camelCase implementation. Removes the drift risk between the two copies. ✓
✅ JSDoc note on batch() empty-fn behavior. Documents that "a batch that produces no effective mutations still fires 'change' handlers" — closes Magi's nit on speculative-op subscribers. ✓
Still open from round-1
batch()doesn't revert DOM mutations on throw (round-1 blocker #1). The catch path atsession.ts:240-244discardsbatchForward/batchInversebut the underlyingapplyOpcalls have already mutatedthis.parsed.document. With the newelementsCacheinvalidation also firing insidedispatch(line 215), a subsequentgetElements()correctly reflects the partial state — confirming the model and patch stream still diverge. Fix is still: walkthis.batchInverse.reverse()and apply each viaapplyPatchesToDocument(this.parsed, ...)in the catch block before re-throwing. Round-trip test:comp.batch(() => { comp.setStyle('hf-a', { color: 'red' }); throw new Error(); }); expect(comp.getElement('hf-a').inlineStyles.color).toBeUndefined();.
Open lower-priority items
- Persist queue
flush()swallows errors (round-1 nit). App-close handlers get no signal that the final write failed. createPersistQueuesetTimeout(0)debounce (round-1 nit). 1-tick coalesce vs intended longer debounce.createPersistQueuepathdefault"composition.html"(round-1 nit). Multi-composition apps will collide.- History coalesce key uses unsorted
opTypes.join(",")(round-1 concern #6). Same-set-different-order doesn't coalesce.
These are accumulation paint-points, not ship-stoppers — fine to land in a follow-up PR.
What I verified
- Round-2 diff vs round-1:
document.ts(-21/+19),session.ts(-3/+15),index.ts(-1/+1),engine/model.ts(-1/+1). getElements()new path:buildRoots(this.parsed.document)→flatElements(...)→ cached, defensive copy on read. No serialize/re-parse anywhere in the hot path.dispatchinvalidateselementsCacheBEFORE the override-set update (line 215 sits above line 217-225). Order is fine since neither path reads the cache.applyPatchesinvalidates cache too (line 346). ✓openCompositionno longer callsbuildDocument(html)— onlyparseMutable. ✓ TheSdkDocument-shaped result is no longer materialized at open-time (only on demand viabuildRoots).batch()control flow unchanged from round-1 — the JSDoc note doesn't alter behavior, just documents existing semantics. Concern #1 reproduces.
What I didn't verify
- Empirical perf: didn't measure
getElements()before/after for a real-size composition. - Whether the cache-invalidation-on-dispatch interacts cleanly with the
batch()partial-revert fix when blocker #1 lands. - Did not re-walk the three example files — assume unchanged unless flagged.
Big improvement on round-1 blocker #2 — the live-DOM walk is the right architecture and the cache is correctly invalidated. The batch() revert is still the one thing worth fixing before this lands on consumers; the rest are polish items.
— Review by Rames D Jusso
miguel-heygen
left a comment
There was a problem hiding this comment.
Building on Rames's review. The fix commit on this PR is housekeeping only (restore full public exports now session/document modules exist). None of the blockers are addressed yet.
✅ Fixed since last review
index.tsexport restoration — correct stack coordination, no semantic change
Still open — my findings
- P2 (
getElements()re-parse hot path):session.ts:204serializes + re-parses on every call. Resolves once the live DOM walk fix lands in #1324 and is threaded through here. - P3 (double parse in
openComposition): still firesbuildDocument(html)+parseMutable(html)on the same string;_docstill discarded. - P3 (empty
batch()fires changeHandlers): worth a JSDoc note even if behavior is intentional.
Amplifying from Rames
Blocker 1 — batch() doesn't revert DOM mutations on throw is the one I'd call the most dangerous issue in this PR. The current error path:
- DOM mutations already applied to
this.parsed.document✓ (they happen insidedispatch, which runs synchronously inside the callback) - Accumulated patches discarded ✓
- No model restoration ✗
So comp.batch(() => { comp.setStyle(...); throw new Error(); }) permanently corrupts the in-memory model with no history entry, no patch emission, and no recovery path. This is nearly impossible to debug downstream because the symptom appears long after the throw site. Option (c) from Rames — apply accumulated inverse patches in the catch block before re-throwing — is the smallest correct fix and can be tested with a straightforward batch throw → getElements() state unchanged assertion.
Blocker 2 (getElements re-parse) is also confirmed — it's the same root issue as #1324 P2, just exposed here as a hot path. Rames's suggestion to call findById(this.parsed.document, id) directly for getElement() is right; buildElement already takes a live Element node, so this is a shallow refactor once the cache is in place.
f396300 to
4e3ece3
Compare
|
Round-3 pushed ( Blocker 1 — batch rollback: fixed ( Concern 6 — coalesce key: fixed. opTypes sorted before join; also (from the adversarial review) coalescing now additionally requires identical patch paths — previously two rapid Concern 7 — applyPatches inversePatches: documented — comment now states emitted events carry an empty Also fixed since your review (adversarial pass findings):
Deferred to follow-up (agree not ship-stoppers, tracked in the last commit message): persist-queue SDK suite: 51 tests green; tsc/lint/format/fallow clean. |
8c210c3 to
b6cdfff
Compare
4e3ece3 to
44522e9
Compare
|
Thanks Vance.. this is very close to what QA needs for the headless daily regression. The memory adapter + injectFault, the pure openComposition(html) seam, and the contract suite seem right. From PR audit~~
Fix: Add the following to src/index.ts: 2. Expose an awaitable
|
44522e9 to
dbc7da2
Compare
miguel-heygen
left a comment
There was a problem hiding this comment.
Re-reviewing at dbc7da2 (fix commit from Vance). Building on Rames's rounds 1 & 2.
✅ Fixed since last review
- Blocker:
batch()transactional rollback — the fix is correct.batchOverridesSnapshotsnapped at outermost entry, inverse patches replayed in reverse in the catch block,elementsCacheinvalidated,resetBatchState()extracted cleanly. Two new tests pin the invariant (model unchanged,undo is no-op after throwing batch). Blocker closed. ✅ - History coalesce key sort (
opTypesKeywith.sort()) — closes concern #6 from round-1. Same-set, different-order batches now coalesce correctly. ✅ flush()onCompositioninterface +CompositionImpl—flush()delegates topersistQueueModule?.flush(), no-ops cleanly when persist isn't attached. JSDoc on the interface is precise. ✅- Public adapter type exports (
PersistAdapter,PreviewAdapter,PersistVersionEntry) — callers can now write typed fakes without reaching into internals. ✅ coalesceMs <= 0disables coalescing — deterministic test scenario, correct short-circuit at the top ofshouldCoalesce. ✅applyPatchesJSDoc documenting emptyinversePatchesfor applyPatches-origin events — closes concern #7 from round-1. ✅
✅ Already cleared in round-2 (confirming still in tree)
- Live-DOM walk +
elementsCache(round-1 blocker #2) —buildRoots(this.parsed.document)with invalidation on dispatch/batch/applyPatches. ✅ - Single parse in
openComposition—parseMutable(html)only. ✅ _docconstructor param dropped. ✅
Remaining open items from round-1/2 (acknowledged as follow-up, not ship-blocking)
persist-queue.tsflush()swallows errors —doWrite()catch only callsopts.onError;flush()resolves even on write failure. App-close handlers get no signal data didn't land. TheonErrorcallback added in this commit (opts.onError?.({ error: ... })) is wired on the #1350 branch but not here — this PR'spersist-queue.tsstill has the silent catch. Worth pulling that forward, but Rames flagged as nit and it was deferred explicitly in the commit message.setTimeout(0)1-tick debounce — same as before.pathdefault"composition.html"— multi-composition collision risk.
These match what the commit message explicitly deferred. Fine to land.
One new observation
resetBatchState() zeroes batchOverridesSnapshot = {}. This is correct — snapshot is consumed. But since it's a private field on CompositionImpl, the zero-value is harmless if the finally block runs before any outer batch re-enters. The nested-batch path (batchDepth > 1) only snapshots at depth === 1 and never writes to batchOverridesSnapshot again until the next outermost entry. Correct.
✅ Approving. All blockers resolved.
8a83024 to
cd8d35f
Compare
dbc7da2 to
6f3c02d
Compare
…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>
The base branch was changed.
…Phase 3a complete
…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>
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>
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>
…ority 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>
6f3c02d to
a2cd542
Compare
…SAP 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>
…ts (#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>
…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>

Summary
Completes Phase 3a of the SDK — wires the engine layer into a full
Compositionsession with history, persist queue, and event bus.session.ts—CompositionImplwith typed mutation methods,batch()with transactional rollback, override-set accumulation,can()validation,serialize(),applyPatches(), andon("change" | "patch" | "persist:error")event handlers.openComposition()factory wires history + persist-queue for standalone (T1/T2) mode; T3 embedded callers supplyoverrides.history.ts— undo/redo ring with configurable coalesce window; patch-path-keyed coalescing so rapid edits to the same property collapse into one undo entrypersist-queue.ts— debounced async write queue with promise-chain mutex (one write in flight at a time, latest-wins coalescing)document.ts—buildRoots/flatElementsfor query APIadapters/—PersistAdapterandPreviewAdapterinterfaces; Vite filesystem adapterKey design decisions
Symbol-like string constant (ORIGIN_LOCAL,ORIGIN_APPLY_PATCHES) avoidsinstanceof Symbolfailures across iframessetStyle({color})+setStyle({color})coalesces butsetStyle({color})+setStyle({fontSize})does notTest plan
bun test packages/sdk— all 51 session + history tests pass🤖 Generated with Claude Code