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>
This commit is contained in:
Vance Ingalls
2026-06-18 23:05:41 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent f65e229663
commit 0ca01c88cf
4 changed files with 391 additions and 1 deletions
+249
View File
@@ -356,6 +356,255 @@ describe("removeElement", () => {
});
});
// ─── addElement ───────────────────────────────────────────────────────────────
describe("addElement", () => {
it("inserts element at specified parent+index and resolves via getElement-style lookup", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "addElement",
parent: "hf-stage",
index: 0,
html: '<p class="new">inserted</p>',
});
expect(result.meta?.newId).toBeTruthy();
const newId = result.meta!.newId!;
const el = parsed.document.querySelector(`[data-hf-id="${newId}"]`);
expect(el).not.toBeNull();
expect(el?.tagName.toLowerCase()).toBe("p");
// Inserted at index 0 → first child of hf-stage
const stage = parsed.document.querySelector('[data-hf-id="hf-stage"]');
expect(stage?.firstElementChild?.getAttribute("data-hf-id")).toBe(newId);
});
it("insert at index >= childCount appends to parent", () => {
const parsed = fresh();
const stage = parsed.document.querySelector('[data-hf-id="hf-stage"]');
const countBefore = stage ? Array.from(stage.children).length : 0;
const result = applyOp(parsed, {
type: "addElement",
parent: "hf-stage",
index: 9999,
html: '<span class="tail">tail</span>',
});
const newId = result.meta!.newId!;
const stageAfter = parsed.document.querySelector('[data-hf-id="hf-stage"]');
expect(stageAfter?.lastElementChild?.getAttribute("data-hf-id")).toBe(newId);
expect(Array.from(stageAfter?.children ?? []).length).toBe(countBefore + 1);
});
it("minted id is unique vs all existing doc ids", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "addElement",
parent: "hf-stage",
index: 0,
html: '<div class="unique-new">content</div>',
});
const newId = result.meta!.newId!;
// Must not collide with any pre-existing id
const existingIds = ["hf-stage", "hf-title", "hf-logo", "hf-sub", "hf-span"];
expect(existingIds).not.toContain(newId);
// Must appear exactly once in the document
const all = Array.from(parsed.document.querySelectorAll(`[data-hf-id="${newId}"]`));
expect(all).toHaveLength(1);
});
it("content-collision with existing element yields a distinct rehashed id", () => {
// Insert a fragment with identical content to an existing element → dup-rehash must yield a distinct id
const parsed = fresh();
// hf-logo is <img data-hf-id="hf-logo" src="/logo.png" alt="Logo" />
// Insert the same HTML without the data-hf-id so mintHfId runs fresh
const result = applyOp(parsed, {
type: "addElement",
parent: "hf-stage",
index: 0,
html: '<img src="/logo.png" alt="Logo" />',
});
const newId = result.meta!.newId!;
expect(newId).not.toBe("hf-logo");
expect(newId.startsWith("hf-")).toBe(true);
const el = parsed.document.querySelector(`[data-hf-id="${newId}"]`);
expect(el).not.toBeNull();
});
it("nested fragment: all new nodes get unique ids; root id returned", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "addElement",
parent: "hf-stage",
index: 0,
html: '<div class="outer"><span class="inner-a">a</span><span class="inner-b">b</span></div>',
});
const rootId = result.meta!.newId!;
const root = parsed.document.querySelector(`[data-hf-id="${rootId}"]`);
expect(root).not.toBeNull();
// All children must have data-hf-id
const children = root ? Array.from(root.querySelectorAll("*")) : [];
for (const child of children) {
expect(child.getAttribute("data-hf-id")).toBeTruthy();
}
// All ids must be distinct
const allIds = [rootId, ...children.map((c) => c.getAttribute("data-hf-id") as string)];
expect(new Set(allIds).size).toBe(allIds.length);
});
it("forward patch is patchAdd; inverse patch is patchRemove — symmetry with removeElement", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "addElement",
parent: "hf-sub",
index: 0,
html: '<em class="em">em text</em>',
});
expect(result.forward).toHaveLength(1);
expect(result.inverse).toHaveLength(1);
expect(result.forward[0]?.op).toBe("add");
expect(result.inverse[0]?.op).toBe("remove");
const newId = result.meta!.newId!;
expect(result.forward[0]?.path).toBe(`/elements/${newId}`);
expect(result.inverse[0]?.path).toBe(`/elements/${newId}`);
});
it("applying inverse patch removes the added element (undo)", () => {
const parsed = fresh();
const { inverse, meta } = applyOp(parsed, {
type: "addElement",
parent: "hf-stage",
index: 0,
html: '<div class="to-undo">undo me</div>',
});
const newId = meta!.newId!;
expect(parsed.document.querySelector(`[data-hf-id="${newId}"]`)).not.toBeNull();
applyPatchesToDocument(parsed, inverse);
expect(parsed.document.querySelector(`[data-hf-id="${newId}"]`)).toBeNull();
});
it("add → undo → redo: element returns with the same id (id stability)", () => {
const parsed = fresh();
// add
const { forward, inverse, meta } = applyOp(parsed, {
type: "addElement",
parent: "hf-stage",
index: 1,
html: '<section class="redo-test">redo</section>',
});
const newId = meta!.newId!;
// undo
applyPatchesToDocument(parsed, inverse);
expect(parsed.document.querySelector(`[data-hf-id="${newId}"]`)).toBeNull();
// redo (replay forward patches)
applyPatchesToDocument(parsed, forward);
const restored = parsed.document.querySelector(`[data-hf-id="${newId}"]`);
expect(restored).not.toBeNull();
expect(restored?.getAttribute("data-hf-id")).toBe(newId);
});
it("parent: null inserts at document body root level", () => {
// Use a simple fragment doc
const parsed = parseMutable(
'<div data-hf-id="hf-root" data-hf-root style="width:100px;height:100px"></div>',
);
const result = applyOp(parsed, {
type: "addElement",
parent: null,
index: 1,
html: '<aside class="body-child">aside</aside>',
});
const newId = result.meta!.newId!;
const el = parsed.document.querySelector(`[data-hf-id="${newId}"]`);
expect(el).not.toBeNull();
expect(el?.parentElement?.tagName.toLowerCase()).toBe("body");
});
it("serialize round-trip: addElement survives serialize()", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "addElement",
parent: "hf-sub",
index: 0,
html: '<b class="bold">bold</b>',
});
const newId = result.meta!.newId!;
const serialized = serializeDocument(parsed);
expect(serialized).toContain(`data-hf-id="${newId}"`);
expect(serialized).toContain("bold");
});
// ─── validateOp ─────────────────────────────────────────────────────────────
it("validateOp: missing parent → E_TARGET_NOT_FOUND", () => {
const parsed = fresh();
const r = validateOp(parsed, {
type: "addElement",
parent: "hf-nonexistent",
index: 0,
html: "<div>x</div>",
});
expect(r.ok).toBe(false);
if (!r.ok) expect(r.code).toBe("E_TARGET_NOT_FOUND");
});
it("validateOp: negative index → E_INVALID_ARGS", () => {
const parsed = fresh();
const r = validateOp(parsed, {
type: "addElement",
parent: "hf-stage",
index: -1,
html: "<div>x</div>",
});
expect(r.ok).toBe(false);
if (!r.ok) expect(r.code).toBe("E_INVALID_ARGS");
});
it("validateOp: empty html → E_INVALID_HTML", () => {
const parsed = fresh();
const r = validateOp(parsed, {
type: "addElement",
parent: "hf-stage",
index: 0,
html: "",
});
expect(r.ok).toBe(false);
if (!r.ok) expect(r.code).toBe("E_INVALID_HTML");
});
it("validateOp: html with only text / zero element nodes → E_INVALID_HTML", () => {
const parsed = fresh();
const r = validateOp(parsed, {
type: "addElement",
parent: "hf-stage",
index: 0,
html: "just text no element",
});
expect(r.ok).toBe(false);
if (!r.ok) expect(r.code).toBe("E_INVALID_HTML");
});
it("validateOp: html containing <script> → E_INVALID_HTML", () => {
const parsed = fresh();
const r = validateOp(parsed, {
type: "addElement",
parent: "hf-stage",
index: 0,
html: "<div><script>alert(1)</scr" + "ipt></div>",
});
expect(r.ok).toBe(false);
if (!r.ok) expect(r.code).toBe("E_INVALID_HTML");
});
it("validateOp: parent: null is valid", () => {
const parsed = fresh();
const r = validateOp(parsed, {
type: "addElement",
parent: null,
index: 0,
html: "<div>body-level</div>",
});
expect(r.ok).toBe(true);
});
});
// ─── setElementStyles (model helper) ──────────────────────────────────────────
describe("setElementStyles key normalization", () => {
+121 -1
View File
@@ -50,6 +50,7 @@ import {
patchRemove,
} from "./patches.js";
import { upsertCssRule } from "./cssWriter.js";
import { mintHfId } from "@hyperframes/core/hf-ids";
import { parseGsapScriptAcornForWrite } from "@hyperframes/core/gsap-parser-acorn";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import {
@@ -77,7 +78,7 @@ import { deriveKeyframeBackfillDefaults } from "./keyframeBackfill.js";
export interface MutationResult {
forward: JsonPatchOp[];
inverse: JsonPatchOp[];
meta?: { animationId?: string };
meta?: { animationId?: string; newId?: string };
}
const EMPTY: MutationResult = { forward: [], inverse: [] };
@@ -270,6 +271,8 @@ export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult {
return handleMoveElement(parsed, targets(op.target), op.x, op.y);
case "removeElement":
return handleRemoveElement(parsed, targets(op.target));
case "addElement":
return handleAddElement(parsed, op.parent, op.index, op.html);
case "reorderElements":
return handleReorderElements(parsed, op.entries);
case "setCompositionMetadata":
@@ -619,6 +622,99 @@ function handleRemoveElement(parsed: ParsedDocument, ids: HfId[]): MutationResul
return result;
}
// ─── addElement handler ───────────────────────────────────────────────────────
// Tags that must never receive a stable hf-id — mirrors hfIds.ts EXCLUDED_TAGS.
const HF_EXCLUDED_TAGS = new Set([
"script",
"style",
"template",
"meta",
"link",
"noscript",
"base",
]);
/**
* Resolve all existing hf-ids in the document into `assigned` so that
* mintHfId cannot issue an id that already exists in the composition.
*/
function collectDocumentHfIds(document: Document): Set<string> {
const assigned = new Set<string>();
for (const el of Array.from(document.querySelectorAll("[data-hf-id]"))) {
const id = el.getAttribute("data-hf-id");
if (id) assigned.add(id);
}
return assigned;
}
/**
* Stamp data-hf-id onto every un-stamped element in `root` and its
* descendants, minting ids against `assigned` (the live document's id set).
* Returns the minted id of `root` (or its existing id if already stamped).
*/
function mintFragmentIds(root: Element, assigned: Set<string>): string {
if (!root.getAttribute("data-hf-id") && !HF_EXCLUDED_TAGS.has(root.tagName.toLowerCase())) {
root.setAttribute("data-hf-id", mintHfId(root, assigned));
}
for (const el of Array.from(root.querySelectorAll("*"))) {
if (HF_EXCLUDED_TAGS.has(el.tagName.toLowerCase())) continue;
if (el.getAttribute("data-hf-id")) continue; // pinned
el.setAttribute("data-hf-id", mintHfId(el, assigned));
}
return root.getAttribute("data-hf-id") ?? "";
}
/**
* Insert an HTML fragment (single-root) as a child of `parent` at `index`.
* Mints ids against the LIVE document's existing id set so new ids can never
* collide with elements already in the composition. Returns the minted root id
* via result.meta.newId — mirrors the `animationId` pattern in addGsapTween.
*
* Inverse = patchRemove of the new element's path; mirrors handleRemoveElement's
* inverse = patchAdd. Forward/inverse are thus symmetric with that handler.
*/
function handleAddElement(
parsed: ParsedDocument,
parent: HfId | null,
index: number,
html: string,
): MutationResult {
// Resolve parent element (null → document body).
const parentEl =
parent === null
? ((parsed.document as unknown as { body: Element }).body as unknown as Element)
: (resolveScoped(parsed.document, parent) as Element);
// Parse the fragment within the target document to avoid cross-document issues
// (same approach as apply-patches.ts:222). validateOp guarantees a non-null firstElementChild.
const tmp = parsed.document.createElement("div");
tmp.innerHTML = html;
const node = tmp.firstElementChild;
if (!node) return EMPTY;
// Mint ids against the LIVE doc's existing id set (the #1 landmine — a fresh
// ensureHfIds(fragment) is blind to existing doc ids and can collide).
// Order: mint → capture outerHTML → insert → build patch (id needed for path).
const assigned = collectDocumentHfIds(parsed.document);
const newId = mintFragmentIds(node, assigned);
const stampedHtml = node.outerHTML;
// Insert at `index` — append if index >= childCount (RFC-6902 insert semantics).
const ref = Array.from(parentEl.children)[index] ?? null;
parentEl.insertBefore(node, ref);
// parentId for the inverse patch: bare id of the parent, or null for body root.
const parentId = parent !== null ? (parentEl.getAttribute("data-hf-id") ?? null) : null;
const path = elementPath(newId);
return {
forward: [patchAdd(path, { html: stampedHtml, parentId, siblingIndex: index })],
inverse: [patchRemove(path)],
meta: { newId },
};
}
function handleReorderElements(
parsed: ParsedDocument,
entries: Array<{ target: HfId; zIndex: number }>,
@@ -1310,6 +1406,30 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
);
return CAN_OK;
}
case "addElement": {
if (op.parent !== null && resolveScoped(parsed.document, op.parent) === null)
return canErr(
"E_TARGET_NOT_FOUND",
`Parent element not found: "${op.parent}".`,
"Verify the parent id against comp.getElements() or comp.find().",
);
if (op.index < 0) return canErr("E_INVALID_ARGS", `index must be >= 0 (got ${op.index}).`);
if (!op.html || op.html.trim().length === 0)
return canErr("E_INVALID_HTML", "html must not be empty.");
// Parse to check for <script> and zero-element fragments.
// Use the same temp-div pattern as apply-patches.ts for consistency.
const tmp = parsed.document.createElement("div");
tmp.innerHTML = op.html;
if (tmp.firstElementChild === null)
return canErr("E_INVALID_HTML", "html parses to zero element nodes.");
if (tmp.querySelector("script") !== null)
return canErr(
"E_INVALID_HTML",
"<script> elements are not permitted in addElement html.",
"GSAP is managed by the composition's single script block; add tweens via addGsapTween.",
);
return CAN_OK;
}
case "reorderElements": {
if (op.entries.length === 0) return CAN_OK;
const missing = op.entries
+5
View File
@@ -139,6 +139,11 @@ class CompositionImpl implements Composition {
this.dispatch({ type: "removeElement", target: id });
}
addElement(parent: HfId | null, index: number, html: string): HfId {
const result = this._dispatch({ type: "addElement", parent, index, html }, ORIGIN_LOCAL);
return result.meta?.newId ?? "";
}
setVariableValue(id: string, value: string | number | boolean | FontValue | ImageValue): void {
this.dispatch({ type: "setVariableValue", id, value });
}
+16
View File
@@ -88,6 +88,15 @@ export type EditOp =
| { type: "setHold"; target: HfId | HfId[]; hold: ElasticHold }
| { type: "moveElement"; target: HfId | HfId[]; x: number; y: number }
| { type: "removeElement"; target: HfId | HfId[] }
| {
type: "addElement";
/** Id of the parent element, or null to insert at the document body root. */
parent: HfId | null;
/** Zero-based sibling index at which to insert (append if >= childCount). */
index: number;
/** Single-root HTML fragment. Must not contain <script>. */
html: string;
}
| {
type: "reorderElements";
/** Each entry sets inline zIndex on one element. Positioning is unchanged — z-index only takes effect on non-static elements, so the caller must ensure the target is positioned. */
@@ -340,6 +349,13 @@ export interface Composition {
setAttribute(id: HfId, name: string, value: string | null): void;
setTiming(id: HfId, timing: { start?: number; duration?: number; trackIndex?: number }): void;
removeElement(id: HfId): void;
/**
* Insert an HTML fragment as a child of `parent` at `index` (WS-D).
* Mints a stable hf-id against the live document's existing id set.
* Returns the minted id of the inserted root element.
* Inverse = removeElement of the returned id.
*/
addElement(parent: HfId | null, index: number, html: string): HfId;
setVariableValue(id: string, value: string | number | boolean): void;
/**
* Read enter/exit times and GSAP labels for every timed element (WS-C).