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>
This commit is contained in:
Vance Ingalls
2026-06-11 12:23:09 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 22bb6737c5
commit 7010edac85
17 changed files with 1875 additions and 7 deletions
+140
View File
@@ -0,0 +1,140 @@
/**
* Session-level behavior: history coalescing invariants and T3 override replay.
*/
import { describe, it, expect } from "vitest";
import { openComposition } from "./session.js";
const BASE_HTML = `
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px" data-duration="5">
<h1 data-hf-id="hf-title" data-start="0" data-end="3" style="color: #fff; font-size: 64px">Hello World</h1>
<p data-hf-id="hf-sub" style="opacity: 0.5">subtitle</p>
<img data-hf-id="hf-logo" src="/logo.png" alt="Logo" />
</div>
`.trim();
// ─── History coalescing ───────────────────────────────────────────────────────
describe("history coalescing", () => {
it("rapid edits to the SAME property coalesce into one undo entry", async () => {
const comp = await openComposition(BASE_HTML);
comp.setStyle("hf-title", { color: "#111" });
comp.setStyle("hf-title", { color: "#222" });
comp.setStyle("hf-title", { color: "#333" });
comp.undo();
const el = comp.getElement("hf-title");
expect(el?.inlineStyles["color"]).toBe("#fff"); // back to original in ONE step
});
it("rapid edits to DIFFERENT elements do NOT coalesce — undo reverts only the last edit", async () => {
const comp = await openComposition(BASE_HTML);
comp.setStyle("hf-title", { color: "#111" });
comp.setStyle("hf-sub", { opacity: "1" });
comp.undo();
expect(comp.getElement("hf-sub")?.inlineStyles["opacity"]).toBe("0.5"); // last edit reverted
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#111"); // first edit intact
comp.undo();
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#fff");
});
it("rapid edits to different properties of the same element do not coalesce", async () => {
const comp = await openComposition(BASE_HTML);
comp.setStyle("hf-title", { color: "#111" });
comp.setStyle("hf-title", { fontSize: "96px" });
comp.undo();
expect(comp.getElement("hf-title")?.inlineStyles["fontSize"]).toBe("64px");
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#111");
});
});
// ─── T3 override replay ───────────────────────────────────────────────────────
describe("override-set replay on open", () => {
it("applies style, text, and attribute overrides to the base document", async () => {
const comp = await openComposition(BASE_HTML, {
overrides: {
"hf-title.style.color": "#e63946",
"hf-title.text": "Edited headline",
"hf-logo.attr.src": "/new-logo.png",
},
});
const title = comp.getElement("hf-title");
expect(title?.inlineStyles["color"]).toBe("#e63946");
expect(title?.text).toBe("Edited headline");
expect(comp.getElement("hf-logo")?.attributes["src"]).toBe("/new-logo.png");
const html = comp.serialize();
expect(html).toContain("Edited headline");
expect(html).toContain("/new-logo.png");
expect(html).toContain("#e63946");
});
it("applies timing overrides (computed absolute end)", async () => {
const comp = await openComposition(BASE_HTML, {
overrides: { "hf-title.timing.end": 4.5 },
});
expect(comp.serialize()).toContain('data-end="4.5"');
});
it("removes elements marked with the null removal marker", async () => {
const comp = await openComposition(BASE_HTML, {
overrides: { "hf-sub": null },
});
expect(comp.getElement("hf-sub")).toBeNull();
expect(comp.serialize()).not.toContain("subtitle");
});
it("treats property-level null as restore-base (no-op on fresh base)", async () => {
const comp = await openComposition(BASE_HTML, {
overrides: { "hf-title.style.color": null },
});
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#fff");
});
it("getOverrides returns the set the session was opened with", async () => {
const overrides = { "hf-title.style.color": "#e63946" };
const comp = await openComposition(BASE_HTML, { overrides });
expect(comp.getOverrides()).toEqual(overrides);
});
});
// ─── batch() transactional rollback ───────────────────────────────────────────
describe("batch rollback on throw", () => {
it("reverts DOM mutations and override-set when the callback throws", async () => {
const comp = await openComposition(BASE_HTML);
const htmlBefore = comp.serialize();
expect(() =>
comp.batch(() => {
comp.setStyle("hf-title", { color: "#e63946" });
comp.setText("hf-sub", "changed");
throw new Error("user cancelled");
}),
).toThrowError("user cancelled");
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#fff");
expect(comp.getElement("hf-sub")?.text).toBe("subtitle");
expect(comp.serialize()).toBe(htmlBefore);
expect(comp.getOverrides()).toEqual({});
});
it("a throwing batch leaves no history entry — undo is a no-op", async () => {
const comp = await openComposition(BASE_HTML);
try {
comp.batch(() => {
comp.setStyle("hf-title", { color: "#e63946" });
throw new Error("boom");
});
} catch {
// expected
}
comp.undo();
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#fff");
});
});