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
+188
View File
@@ -0,0 +1,188 @@
/**
* SDK document model — adaptation layer on top of @hyperframes/core.
*
* F6 decision: SDK builds ON core, no parser duplication.
* - ensureHfIds (from core) is the parse entry point: all construction starts here.
* - DOMParser is NOT used (browser-only). linkedom is the node-safe primitive.
* - ParsedHtml (core) is the Studio timeline view (timed elements only).
* HyperFramesElement is the editing view (ALL editable elements, with raw attrs).
*/
import { parseHTML } from "linkedom";
import { ensureHfIds } from "@hyperframes/core/hf-ids";
import { findRoot, getElementStyles } from "./engine/model.js";
import type { HyperFramesElement, SdkDocument } from "./types.js";
// Tags that carry no editable content and must not enter the element tree.
const EXCLUDED_TAGS = new Set([
"script",
"style",
"template",
"meta",
"link",
"noscript",
"base",
"head",
]);
// Snapshot text is TRIMMED for display (markup indentation produces noisy
// whitespace text nodes). setText writes verbatim — engine getOwnText/setOwnText
// operate on raw text. el.text is a display value, not a round-trip identity.
function ownText(el: Element): string | null {
let text = "";
el.childNodes.forEach((n) => {
if (n.nodeType === 3) text += (n as Text).nodeValue ?? "";
});
const trimmed = text.trim();
return trimmed.length > 0 ? trimmed : null;
}
// fallow-ignore-next-line complexity
function buildElement(el: Element): HyperFramesElement | null {
const tag = el.tagName.toLowerCase();
if (EXCLUDED_TAGS.has(tag)) return null;
const id = el.getAttribute("data-hf-id") ?? "";
if (!id) return null; // should never happen after ensureHfIds, but guard defensively
const inlineStyles = getElementStyles(el);
const classAttr = el.getAttribute("class") ?? "";
const classNames = classAttr
.split(/\s+/)
.map((c) => c.trim())
.filter(Boolean);
const attributes: Record<string, string> = {};
for (const attr of Array.from(el.attributes)) {
if (attr.name === "style" || attr.name === "class" || attr.name.startsWith("data-hf-")) {
continue;
}
attributes[attr.name] = attr.value;
}
const startAttr = el.getAttribute("data-start");
const endAttr = el.getAttribute("data-end");
const trackAttr = el.getAttribute("data-track-index");
const start = startAttr !== null ? parseFloat(startAttr) : null;
const duration =
start !== null && endAttr !== null ? Math.max(0, parseFloat(endAttr) - start) : null;
const trackIndex = trackAttr !== null ? parseInt(trackAttr, 10) : null;
const children: HyperFramesElement[] = [];
for (const child of Array.from(el.children)) {
const built = buildElement(child);
if (built) children.push(built);
}
return {
id,
tag,
children,
inlineStyles,
classNames,
attributes,
text: ownText(el),
start,
duration,
trackIndex,
animationIds: [],
};
}
// fallow-ignore-next-line complexity
function extractGsapScript(doc: Document): string | null {
// GSAP script is the first <script> tag whose text references gsap
for (const script of Array.from(doc.querySelectorAll("script"))) {
const text = script.textContent ?? "";
if (text.includes("gsap") || text.includes("ScrollTrigger")) {
return text;
}
}
return null;
}
function extractStyles(doc: Document): string | null {
const styleEl = doc.querySelector("style");
return styleEl ? styleEl.textContent : null;
}
// Root resolution delegates to the engine's findRoot so dimension extraction
// and mutations agree on which element is the composition root.
// fallow-ignore-next-line complexity
function extractDimensions(doc: Document): { width: number | null; height: number | null } {
const stage = findRoot(doc);
if (!stage) return { width: null, height: null };
// data-width/data-height are the runtime's forced override — prefer them.
const wAttr = stage.getAttribute("data-width");
const hAttr = stage.getAttribute("data-height");
const style = (stage as HTMLElement).getAttribute?.("style") ?? "";
const wm = /width:\s*(\d+)px/.exec(style);
const hm = /height:\s*(\d+)px/.exec(style);
return {
width: wAttr !== null ? parseInt(wAttr, 10) : wm ? parseInt(wm[1] ?? "", 10) : null,
height: hAttr !== null ? parseInt(hAttr, 10) : hm ? parseInt(hm[1] ?? "", 10) : null,
};
}
function extractDuration(doc: Document): number | null {
const root = findRoot(doc) ?? doc.body;
const dur = root?.getAttribute("data-duration");
return dur ? parseFloat(dur) : null;
}
/**
* Build the element tree from an already-parsed (hf-id-stamped) linkedom Document.
* Walks the live DOM directly — no serialize/re-parse round trip. This is what
* the session's query API uses against its mutable document.
*/
export function buildRoots(document: Document): HyperFramesElement[] {
const body = document.body;
const roots: HyperFramesElement[] = [];
if (body) {
for (const child of Array.from(body.children)) {
const built = buildElement(child);
if (built) roots.push(built);
}
}
return roots;
}
/**
* Parse an HTML string into the SDK document model.
* Calls ensureHfIds first so every element has a stable data-hf-id.
* Uses linkedom — node-safe (works in agents, CI, server-side).
*/
export function buildDocument(html: string): SdkDocument {
const stamped = ensureHfIds(html);
const hasShell = /<!doctype|<html[\s>]/i.test(stamped);
const wrapped = !hasShell;
const { document } = wrapped
? parseHTML(`<!DOCTYPE html><html><head></head><body>${stamped}</body></html>`)
: parseHTML(stamped);
const dims = extractDimensions(document);
return {
roots: buildRoots(document),
gsapScript: extractGsapScript(document),
styles: extractStyles(document),
width: dims.width,
height: dims.height,
compositionDuration: extractDuration(document),
html: stamped,
};
}
/** Flat walk of the element tree — returns every element in document order */
export function flatElements(roots: readonly HyperFramesElement[]): HyperFramesElement[] {
const result: HyperFramesElement[] = [];
function walk(el: HyperFramesElement) {
result.push(el);
for (const child of el.children) walk(child);
}
for (const root of roots) walk(root);
return result;
}