From f3059948f4faa1b75b3de7ab92415c42a4268653 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 10 Jun 2026 13:52:43 -0700 Subject: [PATCH 1/6] =?UTF-8?q?feat(sdk):=20scaffold=20@hyperframes/sdk=20?= =?UTF-8?q?=E2=80=94=20engine=20layer=20(model,=20RFC=206902=20patches,=20?= =?UTF-8?q?mutate,=20apply-patches)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .fallowrc.jsonc | 1 + bun.lock | 40 ++- packages/sdk/package.json | 73 +++++ packages/sdk/src/adapters/types.ts | 73 +++++ packages/sdk/src/engine/apply-patches.ts | 180 +++++++++++ packages/sdk/src/engine/model.ts | 141 +++++++++ packages/sdk/src/engine/mutate.test.ts | 363 +++++++++++++++++++++++ packages/sdk/src/engine/mutate.ts | 356 ++++++++++++++++++++++ packages/sdk/src/engine/patches.ts | 151 ++++++++++ packages/sdk/src/engine/serialize.ts | 22 ++ packages/sdk/src/index.ts | 30 ++ packages/sdk/src/types.ts | 243 +++++++++++++++ packages/sdk/tsconfig.check.json | 9 + packages/sdk/tsconfig.json | 18 ++ packages/sdk/vitest.config.ts | 8 + 15 files changed, 1693 insertions(+), 15 deletions(-) create mode 100644 packages/sdk/package.json create mode 100644 packages/sdk/src/adapters/types.ts create mode 100644 packages/sdk/src/engine/apply-patches.ts create mode 100644 packages/sdk/src/engine/model.ts create mode 100644 packages/sdk/src/engine/mutate.test.ts create mode 100644 packages/sdk/src/engine/mutate.ts create mode 100644 packages/sdk/src/engine/patches.ts create mode 100644 packages/sdk/src/engine/serialize.ts create mode 100644 packages/sdk/src/index.ts create mode 100644 packages/sdk/src/types.ts create mode 100644 packages/sdk/tsconfig.check.json create mode 100644 packages/sdk/tsconfig.json create mode 100644 packages/sdk/vitest.config.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 5b413ae95..64fcc1831 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -47,6 +47,7 @@ "packages/**/__goldens__/**", "registry/**", "examples/**", + "packages/sdk/examples/**", ".github/workflows/fixtures/**", // Auto-generated TS client for the HeyGen cloud API. Regenerated by // experiment-framework/scripts/generate_hyperframes_cli_client.py via diff --git a/bun.lock b/bun.lock index 8fdaacc86..b8502622b 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/aws-lambda": { "name": "@hyperframes/aws-lambda", - "version": "0.6.88", + "version": "0.6.86", "dependencies": { "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/client-sfn": "^3.700.0", @@ -54,7 +54,7 @@ }, "packages/cli": { "name": "@hyperframes/cli", - "version": "0.6.88", + "version": "0.6.86", "bin": { "hyperframes": "./dist/cli.js", }, @@ -101,12 +101,11 @@ }, "packages/core": { "name": "@hyperframes/core", - "version": "0.6.88", + "version": "0.6.86", "dependencies": { "@babel/parser": "^7.27.0", "@chenglou/pretext": "^0.0.5", "postcss": "^8.5.8", - "postcss-selector-parser": "^7.1.2", "recast": "^0.23.11", }, "devDependencies": { @@ -131,7 +130,7 @@ }, "packages/engine": { "name": "@hyperframes/engine", - "version": "0.6.88", + "version": "0.6.86", "dependencies": { "@hono/node-server": "^1.13.0", "@hyperframes/core": "workspace:^", @@ -149,7 +148,7 @@ }, "packages/gcp-cloud-run": { "name": "@hyperframes/gcp-cloud-run", - "version": "0.6.88", + "version": "0.6.86", "dependencies": { "@google-cloud/storage": "^7.14.0", "@google-cloud/workflows": "^4.2.0", @@ -169,7 +168,7 @@ }, "packages/player": { "name": "@hyperframes/player", - "version": "0.6.88", + "version": "0.6.86", "devDependencies": { "@types/bun": "^1.1.0", "gsap": "^3.12.5", @@ -181,7 +180,7 @@ }, "packages/producer": { "name": "@hyperframes/producer", - "version": "0.6.88", + "version": "0.6.86", "dependencies": { "@fontsource/archivo-black": "^5.2.8", "@fontsource/eb-garamond": "^5.2.7", @@ -220,9 +219,22 @@ "typescript": "^5.7.2", }, }, + "packages/sdk": { + "name": "@hyperframes/sdk", + "version": "0.6.86", + "dependencies": { + "@hyperframes/core": "workspace:*", + "linkedom": "^0.18.12", + }, + "devDependencies": { + "@types/node": "^25.0.10", + "typescript": "^5.0.0", + "vitest": "^3.2.4", + }, + }, "packages/shader-transitions": { "name": "@hyperframes/shader-transitions", - "version": "0.6.88", + "version": "0.6.86", "dependencies": { "html2canvas": "^1.4.1", }, @@ -234,7 +246,7 @@ }, "packages/studio": { "name": "@hyperframes/studio", - "version": "0.6.88", + "version": "0.6.86", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", @@ -631,6 +643,8 @@ "@hyperframes/producer": ["@hyperframes/producer@workspace:packages/producer"], + "@hyperframes/sdk": ["@hyperframes/sdk@workspace:packages/sdk"], + "@hyperframes/shader-transitions": ["@hyperframes/shader-transitions@workspace:packages/shader-transitions"], "@hyperframes/studio": ["@hyperframes/studio@workspace:packages/studio"], @@ -1739,7 +1753,7 @@ "postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="], - "postcss-selector-parser": ["postcss-selector-parser@7.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Wjvt4scRFouioIInHf51IFNP4ltJ2EngJM+cZPGiqbKetBfmP3vpdPV8ID2S6JS6/jdo74N8+aEYH9lQr2C6sA=="], + "postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], @@ -2133,8 +2147,6 @@ "path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - "postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], - "proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], @@ -2151,8 +2163,6 @@ "tailwindcss/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], - "tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], - "tar/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], "teeny-request/http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="], diff --git a/packages/sdk/package.json b/packages/sdk/package.json new file mode 100644 index 000000000..4d235b549 --- /dev/null +++ b/packages/sdk/package.json @@ -0,0 +1,73 @@ +{ + "name": "@hyperframes/sdk", + "version": "0.6.86", + "description": "Headless, framework-neutral HyperFrames composition editing engine", + "repository": { + "type": "git", + "url": "https://github.com/heygen-com/hyperframes", + "directory": "packages/sdk" + }, + "files": [ + "dist", + "README.md" + ], + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "import": "./src/index.ts", + "types": "./src/index.ts" + }, + "./adapters/memory": { + "import": "./src/adapters/memory.ts", + "types": "./src/adapters/memory.ts" + }, + "./adapters/fs": { + "import": "./src/adapters/fs.ts", + "types": "./src/adapters/fs.ts" + }, + "./adapters/headless": { + "import": "./src/adapters/headless.ts", + "types": "./src/adapters/headless.ts" + } + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./adapters/memory": { + "import": "./dist/adapters/memory.js", + "types": "./dist/adapters/memory.d.ts" + }, + "./adapters/fs": { + "import": "./dist/adapters/fs.js", + "types": "./dist/adapters/fs.d.ts" + }, + "./adapters/headless": { + "import": "./dist/adapters/headless.js", + "types": "./dist/adapters/headless.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "scripts": { + "build": "tsc", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "typecheck:examples": "tsc --noEmit -p tsconfig.check.json" + }, + "dependencies": { + "@hyperframes/core": "workspace:*", + "linkedom": "^0.18.12" + }, + "devDependencies": { + "@types/node": "^25.0.10", + "typescript": "^5.0.0", + "vitest": "^3.2.4" + } +} diff --git a/packages/sdk/src/adapters/types.ts b/packages/sdk/src/adapters/types.ts new file mode 100644 index 000000000..7ddb1410b --- /dev/null +++ b/packages/sdk/src/adapters/types.ts @@ -0,0 +1,73 @@ +import type { PersistErrorEvent } from "../types.js"; + +// ─── PersistAdapter ─────────────────────────────────────────────────────────── + +export interface PersistVersionEntry { + /** Opaque key identifying this version (adapter-defined format) */ + key: string; + content: string; + timestamp?: number; +} + +/** + * Injectable storage adapter — decouples the SDK from the underlying persistence mechanism. + * Implementations: memory (tests/demos), fs (local dev), S3 (cloud), HTTP (Pacific). + * + * Contract: + * - read() returns undefined for a path never written + * - write() is idempotent (second write overwrites) + * - flush() resolves when any queued writes are committed + * - listVersions() returns entries newest-first + * - loadFrom() returns content for the given version key (undefined if not found) + * - on('persist:error') fires when a write fails; the error must not propagate as a thrown exception + */ +export interface PersistAdapter { + read(path: string): Promise; + write(path: string, content: string): Promise; + /** Force all pending writes to commit before returning */ + flush(): Promise; + listVersions(path: string): Promise; + loadFrom(path: string, versionKey: string): Promise; + on(event: "persist:error", handler: (event: PersistErrorEvent) => void): () => void; +} + +// ─── PreviewAdapter ─────────────────────────────────────────────────────────── + +export interface ElementAtPointResult { + id: string; + tag: string; +} + +export interface DraftProps { + dx?: number; + dy?: number; + width?: number; + height?: number; +} + +/** + * Injectable preview adapter — decouples the SDK from the host preview surface. + * The null/headless adapter stubs all methods (no browser needed). + * + * The SDK is NOT in the 60fps draft loop — consumers call applyDraft() directly on + * the preview at 60fps; commitPreview() fires once on pointer-up to derive and + * dispatch the resulting op. + */ +export interface PreviewAdapter { + /** Sync hit-test at composition coordinates. Requires same-origin iframe. */ + elementAtPoint(x: number, y: number, opts?: { atTime?: number }): ElementAtPointResult | null; + + /** Apply draft CSS markers to the preview element (60fps, SDK not involved) */ + applyDraft(id: string, props: DraftProps): void; + + /** Derive op from draft markers, dispatch it, emit patch event, clear markers */ + commitPreview(): void; + + /** Revert draft markers without committing. Model never changed. */ + cancelPreview(): void; + + /** Set preview selection; fires selectionchange on the session */ + select(ids: string[], opts?: { additive?: boolean }): void; + + on(event: "selection", handler: (ids: string[]) => void): () => void; +} diff --git a/packages/sdk/src/engine/apply-patches.ts b/packages/sdk/src/engine/apply-patches.ts new file mode 100644 index 000000000..aeae34aaa --- /dev/null +++ b/packages/sdk/src/engine/apply-patches.ts @@ -0,0 +1,180 @@ +/** + * Bounded RFC 6902 patch applier — handles only the path patterns emitted by mutate.ts. + * + * Not a general-purpose JSON Patch implementation. Translates the well-defined path + * grammar back into DOM mutations. Used by applyPatches() for host undo (T3 mode). + */ + +import type { JsonPatchOp } from "../types.js"; +import type { ParsedDocument } from "./model.js"; +import { findById, findRoot, setElementStyles, setOwnText } from "./model.js"; + +// ─── Path parser ──────────────────────────────────────────────────────────── + +interface ParsedPath { + type: "style" | "text" | "attribute" | "timing" | "hold" | "element" | "variable" | "metadata"; + id?: string; + prop?: string; + field?: string; +} + +function parsePath(path: string): ParsedPath | null { + const styleM = /^\/elements\/([^/]+)\/inlineStyles\/(.+)$/.exec(path); + if (styleM) return { type: "style", id: styleM[1], prop: styleM[2] }; + + const textM = /^\/elements\/([^/]+)\/text$/.exec(path); + if (textM) return { type: "text", id: textM[1] }; + + const attrM = /^\/elements\/([^/]+)\/attributes\/(.+)$/.exec(path); + if (attrM) + return { + type: "attribute", + id: attrM[1], + prop: attrM[2]?.replace(/~1/g, "/").replace(/~0/g, "~"), + }; + + const timingM = /^\/elements\/([^/]+)\/timing\/(.+)$/.exec(path); + if (timingM) return { type: "timing", id: timingM[1], field: timingM[2] }; + + const holdM = /^\/elements\/([^/]+)\/hold\/(.+)$/.exec(path); + if (holdM) return { type: "hold", id: holdM[1], field: holdM[2] }; + + const elemM = /^\/elements\/([^/]+)$/.exec(path); + if (elemM) return { type: "element", id: elemM[1] }; + + const varM = /^\/variables\/(.+)$/.exec(path); + if (varM) return { type: "variable", id: varM[1] }; + + const metaM = /^\/metadata\/(.+)$/.exec(path); + if (metaM) return { type: "metadata", field: metaM[1] }; + + return null; +} + +// ─── Patch application ─────────────────────────────────────────────────────── + +export function applyPatchesToDocument( + parsed: ParsedDocument, + patches: readonly JsonPatchOp[], +): void { + for (const patch of patches) { + const p = parsePath(patch.path); + if (!p) continue; + applyOne(parsed, patch, p); + } +} + +// fallow-ignore-next-line complexity +function applyOne(parsed: ParsedDocument, patch: JsonPatchOp, p: ParsedPath): void { + switch (p.type) { + case "style": { + const el = p.id ? findById(parsed.document, p.id) : null; + if (!el || !p.prop) return; + if (patch.op === "remove") { + setElementStyles(el, { [p.prop]: null }); + } else { + setElementStyles(el, { [p.prop]: String(patch.value) }); + } + break; + } + + case "text": { + const el = p.id ? findById(parsed.document, p.id) : null; + if (!el) return; + if (patch.op === "remove") { + setOwnText(el, ""); + } else { + setOwnText(el, String(patch.value ?? "")); + } + break; + } + + case "attribute": { + const el = p.id ? findById(parsed.document, p.id) : null; + if (!el || !p.prop) return; + if (patch.op === "remove") { + el.removeAttribute(p.prop); + } else { + el.setAttribute(p.prop, String(patch.value ?? "")); + } + break; + } + + case "timing": { + const el = p.id ? findById(parsed.document, p.id) : null; + if (!el || !p.field) return; + if (p.field === "start") { + if (patch.op === "remove") el.removeAttribute("data-start"); + else el.setAttribute("data-start", String(patch.value)); + } else if (p.field === "end") { + // Patch value is the absolute data-end time — set directly, no re-derivation. + if (patch.op === "remove") el.removeAttribute("data-end"); + else el.setAttribute("data-end", String(patch.value)); + } else if (p.field === "trackIndex") { + if (patch.op === "remove") el.removeAttribute("data-track-index"); + else el.setAttribute("data-track-index", String(patch.value)); + } + break; + } + + case "hold": { + const el = p.id ? findById(parsed.document, p.id) : null; + if (!el || !p.field) return; + const attrName = `data-hold-${p.field}`; + if (patch.op === "remove") el.removeAttribute(attrName); + else el.setAttribute(attrName, String(patch.value)); + break; + } + + case "element": { + if (!p.id) return; + if (patch.op === "remove") { + const el = findById(parsed.document, p.id); + el?.remove(); + } else if (patch.op === "add" && patch.value) { + const v = patch.value as { html: string; parentId: string | null; siblingIndex: number }; + const parent = v.parentId + ? findById(parsed.document, v.parentId) + : ((parsed.document as unknown as { body: Element }).body as unknown as Element); + if (!parent) return; + // Parse within the target document to avoid cross-document node issues. + const tmp = parsed.document.createElement("div"); + tmp.innerHTML = v.html; + const node = tmp.firstElementChild; + if (!node) return; + const children = Array.from(parent.children); + const ref = children[v.siblingIndex] ?? null; + parent.insertBefore(node, ref); + } + break; + } + + case "variable": { + const root = findRoot(parsed.document); + if (!root || !p.id) return; + const cssVar = `--${p.id}`; + if (patch.op === "remove") { + setElementStyles(root, { [cssVar]: null }); + } else { + setElementStyles(root, { [cssVar]: String(patch.value) }); + } + break; + } + + case "metadata": { + const root = findRoot(parsed.document); + if (!root || !p.field) return; + if (p.field === "width") { + if (patch.op === "remove") setElementStyles(root, { width: null }); + else setElementStyles(root, { width: `${patch.value}px` }); + } else if (p.field === "height") { + if (patch.op === "remove") setElementStyles(root, { height: null }); + else setElementStyles(root, { height: `${patch.value}px` }); + } else if (p.field === "duration") { + if (patch.op === "remove") root.removeAttribute("data-duration"); + else root.setAttribute("data-duration", String(patch.value)); + } + break; + } + } +} diff --git a/packages/sdk/src/engine/model.ts b/packages/sdk/src/engine/model.ts new file mode 100644 index 000000000..e170363bc --- /dev/null +++ b/packages/sdk/src/engine/model.ts @@ -0,0 +1,141 @@ +/** + * Mutable document — linkedom Document wrapper for Phase 3 editing. + * + * The linkedom Document IS the mutable backing store. All dispatch mutations + * go here. serialize() walks the live DOM; no separate mutable tree to sync. + */ + +import { parseHTML } from "linkedom"; +import { ensureHfIds } from "@hyperframes/core/hf-ids"; + +export interface ParsedDocument { + document: Document; + /** True when the input was a fragment (no shell) and was wrapped. */ + wrapped: boolean; + /** ensureHfIds-stamped original HTML — used as fallback / diff base. */ + stamped: string; +} + +export function parseMutable(html: string): ParsedDocument { + const stamped = ensureHfIds(html); + const hasShell = /]/i.test(stamped); + const wrapped = !hasShell; + const { document } = wrapped + ? parseHTML(`${stamped}`) + : parseHTML(stamped); + return { document: document as unknown as Document, wrapped, stamped }; +} + +// ─── Element lookup ─────────────────────────────────────────────────────────── + +export function findById(document: Document, id: string): Element | null { + // CSS.escape is browser-only; hf-ids are restricted identifiers so simple quote-escaping is safe. + const escaped = id.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + return document.querySelector(`[data-hf-id="${escaped}"]`); +} + +export function findRoot(document: Document): Element | null { + return ( + document.querySelector("[data-hf-root]") ?? + document.getElementById("stage") ?? + document.body?.firstElementChild ?? + null + ); +} + +// ─── Inline style helpers ───────────────────────────────────────────────────── + +function toCamel(prop: string): string { + if (prop.startsWith("--")) return prop; + return prop.replace(/-([a-z])/g, (_, c: string) => (c as string).toUpperCase()); +} + +function toKebab(prop: string): string { + if (prop.startsWith("--")) return prop; + return prop.replace(/([A-Z])/g, (c) => `-${c.toLowerCase()}`); +} + +/** Parse style attribute string → camelCase map (custom props kept as-is). */ +function parseStyleAttr(styleAttr: string): Record { + const result: Record = {}; + for (const decl of styleAttr.split(";")) { + const idx = decl.indexOf(":"); + if (idx === -1) continue; + const rawProp = decl.slice(0, idx).trim(); + const value = decl.slice(idx + 1).trim(); + if (!rawProp || !value) continue; + result[toCamel(rawProp)] = value; + } + return result; +} + +/** Serialize camelCase style map → style attribute string. */ +function serializeStyleAttr(styles: Record): string { + return Object.entries(styles) + .map(([k, v]) => `${toKebab(k)}: ${v}`) + .join("; "); +} + +export function getElementStyles(el: Element): Record { + const attr = el.getAttribute("style") ?? ""; + return parseStyleAttr(attr); +} + +export function setElementStyles(el: Element, updates: Record): void { + const current = getElementStyles(el); + for (const [prop, value] of Object.entries(updates)) { + if (value === null) { + delete current[prop]; + } else { + current[prop] = value; + } + } + const serialized = serializeStyleAttr(current); + if (serialized) { + el.setAttribute("style", serialized); + } else { + el.removeAttribute("style"); + } +} + +// ─── Text helpers ───────────────────────────────────────────────────────────── + +/** Read only direct (non-descendant) text node content. */ +export function getOwnText(el: Element): string { + let text = ""; + el.childNodes.forEach((n) => { + if (n.nodeType === 3) text += (n as Text).nodeValue ?? ""; + }); + return text; +} + +/** Replace only direct text nodes — preserves child elements. */ +export function setOwnText(el: Element, text: string): void { + const doc = el.ownerDocument; + const children = Array.from(el.childNodes); + // Track original position of the first text node so we restore there, not at firstChild. + let firstTextIdx = -1; + for (let i = 0; i < children.length; i++) { + if (children[i].nodeType === 3) { + firstTextIdx = i; + break; + } + } + for (const child of children) { + if (child.nodeType === 3) el.removeChild(child); + } + if (text) { + // No text nodes before firstTextIdx (it's the first one), so index is stable. + const current = Array.from(el.childNodes); + const ref = firstTextIdx >= 0 ? (current[firstTextIdx] ?? null) : null; + el.insertBefore(doc.createTextNode(text), ref); + } +} + +// ─── Sibling index ──────────────────────────────────────────────────────────── + +export function getSiblingIndex(el: Element): number { + const parent = el.parentElement; + if (!parent) return 0; + return Array.from(parent.children).indexOf(el); +} diff --git a/packages/sdk/src/engine/mutate.test.ts b/packages/sdk/src/engine/mutate.test.ts new file mode 100644 index 000000000..065b6978a --- /dev/null +++ b/packages/sdk/src/engine/mutate.test.ts @@ -0,0 +1,363 @@ +/** + * T4 — Op contract tests for the Phase 3a dispatch boundary. + * + * Tests verify: correct DOM mutation, correct RFC 6902 forward patches, + * correct inverse patches (applying them restores the original state), + * and override-set key mapping. + */ + +import { describe, it, expect } from "vitest"; +import { parseMutable } from "./model.js"; +import { applyOp, validateOp } from "./mutate.js"; +import { applyPatchesToDocument } from "./apply-patches.js"; +import { pathToKey } from "./patches.js"; +import { serializeDocument } from "./serialize.js"; + +// ─── Fixtures ──────────────────────────────────────────────────────────────── + +// No trailing semicolons in style attrs — serializeStyleAttr never adds them. +const BASE_HTML = ` +
+

Hello World

+ Logo +
+ sub text +
+
+`.trim(); + +function fresh() { + return parseMutable(BASE_HTML); +} + +// ─── setStyle ──────────────────────────────────────────────────────────────── + +describe("setStyle", () => { + it("mutates existing style prop and emits replace patches", () => { + const parsed = fresh(); + const result = applyOp(parsed, { + type: "setStyle", + target: "hf-title", + styles: { fontSize: "96px" }, + }); + expect(result.forward).toHaveLength(1); + expect(result.forward[0]).toEqual({ + op: "replace", + path: "/elements/hf-title/inlineStyles/fontSize", + value: "96px", + }); + expect(result.inverse[0]).toEqual({ + op: "replace", + path: "/elements/hf-title/inlineStyles/fontSize", + value: "64px", + }); + // DOM mutated + const el = parsed.document.querySelector('[data-hf-id="hf-title"]'); + expect(el?.getAttribute("style")).toContain("font-size: 96px"); + }); + + it("adds new style prop and emits add patch", () => { + const parsed = fresh(); + const result = applyOp(parsed, { + type: "setStyle", + target: "hf-logo", + styles: { opacity: "0.8" }, + }); + expect(result.forward[0]?.op).toBe("add"); + expect(result.inverse[0]?.op).toBe("remove"); + }); + + it("removes style prop when value is null", () => { + const parsed = fresh(); + const result = applyOp(parsed, { + type: "setStyle", + target: "hf-title", + styles: { color: null }, + }); + expect(result.forward[0]?.op).toBe("remove"); + expect(result.inverse[0]?.op).toBe("add"); + expect(result.inverse[0]?.value).toBe("#fff"); + }); + + it("inverse patches restore original state", () => { + const parsed = fresh(); + const before = serializeDocument(parsed); + const { inverse } = applyOp(parsed, { + type: "setStyle", + target: "hf-title", + styles: { fontSize: "96px", color: "#f00" }, + }); + applyPatchesToDocument(parsed, inverse); + expect(serializeDocument(parsed)).toBe(before); + }); + + it("applies to multiple targets", () => { + const parsed = fresh(); + const result = applyOp(parsed, { + type: "setStyle", + target: ["hf-title", "hf-span"], + styles: { opacity: "1" }, + }); + expect(result.forward).toHaveLength(2); + }); + + it("override-set key maps correctly", () => { + const key = pathToKey("/elements/hf-title/inlineStyles/fontSize"); + expect(key).toBe("hf-title.style.fontSize"); + }); +}); + +// ─── setText ───────────────────────────────────────────────────────────────── + +describe("setText", () => { + it("updates text content and emits replace patch", () => { + const parsed = fresh(); + const result = applyOp(parsed, { + type: "setText", + target: "hf-title", + value: "Goodbye World", + }); + expect(result.forward[0]).toEqual({ + op: "replace", + path: "/elements/hf-title/text", + value: "Goodbye World", + }); + const el = parsed.document.querySelector('[data-hf-id="hf-title"]'); + // text node should contain new value + expect(el?.textContent).toContain("Goodbye World"); + }); + + it("inverse patches restore original text", () => { + const parsed = fresh(); + const before = serializeDocument(parsed); + const { inverse } = applyOp(parsed, { + type: "setText", + target: "hf-title", + value: "Changed", + }); + applyPatchesToDocument(parsed, inverse); + expect(serializeDocument(parsed)).toBe(before); + }); + + it("override-set key maps correctly", () => { + expect(pathToKey("/elements/hf-title/text")).toBe("hf-title.text"); + }); +}); + +// ─── setAttribute ───────────────────────────────────────────────────────────── + +describe("setAttribute", () => { + it("sets a new attribute and emits add patch", () => { + const parsed = fresh(); + const result = applyOp(parsed, { + type: "setAttribute", + target: "hf-logo", + name: "src", + value: "/new-logo.png", + }); + expect(result.forward[0]).toEqual({ + op: "replace", + path: "/elements/hf-logo/attributes/src", + value: "/new-logo.png", + }); + }); + + it("removes attribute when value is null", () => { + const parsed = fresh(); + const result = applyOp(parsed, { + type: "setAttribute", + target: "hf-logo", + name: "alt", + value: null, + }); + expect(result.forward[0]?.op).toBe("remove"); + expect(result.inverse[0]?.value).toBe("Logo"); + }); + + it("inverse patches restore original attribute", () => { + const parsed = fresh(); + const before = serializeDocument(parsed); + const { inverse } = applyOp(parsed, { + type: "setAttribute", + target: "hf-logo", + name: "src", + value: "/changed.png", + }); + applyPatchesToDocument(parsed, inverse); + expect(serializeDocument(parsed)).toBe(before); + }); +}); + +// ─── setTiming ──────────────────────────────────────────────────────────────── + +describe("setTiming", () => { + it("updates start and recalculates end", () => { + const parsed = fresh(); + const result = applyOp(parsed, { + type: "setTiming", + target: "hf-title", + start: 1, + }); + const el = parsed.document.querySelector('[data-hf-id="hf-title"]'); + expect(el?.getAttribute("data-start")).toBe("1"); + // duration was 3 (0→3), so end = 1+3 = 4 + expect(el?.getAttribute("data-end")).toBe("4"); + const startPatch = result.forward.find((p) => p.path.endsWith("/start")); + expect(startPatch?.value).toBe(1); + }); + + it("updates duration and recalculates end", () => { + const parsed = fresh(); + applyOp(parsed, { type: "setTiming", target: "hf-title", duration: 2 }); + const el = parsed.document.querySelector('[data-hf-id="hf-title"]'); + expect(el?.getAttribute("data-end")).toBe("2"); // start=0, duration=2 → end=2 + }); + + it("inverse patches restore original timing", () => { + const parsed = fresh(); + const before = serializeDocument(parsed); + const { inverse } = applyOp(parsed, { + type: "setTiming", + target: "hf-title", + start: 1, + duration: 2, + trackIndex: 1, + }); + applyPatchesToDocument(parsed, inverse); + expect(serializeDocument(parsed)).toBe(before); + }); +}); + +// ─── removeElement ─────────────────────────────────────────────────────────── + +describe("removeElement", () => { + it("removes element from DOM and emits remove patch", () => { + const parsed = fresh(); + const result = applyOp(parsed, { + type: "removeElement", + target: "hf-span", + }); + expect(result.forward[0]?.op).toBe("remove"); + expect(result.forward[0]?.path).toBe("/elements/hf-span"); + expect(parsed.document.querySelector('[data-hf-id="hf-span"]')).toBeNull(); + }); + + it("inverse patch carries html and restore position", () => { + const parsed = fresh(); + const { inverse } = applyOp(parsed, { + type: "removeElement", + target: "hf-span", + }); + expect(inverse[0]?.op).toBe("add"); + const val = inverse[0]?.value as { + html: string; + parentId: string | null; + siblingIndex: number; + }; + expect(val.html).toContain("hf-span"); + expect(val.parentId).toBe("hf-sub"); + expect(val.siblingIndex).toBe(0); + }); + + it("applying inverse patch restores the element in correct parent", () => { + const parsed = fresh(); + const { inverse } = applyOp(parsed, { + type: "removeElement", + target: "hf-span", + }); + applyPatchesToDocument(parsed, inverse); + const restored = parsed.document.querySelector('[data-hf-id="hf-span"]'); + expect(restored).not.toBeNull(); + expect(restored?.parentElement?.getAttribute("data-hf-id")).toBe("hf-sub"); + expect(restored?.getAttribute("style")).toBe("opacity: 0.5"); + expect(restored?.textContent).toBe("sub text"); + }); +}); + +// ─── setVariableValue ───────────────────────────────────────────────────────── + +describe("setVariableValue", () => { + it("sets CSS custom property on root element", () => { + const parsed = fresh(); + const result = applyOp(parsed, { + type: "setVariableValue", + id: "brand-color-primary", + value: "#ff0000", + }); + expect(result.forward[0]?.path).toBe("/variables/brand-color-primary"); + expect(result.forward[0]?.value).toBe("#ff0000"); + const root = parsed.document.querySelector("[data-hf-root]"); + expect(root?.getAttribute("style")).toContain("--brand-color-primary: #ff0000"); + }); + + it("override-set key maps correctly", () => { + expect(pathToKey("/variables/brand-color-primary")).toBe("var.brand-color-primary"); + }); +}); + +// ─── setCompositionMetadata ─────────────────────────────────────────────────── + +describe("setCompositionMetadata", () => { + it("updates width, height, duration on root element", () => { + const parsed = fresh(); + applyOp(parsed, { + type: "setCompositionMetadata", + width: 1920, + height: 1080, + duration: 10, + }); + const root = parsed.document.querySelector("[data-hf-root]"); + expect(root?.getAttribute("style")).toContain("width: 1920px"); + expect(root?.getAttribute("style")).toContain("height: 1080px"); + expect(root?.getAttribute("data-duration")).toBe("10"); + }); + + it("inverse patches restore original metadata", () => { + const parsed = fresh(); + const before = serializeDocument(parsed); + const { inverse } = applyOp(parsed, { + type: "setCompositionMetadata", + width: 1920, + height: 1080, + duration: 10, + }); + applyPatchesToDocument(parsed, inverse); + expect(serializeDocument(parsed)).toBe(before); + }); +}); + +// ─── moveElement ───────────────────────────────────────────────────────────── + +describe("moveElement", () => { + it("sets left and top as inline styles", () => { + const parsed = fresh(); + const result = applyOp(parsed, { + type: "moveElement", + target: "hf-title", + x: 100, + y: 200, + }); + expect(result.forward.some((p) => p.path.endsWith("/left"))).toBe(true); + expect(result.forward.some((p) => p.path.endsWith("/top"))).toBe(true); + const el = parsed.document.querySelector('[data-hf-id="hf-title"]'); + expect(el?.getAttribute("style")).toContain("left: 100px"); + expect(el?.getAttribute("style")).toContain("top: 200px"); + }); +}); + +// ─── validateOp (can()) ─────────────────────────────────────────────────────── + +describe("validateOp", () => { + it("returns true for existing element", () => { + expect(validateOp(fresh(), { type: "setStyle", target: "hf-title", styles: {} })).toBe(true); + }); + + it("returns false for unknown element id", () => { + expect(validateOp(fresh(), { type: "setStyle", target: "hf-unknown", styles: {} })).toBe(false); + }); + + it("returns true for setCompositionMetadata (no target)", () => { + expect(validateOp(fresh(), { type: "setCompositionMetadata", width: 100 })).toBe(true); + }); +}); diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts new file mode 100644 index 000000000..3947354d0 --- /dev/null +++ b/packages/sdk/src/engine/mutate.ts @@ -0,0 +1,356 @@ +/** + * Op handlers for Phase 3a (non-parser ops). + * + * Each handler: mutates the linkedom Document, returns {forward, inverse} RFC 6902 patches. + * Pure with respect to events — callers emit events from the patches. + * + * Phase 3b (parser-backed) will add setClassStyle + 7 GSAP ops as additional handlers. + */ + +import type { EditOp, HfId, JsonPatchOp } from "../types.js"; +import type { ParsedDocument } from "./model.js"; +import { + findById, + findRoot, + getElementStyles, + setElementStyles, + getOwnText, + setOwnText, + getSiblingIndex, +} from "./model.js"; +import { + stylePath, + textPath, + attrPath, + timingPath, + holdPath, + elementPath, + variablePath, + metaPath, + scalarChange, + scalarDelete, + patchAdd, + patchRemove, +} from "./patches.js"; + +export interface MutationResult { + forward: JsonPatchOp[]; + inverse: JsonPatchOp[]; +} + +const EMPTY: MutationResult = { forward: [], inverse: [] }; + +// ─── Target normalization ──────────────────────────────────────────────────── + +function targets(target: HfId | HfId[]): HfId[] { + return Array.isArray(target) ? target : [target]; +} + +// ─── Op dispatch ──────────────────────────────────────────────────────────── + +export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult { + switch (op.type) { + case "setStyle": + return handleSetStyle(parsed, targets(op.target), op.styles); + case "setText": + return handleSetText(parsed, targets(op.target), op.value); + case "setAttribute": + return handleSetAttribute(parsed, targets(op.target), op.name, op.value); + case "setTiming": + return handleSetTiming(parsed, targets(op.target), { + start: op.start, + duration: op.duration, + trackIndex: op.trackIndex, + }); + case "setHold": + return handleSetHold(parsed, targets(op.target), op.hold); + case "moveElement": + return handleSetStyle(parsed, targets(op.target), { + left: `${op.x}px`, + top: `${op.y}px`, + }); + case "removeElement": + return handleRemoveElement(parsed, targets(op.target)); + case "setCompositionMetadata": + return handleSetCompositionMetadata(parsed, op); + case "setVariableValue": + return handleSetVariableValue(parsed, op.id, op.value); + // Phase 3b parser-backed ops — pass through without mutation for now + case "setClassStyle": + case "addGsapTween": + case "setGsapTween": + case "setGsapKeyframe": + case "addGsapKeyframe": + case "removeGsapKeyframe": + case "removeGsapTween": + case "addLabel": + case "removeLabel": + return EMPTY; + } +} + +// ─── Op handlers ──────────────────────────────────────────────────────────── + +function handleSetStyle( + parsed: ParsedDocument, + ids: HfId[], + styles: Record, +): MutationResult { + const result: MutationResult = { forward: [], inverse: [] }; + for (const id of ids) { + const el = findById(parsed.document, id); + if (!el) continue; + const old = getElementStyles(el); + setElementStyles(el, styles); + for (const [prop, value] of Object.entries(styles)) { + const path = stylePath(id, prop); + const oldValue = old[prop] ?? null; + if (value !== null) { + const p = scalarChange(path, oldValue, value); + result.forward.push(p.forward); + result.inverse.push(p.inverse); + } else if (oldValue !== null) { + const p = scalarDelete(path, oldValue); + result.forward.push(p.forward); + result.inverse.push(p.inverse); + } + } + } + return result; +} + +function handleSetText(parsed: ParsedDocument, ids: HfId[], value: string): MutationResult { + const result: MutationResult = { forward: [], inverse: [] }; + for (const id of ids) { + const el = findById(parsed.document, id); + if (!el) continue; + const oldText = getOwnText(el); + setOwnText(el, value); + const path = textPath(id); + const p = scalarChange(path, oldText || null, value); + result.forward.push(p.forward); + result.inverse.push(p.inverse); + } + return result; +} + +function handleSetAttribute( + parsed: ParsedDocument, + ids: HfId[], + name: string, + value: string | null, +): MutationResult { + const result: MutationResult = { forward: [], inverse: [] }; + for (const id of ids) { + const el = findById(parsed.document, id); + if (!el) continue; + const oldValue = el.getAttribute(name); + const path = attrPath(id, name); + if (value !== null) { + el.setAttribute(name, value); + const p = scalarChange(path, oldValue, value); + result.forward.push(p.forward); + result.inverse.push(p.inverse); + } else if (oldValue !== null) { + el.removeAttribute(name); + const p = scalarDelete(path, oldValue); + result.forward.push(p.forward); + result.inverse.push(p.inverse); + } + } + return result; +} + +// fallow-ignore-next-line complexity +function handleSetTiming( + parsed: ParsedDocument, + ids: HfId[], + timing: { start?: number; duration?: number; trackIndex?: number }, +): MutationResult { + const result: MutationResult = { forward: [], inverse: [] }; + for (const id of ids) { + const el = findById(parsed.document, id); + if (!el) continue; + + const oldStartStr = el.getAttribute("data-start"); + const oldEndStr = el.getAttribute("data-end"); + const oldTrackStr = el.getAttribute("data-track-index"); + + const oldStart = oldStartStr !== null ? parseFloat(oldStartStr) : null; + const oldEnd = oldEndStr !== null ? parseFloat(oldEndStr) : null; + const oldDuration = oldStart !== null && oldEnd !== null ? oldEnd - oldStart : null; + const oldTrack = oldTrackStr !== null ? parseInt(oldTrackStr, 10) : null; + + const newStart = timing.start ?? oldStart; + const newDuration = timing.duration ?? oldDuration; + + if (timing.start !== undefined && newStart !== null) { + const path = timingPath(id, "start"); + const p = scalarChange(path, oldStart, newStart); + result.forward.push(p.forward); + result.inverse.push(p.inverse); + el.setAttribute("data-start", String(newStart)); + } + + if ( + (timing.duration !== undefined || timing.start !== undefined) && + newStart !== null && + newDuration !== null + ) { + const newEnd = newStart + newDuration; + // Store the computed end value directly (not the logical duration) so the inverse + // patch is self-contained and doesn't require data-start to be restored first. + const path = timingPath(id, "end"); + const p = scalarChange(path, oldEnd, newEnd); + result.forward.push(p.forward); + result.inverse.push(p.inverse); + el.setAttribute("data-end", String(newEnd)); + } + + if (timing.trackIndex !== undefined) { + const newTrack = timing.trackIndex; + const path = timingPath(id, "trackIndex"); + const p = scalarChange(path, oldTrack, newTrack); + result.forward.push(p.forward); + result.inverse.push(p.inverse); + el.setAttribute("data-track-index", String(newTrack)); + } + } + return result; +} + +function handleSetHold( + parsed: ParsedDocument, + ids: HfId[], + hold: { start: number; end: number; fill: "freeze" | "loop" }, +): MutationResult { + const result: MutationResult = { forward: [], inverse: [] }; + for (const id of ids) { + const el = findById(parsed.document, id); + if (!el) continue; + + const fields: Array<["start" | "end" | "fill", string]> = [ + ["start", String(hold.start)], + ["end", String(hold.end)], + ["fill", hold.fill], + ]; + + for (const [field, newVal] of fields) { + const attrName = `data-hold-${field}`; + const oldVal = el.getAttribute(attrName); + const path = holdPath(id, field); + el.setAttribute(attrName, newVal); + const p = scalarChange(path, oldVal, newVal); + result.forward.push(p.forward); + result.inverse.push(p.inverse); + } + } + return result; +} + +function handleRemoveElement(parsed: ParsedDocument, ids: HfId[]): MutationResult { + const result: MutationResult = { forward: [], inverse: [] }; + for (const id of ids) { + const el = findById(parsed.document, id); + if (!el) continue; + const parentEl = el.parentElement; + const parentId = parentEl?.getAttribute("data-hf-id") ?? null; + const siblingIndex = getSiblingIndex(el); + const html = el.outerHTML; + + el.remove(); + + const path = elementPath(id); + result.forward.push(patchRemove(path)); + result.inverse.push(patchAdd(path, { html, parentId, siblingIndex })); + } + return result; +} + +// fallow-ignore-next-line complexity +function handleSetCompositionMetadata( + parsed: ParsedDocument, + op: { width?: number; height?: number; duration?: number }, +): MutationResult { + const result: MutationResult = { forward: [], inverse: [] }; + const root = findRoot(parsed.document); + if (!root) return result; + + if (op.width !== undefined) { + const styles = getElementStyles(root); + const oldWidth = styles["width"] ?? null; + const newVal = `${op.width}px`; + setElementStyles(root, { width: newVal }); + const path = metaPath("width"); + const p = scalarChange(path, oldWidth !== null ? parseFloat(oldWidth) : null, op.width); + result.forward.push(p.forward); + result.inverse.push(p.inverse); + } + + if (op.height !== undefined) { + const styles = getElementStyles(root); + const oldHeight = styles["height"] ?? null; + const newVal = `${op.height}px`; + setElementStyles(root, { height: newVal }); + const path = metaPath("height"); + const p = scalarChange(path, oldHeight !== null ? parseFloat(oldHeight) : null, op.height); + result.forward.push(p.forward); + result.inverse.push(p.inverse); + } + + if (op.duration !== undefined) { + const oldDur = root.getAttribute("data-duration"); + const oldVal = oldDur !== null ? parseFloat(oldDur) : null; + root.setAttribute("data-duration", String(op.duration)); + const path = metaPath("duration"); + const p = scalarChange(path, oldVal, op.duration); + result.forward.push(p.forward); + result.inverse.push(p.inverse); + } + + return result; +} + +function handleSetVariableValue( + parsed: ParsedDocument, + id: string, + value: string | number | boolean, +): MutationResult { + const root = findRoot(parsed.document); + if (!root) return EMPTY; + + const cssVar = `--${id}`; + const oldStyles = getElementStyles(root); + const oldValue = oldStyles[cssVar] ?? null; + const newVal = String(value); + setElementStyles(root, { [cssVar]: newVal }); + + const path = variablePath(id); + const p = scalarChange(path, oldValue, newVal); + return { forward: [p.forward], inverse: [p.inverse] }; +} + +// ─── Validation (can(op)) ──────────────────────────────────────────────────── + +/** Returns true if the op can be applied to the current document state. */ +export function validateOp(parsed: ParsedDocument, op: EditOp): boolean { + switch (op.type) { + case "setStyle": + case "setText": + case "setAttribute": + case "setTiming": + case "setHold": + case "moveElement": + case "removeElement": { + const ids = targets(op.target); + return ids.length > 0 && ids.every((id) => findById(parsed.document, id) !== null); + } + case "setVariableValue": + return findRoot(parsed.document) !== null; + case "setCompositionMetadata": + return true; + // Phase 3b — defer validation; allow through + default: + return true; + } +} diff --git a/packages/sdk/src/engine/patches.ts b/packages/sdk/src/engine/patches.ts new file mode 100644 index 000000000..8c2d27557 --- /dev/null +++ b/packages/sdk/src/engine/patches.ts @@ -0,0 +1,151 @@ +/** + * RFC 6902 patch path grammar (F2) and override-set key mapping (F2 item 7). + * + * Path grammar: + * /elements/{hfId}/inlineStyles/{camelCaseProp} + * /elements/{hfId}/text + * /elements/{hfId}/attributes/{name} + * /elements/{hfId}/timing/{start|duration|trackIndex} + * /elements/{hfId}/hold/{start|end|fill} + * /elements/{hfId} ← whole subtree (removeElement) + * /variables/{variableId} + * /metadata/{width|height|duration} + * + * Override-set key mapping: + * /elements/hf-x/inlineStyles/fontSize → "hf-x.style.fontSize" + * /elements/hf-x/text → "hf-x.text" + * /elements/hf-x/attributes/src → "hf-x.attr.src" + * /elements/hf-x/timing/start → "hf-x.timing.start" + * /elements/hf-x/hold/start → "hf-x.hold.start" + * /elements/hf-x → "hf-x" (null = removal marker) + * /variables/brand-color-primary → "var.brand-color-primary" + * /metadata/width → "meta.width" + */ + +import type { JsonPatchOp, PatchEvent } from "../types.js"; + +// ─── Path builders ──────────────────────────────────────────────────────────── + +export function stylePath(id: string, prop: string): string { + return `/elements/${id}/inlineStyles/${prop}`; +} + +export function textPath(id: string): string { + return `/elements/${id}/text`; +} + +export function attrPath(id: string, name: string): string { + // RFC 6902 JSON Pointer: ~ → ~0, / → ~1 + const escaped = name.replace(/~/g, "~0").replace(/\//g, "~1"); + return `/elements/${id}/attributes/${escaped}`; +} + +export function timingPath(id: string, field: "start" | "end" | "trackIndex"): string { + return `/elements/${id}/timing/${field}`; +} + +export function holdPath(id: string, field: "start" | "end" | "fill"): string { + return `/elements/${id}/hold/${field}`; +} + +export function elementPath(id: string): string { + return `/elements/${id}`; +} + +export function variablePath(id: string): string { + return `/variables/${id}`; +} + +export function metaPath(field: "width" | "height" | "duration"): string { + return `/metadata/${field}`; +} + +// ─── Override-set key mapping ───────────────────────────────────────────────── + +/** + * Maps an RFC 6902 patch path to its override-set key. + * Returns null for paths that don't correspond to override-set entries. + */ +export function pathToKey(path: string): string | null { + // /elements/{id}/inlineStyles/{prop} → "{id}.style.{prop}" + const styleMatch = /^\/elements\/([^/]+)\/inlineStyles\/(.+)$/.exec(path); + if (styleMatch) return `${styleMatch[1]}.style.${styleMatch[2]}`; + + // /elements/{id}/text → "{id}.text" + const textMatch = /^\/elements\/([^/]+)\/text$/.exec(path); + if (textMatch) return `${textMatch[1]}.text`; + + // /elements/{id}/attributes/{name} → "{id}.attr.{name}" + const attrMatch = /^\/elements\/([^/]+)\/attributes\/(.+)$/.exec(path); + if (attrMatch) return `${attrMatch[1]}.attr.${attrMatch[2]}`; + + // /elements/{id}/timing/{field} → "{id}.timing.{field}" + // Note: field "end" maps to the computed data-end attribute value. + const timingMatch = /^\/elements\/([^/]+)\/timing\/(.+)$/.exec(path); + if (timingMatch) return `${timingMatch[1]}.timing.${timingMatch[2]}`; + + // /elements/{id}/hold/{field} → "{id}.hold.{field}" + const holdMatch = /^\/elements\/([^/]+)\/hold\/(.+)$/.exec(path); + if (holdMatch) return `${holdMatch[1]}.hold.${holdMatch[2]}`; + + // /elements/{id} (whole element) → "{id}" + const elemMatch = /^\/elements\/([^/]+)$/.exec(path); + if (elemMatch) return elemMatch[1] ?? null; + + // /variables/{id} → "var.{id}" + const varMatch = /^\/variables\/(.+)$/.exec(path); + if (varMatch) return `var.${varMatch[1]}`; + + // /metadata/{field} → "meta.{field}" + const metaMatch = /^\/metadata\/(.+)$/.exec(path); + if (metaMatch) return `meta.${metaMatch[1]}`; + + return null; +} + +// ─── Patch event builder ────────────────────────────────────────────────────── + +export function buildPatchEvent( + forward: readonly JsonPatchOp[], + inverse: readonly JsonPatchOp[], + origin: unknown, + opTypes: readonly string[], +): PatchEvent { + return { formatVersion: 1, patches: forward, inversePatches: inverse, origin, opTypes }; +} + +// ─── Replace/add/remove helpers ─────────────────────────────────────────────── + +function patchReplace(path: string, value: unknown): JsonPatchOp { + return { op: "replace", path, value }; +} + +export function patchAdd(path: string, value: unknown): JsonPatchOp { + return { op: "add", path, value }; +} + +export function patchRemove(path: string): JsonPatchOp { + return { op: "remove", path }; +} + +/** Emit forward (replace or add) + inverse (replace or remove) for a scalar change. */ +export function scalarChange( + path: string, + oldValue: string | number | boolean | null | undefined, + newValue: string | number | boolean, +): { forward: JsonPatchOp; inverse: JsonPatchOp } { + const forward = oldValue == null ? patchAdd(path, newValue) : patchReplace(path, newValue); + const inverse = oldValue == null ? patchRemove(path) : patchReplace(path, oldValue ?? null); + return { forward, inverse }; +} + +/** Emit forward remove + inverse add for a deletion. */ +export function scalarDelete( + path: string, + oldValue: string | number | boolean, +): { forward: JsonPatchOp; inverse: JsonPatchOp } { + return { + forward: patchRemove(path), + inverse: patchAdd(path, oldValue), + }; +} diff --git a/packages/sdk/src/engine/serialize.ts b/packages/sdk/src/engine/serialize.ts new file mode 100644 index 000000000..3edcead86 --- /dev/null +++ b/packages/sdk/src/engine/serialize.ts @@ -0,0 +1,22 @@ +/** + * HTML serializer — walks the live linkedom Document and generates clean HF HTML. + * + * Phase 3a: generates from the live DOM. The DOM IS the mutable state. + * Phase 3b: GSAP script section will use the meriyah/offset-splice path once available. + */ + +import type { ParsedDocument } from "./model.js"; + +/** + * Serialize the live document back to HTML. + * + * If the original input was a fragment (wrapped=true), returns only body content. + * If the original input had a full HTML shell (wrapped=false), returns the full document. + */ +export function serializeDocument(parsed: ParsedDocument): string { + const doc = parsed.document; + if (parsed.wrapped) { + return (doc.body as HTMLBodyElement).innerHTML ?? ""; + } + return `\n${doc.documentElement.outerHTML}`; +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts new file mode 100644 index 000000000..e49638838 --- /dev/null +++ b/packages/sdk/src/index.ts @@ -0,0 +1,30 @@ +export type { + HyperFramesElement, + SdkDocument, + OverrideSet, + EditOp, + ElasticHold, + GsapTweenSpec, + HfId, + JsonPatchOp, + PatchEvent, + PersistErrorEvent, + ElementSnapshot, + FindQuery, + SelectionProxy, + ElementHandle, + Composition, +} from "./types.js"; + +export { ORIGIN_APPLY_PATCHES, ORIGIN_LOCAL } from "./types.js"; + +export { buildDocument, flatElements } from "./document.js"; + +export { openComposition } from "./session.js"; +export type { OpenCompositionOptions } from "./session.js"; + +export { createHistory } from "./history.js"; +export type { HistoryModule, HistoryOptions, HistoryEntry } from "./history.js"; + +export { createPersistQueue } from "./persist-queue.js"; +export type { PersistQueueModule, PersistQueueOptions } from "./persist-queue.js"; diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts new file mode 100644 index 000000000..39d1f2668 --- /dev/null +++ b/packages/sdk/src/types.ts @@ -0,0 +1,243 @@ +// ─── Document model ─────────────────────────────────────────────────────────── + +/** Full DOM-level view of one editable element. Built by the SDK adaptation layer. */ +export interface HyperFramesElement { + readonly id: string; + readonly tag: string; + readonly children: readonly HyperFramesElement[]; + /** camelCase property names — mirrors CSSStyleDeclaration convention */ + readonly inlineStyles: Readonly>; + readonly classNames: readonly string[]; + /** All attributes except style, class, and data-hf-* (those are model-level) */ + readonly attributes: Readonly>; + /** Direct text node content (not descendant text) */ + readonly text: string | null; + // Timing — null when element has no data-start + readonly start: number | null; + readonly duration: number | null; + readonly trackIndex: number | null; + /** Phase 2: GSAP tween IDs whose target is this element */ + readonly animationIds: readonly string[]; +} + +/** The SDK's in-memory document. Built from ensureHfIds + linkedom DOM walk. */ +export interface SdkDocument { + readonly roots: readonly HyperFramesElement[]; + readonly gsapScript: string | null; + readonly styles: string | null; + readonly width: number | null; + readonly height: number | null; + readonly compositionDuration: number | null; + /** ensureHfIds-stamped HTML used as the source of truth for serialization */ + readonly html: string; +} + +// ─── Override-set (T3 embedded mode) ───────────────────────────────────────── + +/** + * Sparse map of `hfId.prop.path → value` overrides layered on top of the base template. + * null value = removal marker (element or property deleted by user). + * Examples: { "hf-x7k2.style.fontSize": "96px", "hf-y3a1.text": "Hello", "hf-z5k2": null } + */ +export type OverrideSet = Record; + +// ─── Edit operations (F1: explicit target on every element op) ──────────────── + +export type HfId = string; + +/** Every element op takes explicit target id(s). No selection-implicit mutation. */ +export type EditOp = + | { type: "setStyle"; target: HfId | HfId[]; styles: Record } + | { type: "setText"; target: HfId | HfId[]; value: string } + | { type: "setAttribute"; target: HfId | HfId[]; name: string; value: string | null } + | { + type: "setTiming"; + target: HfId | HfId[]; + start?: number; + duration?: number; + trackIndex?: number; + } + | { type: "setHold"; target: HfId | HfId[]; hold: ElasticHold } + | { type: "moveElement"; target: HfId | HfId[]; x: number; y: number } + | { type: "removeElement"; target: HfId | HfId[] } + | { type: "setClassStyle"; selector: string; styles: Record } + | { type: "setCompositionMetadata"; width?: number; height?: number; duration?: number } + | { type: "setVariableValue"; id: string; value: string | number | boolean } + | { type: "addGsapTween"; target: HfId; id: string; tween: GsapTweenSpec } + | { type: "setGsapTween"; animationId: string; properties: Partial } + | { + type: "setGsapKeyframe"; + animationId: string; + keyframeIndex: number; + position?: number; + value?: Record; + ease?: string; + } + | { + type: "addGsapKeyframe"; + animationId: string; + position: number; + value: Record; + } + | { type: "removeGsapKeyframe"; animationId: string; keyframeIndex: number } + | { type: "removeGsapTween"; animationId: string } + | { type: "addLabel"; name: string; position: number } + | { type: "removeLabel"; name: string }; + +export interface ElasticHold { + start: number; + end: number; + fill: "freeze" | "loop"; +} + +export interface GsapTweenSpec { + method: "from" | "to" | "fromTo"; + position?: number | string; + duration?: number; + ease?: string; + fromProperties?: Record; + toProperties?: Record; + /** For 'to' tweens — the properties to animate toward */ + properties?: Record; + repeat?: number; + yoyo?: boolean; +} + +// ─── Patch layer (F2: RFC 6902 frozen contract) ─────────────────────────────── + +export interface JsonPatchOp { + op: "add" | "remove" | "replace"; + path: string; + value?: unknown; +} + +/** + * Emitted by session.on('patch') after every committed change. + * formatVersion bumps = breaking; hosts check once and reject unknown versions. + */ +export interface PatchEvent { + readonly formatVersion: 1; + readonly patches: readonly JsonPatchOp[]; + readonly inversePatches: readonly JsonPatchOp[]; + /** Re-emitted verbatim from the mutation entry. Use ORIGIN_APPLY_PATCHES to detect undo loops. */ + readonly origin: unknown; + /** Semantic op names ('setStyle') — for analytics/history labels. Not versioned. */ + readonly opTypes: readonly string[]; +} + +// ─── Origin model (F4) ──────────────────────────────────────────────────────── + +/** + * Reserved origin tag for applyPatches(). + * Host listeners MUST skip this origin to prevent undo loops: + * comp.on('patch', ({ origin }) => { if (origin === ORIGIN_APPLY_PATCHES) return; ... }) + */ +export const ORIGIN_APPLY_PATCHES: unique symbol = Symbol("applyPatches"); + +/** Default origin when none specified — UI-driven dispatch. */ +export const ORIGIN_LOCAL = "local" as const; + +// ─── Event types ───────────────────────────────────────────────────────────── + +export interface PersistErrorEvent { + error: { message: string; hint?: string; cause?: unknown }; +} + +// ─── Element query / snapshot (F1 query API) ───────────────────────────────── + +/** Flat read-only snapshot returned by getElements() / getElement() */ +export type ElementSnapshot = HyperFramesElement; + +export interface FindQuery { + tag?: string; + text?: string; + name?: string; + track?: number; +} + +// ─── Typed method sugar (F10) ───────────────────────────────────────────────── + +/** + * Proxy returned by comp.selection() — resolves getSelection() → explicit ops at call time. + * Multi-select gets well-defined semantics: op applied per id within one batch. + */ +export interface SelectionProxy { + readonly ids: readonly string[]; + setStyle(styles: Record): void; + setText(value: string): void; + setAttribute(name: string, value: string | null): void; + setTiming(timing: { start?: number; duration?: number; trackIndex?: number }): void; + removeElement(): void; +} + +/** + * Curried element handle — holds only the id string, no stale-ref hazard. + * comp.element('hf-x7k2').setStyle({ color: '#fff' }) + */ +export interface ElementHandle { + readonly id: string; + setStyle(styles: Record): void; + setText(value: string): void; + setAttribute(name: string, value: string | null): void; + setTiming(timing: { start?: number; duration?: number; trackIndex?: number }): void; + removeElement(): void; +} + +// ─── Composition (the main public surface, F10) ─────────────────────────────── + +/** + * An open composition editing session. + * Typed methods (docs page one) sugar over dispatch() — all validation in dispatch. + * dispatch() is the advanced/agent layer (data-shaped ops, automation, replay). + */ +export interface Composition { + // ── Typed methods (F10 layer 1) ──────────────────────────────────────────── + setStyle(id: HfId, styles: Record): void; + setText(id: HfId, value: string): void; + setAttribute(id: HfId, name: string, value: string | null): void; + setTiming(id: HfId, timing: { start?: number; duration?: number; trackIndex?: number }): void; + removeElement(id: HfId): void; + setVariableValue(id: string, value: string | number | boolean): void; + /** Returns the newly-assigned tween ID */ + addGsapTween(target: HfId, tween: GsapTweenSpec): string; + setGsapTween(animationId: string, properties: Partial): void; + removeGsapTween(animationId: string): void; + undo(): void; + redo(): void; + + // ── Query API (F1) ───────────────────────────────────────────────────────── + getElements(): ElementSnapshot[]; + getElement(id: HfId): ElementSnapshot | null; + find(query: FindQuery): string[]; + + // ── Selection API ────────────────────────────────────────────────────────── + /** Sugar: resolves getSelection() → explicit ops at call time */ + selection(): SelectionProxy; + /** Curried handle — holds only the id, no stale-ref hazard */ + element(id: HfId): ElementHandle; + getSelection(): string[]; + + // ── Advanced / agent layer (F10 layer 2) ────────────────────────────────── + dispatch(op: EditOp, opts?: { origin?: unknown }): void; + batch(fn: () => void, opts?: { origin?: unknown }): void; + /** Dry-run validation — would dispatch(op) succeed? UI enablement, agent precondition checks. */ + can(op: EditOp): boolean; + + // ── Events (one typed emitter — F10) ────────────────────────────────────── + on(event: "change", handler: () => void): () => void; + on(event: "selectionchange", handler: (ids: string[]) => void): () => void; + on(event: "patch", handler: (event: PatchEvent) => void): () => void; + on(event: "persist:error", handler: (event: PersistErrorEvent) => void): () => void; + + // ── Serialization ────────────────────────────────────────────────────────── + serialize(): string; + + // ── T3 embedded-mode extras ──────────────────────────────────────────────── + /** Current override-set — serialize for host storage */ + getOverrides(): OverrideSet; + /** Apply inverse patches from host undo stack; auto-tags origin: ORIGIN_APPLY_PATCHES */ + applyPatches(patches: readonly JsonPatchOp[], opts?: { origin?: unknown }): void; + + // ── Lifecycle ────────────────────────────────────────────────────────────── + dispose(): void; +} diff --git a/packages/sdk/tsconfig.check.json b/packages/sdk/tsconfig.check.json new file mode 100644 index 000000000..5d08590ac --- /dev/null +++ b/packages/sdk/tsconfig.check.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true + }, + "include": ["src/**/*", "examples/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json new file mode 100644 index 000000000..96f38bce0 --- /dev/null +++ b/packages/sdk/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/sdk/vitest.config.ts b/packages/sdk/vitest.config.ts new file mode 100644 index 000000000..ce36a7426 --- /dev/null +++ b/packages/sdk/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + environment: "node", + }, +}); From e0fae682f279bdcad736f6e7145b67ca673e3b1d Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 10 Jun 2026 15:21:01 -0700 Subject: [PATCH 2/6] =?UTF-8?q?fix(sdk):=20make=20engine-layer=20PR=20self?= =?UTF-8?q?-contained=20=E2=80=94=20trim=20index.ts,=20guard=20indexed=20a?= =?UTF-8?q?ccess?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- packages/sdk/src/adapters/types.ts | 2 ++ packages/sdk/src/engine/model.ts | 2 +- packages/sdk/src/engine/patches.ts | 2 ++ packages/sdk/src/index.ts | 11 ----------- 4 files changed, 5 insertions(+), 12 deletions(-) diff --git a/packages/sdk/src/adapters/types.ts b/packages/sdk/src/adapters/types.ts index 7ddb1410b..cf160727c 100644 --- a/packages/sdk/src/adapters/types.ts +++ b/packages/sdk/src/adapters/types.ts @@ -1,3 +1,5 @@ +// Consumed by session.ts + adapter implementations in the next stacked PR (#1325). +// fallow-ignore-file unused-file import type { PersistErrorEvent } from "../types.js"; // ─── PersistAdapter ─────────────────────────────────────────────────────────── diff --git a/packages/sdk/src/engine/model.ts b/packages/sdk/src/engine/model.ts index e170363bc..fb937b7a7 100644 --- a/packages/sdk/src/engine/model.ts +++ b/packages/sdk/src/engine/model.ts @@ -116,7 +116,7 @@ export function setOwnText(el: Element, text: string): void { // Track original position of the first text node so we restore there, not at firstChild. let firstTextIdx = -1; for (let i = 0; i < children.length; i++) { - if (children[i].nodeType === 3) { + if (children[i]?.nodeType === 3) { firstTextIdx = i; break; } diff --git a/packages/sdk/src/engine/patches.ts b/packages/sdk/src/engine/patches.ts index 8c2d27557..412627422 100644 --- a/packages/sdk/src/engine/patches.ts +++ b/packages/sdk/src/engine/patches.ts @@ -105,6 +105,8 @@ export function pathToKey(path: string): string | null { // ─── Patch event builder ────────────────────────────────────────────────────── +// Consumed by session.ts dispatch/batch in the next stacked PR (#1325). +// fallow-ignore-next-line unused-export export function buildPatchEvent( forward: readonly JsonPatchOp[], inverse: readonly JsonPatchOp[], diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index e49638838..0c1cc00cf 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -17,14 +17,3 @@ export type { } from "./types.js"; export { ORIGIN_APPLY_PATCHES, ORIGIN_LOCAL } from "./types.js"; - -export { buildDocument, flatElements } from "./document.js"; - -export { openComposition } from "./session.js"; -export type { OpenCompositionOptions } from "./session.js"; - -export { createHistory } from "./history.js"; -export type { HistoryModule, HistoryOptions, HistoryEntry } from "./history.js"; - -export { createPersistQueue } from "./persist-queue.js"; -export type { PersistQueueModule, PersistQueueOptions } from "./persist-queue.js"; From bc1f9e9e05bf9fb3cf46fbfd73667e773cdd3aef Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 10 Jun 2026 15:38:40 -0700 Subject: [PATCH 3/6] fix(sdk): fail loudly on Phase 3b ops; add sdk to root build pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- package.json | 2 +- packages/sdk/src/engine/mutate.test.ts | 27 +++++++++++++++++++ packages/sdk/src/engine/mutate.ts | 37 +++++++++++++++++++++++--- 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 62383845a..a97fc789f 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "type": "module", "scripts": { "dev": "bun run studio", - "build": "bun run --filter @hyperframes/core build && bun run --filter '@hyperframes/{core,engine,producer,player,studio,shader-transitions,aws-lambda,gcp-cloud-run}' build && bun run --filter @hyperframes/cli build", + "build": "bun run --filter @hyperframes/core build && bun run --filter '@hyperframes/{core,engine,producer,player,studio,shader-transitions,aws-lambda,gcp-cloud-run,sdk}' build && bun run --filter @hyperframes/cli build", "build:producer": "bun run --filter @hyperframes/producer build", "studio": "bun run --filter @hyperframes/studio dev", "build:hyperframes-runtime": "bun run --filter @hyperframes/core build:hyperframes-runtime", diff --git a/packages/sdk/src/engine/mutate.test.ts b/packages/sdk/src/engine/mutate.test.ts index 065b6978a..25b4bffed 100644 --- a/packages/sdk/src/engine/mutate.test.ts +++ b/packages/sdk/src/engine/mutate.test.ts @@ -361,3 +361,30 @@ describe("validateOp", () => { expect(validateOp(fresh(), { type: "setCompositionMetadata", width: 100 })).toBe(true); }); }); + +// ─── Phase 3b ops — fail loudly, feature-detectable ─────────────────────────── + +describe("Phase 3b ops", () => { + it("applyOp throws UnsupportedOpError instead of silently no-opping", () => { + expect(() => + applyOp(fresh(), { + type: "addGsapTween", + target: "hf-title", + id: "tw-1", + tween: { method: "from", fromProperties: { opacity: 0 } }, + }), + ).toThrowError(/Phase 3b/); + }); + + it("validateOp returns false so can() feature-detects", () => { + expect(validateOp(fresh(), { type: "removeGsapTween", animationId: "tw-1" })).toBe(false); + expect( + validateOp(fresh(), { + type: "addGsapTween", + target: "hf-title", + id: "tw-1", + tween: { method: "from", fromProperties: { opacity: 0 } }, + }), + ).toBe(false); + }); +}); diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index 3947354d0..35751445f 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -40,6 +40,32 @@ export interface MutationResult { const EMPTY: MutationResult = { forward: [], inverse: [] }; +/** Ops that require the Phase 3b parser-backed engine (meriyah/css-tree). */ +const PHASE3B_OPS = new Set([ + "setClassStyle", + "addGsapTween", + "setGsapTween", + "setGsapKeyframe", + "addGsapKeyframe", + "removeGsapKeyframe", + "removeGsapTween", + "addLabel", + "removeLabel", +]); + +// Re-exported from the package entry in the next stacked PR (#1325). +// fallow-ignore-next-line unused-export +export class UnsupportedOpError extends Error { + readonly code = "E_UNSUPPORTED_OP"; + constructor(opType: string) { + super( + `Op '${opType}' requires the Phase 3b parser-backed engine and is not available yet. ` + + `Use can(op) to feature-detect before dispatching.`, + ); + this.name = "UnsupportedOpError"; + } +} + // ─── Target normalization ──────────────────────────────────────────────────── function targets(target: HfId | HfId[]): HfId[] { @@ -75,7 +101,9 @@ export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult { return handleSetCompositionMetadata(parsed, op); case "setVariableValue": return handleSetVariableValue(parsed, op.id, op.value); - // Phase 3b parser-backed ops — pass through without mutation for now + // Phase 3b parser-backed ops — fail loudly rather than silently no-op: + // a caller must never believe an animation edit succeeded when nothing + // was mutated and no patch was emitted. case "setClassStyle": case "addGsapTween": case "setGsapTween": @@ -85,7 +113,7 @@ export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult { case "removeGsapTween": case "addLabel": case "removeLabel": - return EMPTY; + throw new UnsupportedOpError(op.type); } } @@ -349,8 +377,9 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): boolean { return findRoot(parsed.document) !== null; case "setCompositionMetadata": return true; - // Phase 3b — defer validation; allow through + // Phase 3b — not implemented yet; can() must report false so callers + // can feature-detect instead of hitting UnsupportedOpError. default: - return true; + return !PHASE3B_OPS.has(op.type); } } From fd4b0ec6f760b296f413b5a105c6b61120bfae29 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 10 Jun 2026 15:49:31 -0700 Subject: [PATCH 4/6] fix(sdk): cross-realm origin sentinel, dual width/height channel, contract docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/sdk/src/engine/apply-patches.ts | 25 ++++++++++++++--- packages/sdk/src/engine/mutate.test.ts | 35 ++++++++++++++++++++++++ packages/sdk/src/engine/mutate.ts | 13 +++++++-- packages/sdk/src/engine/patches.ts | 2 +- packages/sdk/src/types.ts | 17 ++++++++++-- 5 files changed, 83 insertions(+), 9 deletions(-) diff --git a/packages/sdk/src/engine/apply-patches.ts b/packages/sdk/src/engine/apply-patches.ts index aeae34aaa..686ac3414 100644 --- a/packages/sdk/src/engine/apply-patches.ts +++ b/packages/sdk/src/engine/apply-patches.ts @@ -3,6 +3,9 @@ * * Not a general-purpose JSON Patch implementation. Translates the well-defined path * grammar back into DOM mutations. Used by applyPatches() for host undo (T3 mode). + * + * Supports only the emit subset (add/remove/replace) — move/copy/test ops and + * unknown paths are silently ignored, matching the JsonPatchOp contract. */ import type { JsonPatchOp } from "../types.js"; @@ -164,12 +167,26 @@ function applyOne(parsed: ParsedDocument, patch: JsonPatchOp, p: ParsedPath): vo case "metadata": { const root = findRoot(parsed.document); if (!root || !p.field) return; + // Mirror mutate.ts: style always written; the data-* forced-override + // attribute is updated only when the composition already carries it. if (p.field === "width") { - if (patch.op === "remove") setElementStyles(root, { width: null }); - else setElementStyles(root, { width: `${patch.value}px` }); + if (patch.op === "remove") { + setElementStyles(root, { width: null }); + root.removeAttribute("data-width"); + } else { + setElementStyles(root, { width: `${patch.value}px` }); + if (root.hasAttribute("data-width")) root.setAttribute("data-width", String(patch.value)); + } } else if (p.field === "height") { - if (patch.op === "remove") setElementStyles(root, { height: null }); - else setElementStyles(root, { height: `${patch.value}px` }); + if (patch.op === "remove") { + setElementStyles(root, { height: null }); + root.removeAttribute("data-height"); + } else { + setElementStyles(root, { height: `${patch.value}px` }); + if (root.hasAttribute("data-height")) { + root.setAttribute("data-height", String(patch.value)); + } + } } else if (p.field === "duration") { if (patch.op === "remove") root.removeAttribute("data-duration"); else root.setAttribute("data-duration", String(patch.value)); diff --git a/packages/sdk/src/engine/mutate.test.ts b/packages/sdk/src/engine/mutate.test.ts index 25b4bffed..806a694df 100644 --- a/packages/sdk/src/engine/mutate.test.ts +++ b/packages/sdk/src/engine/mutate.test.ts @@ -388,3 +388,38 @@ describe("Phase 3b ops", () => { ).toBe(false); }); }); + +// ─── setCompositionMetadata — data-width/data-height forced override ───────── + +describe("setCompositionMetadata data-* channel", () => { + const ATTR_HTML = ` +
+

Hi

+
+`.trim(); + + it("updates data-width/data-height when the composition carries them", () => { + const parsed = parseMutable(ATTR_HTML); + applyOp(parsed, { type: "setCompositionMetadata", width: 1920, height: 1080 }); + const root = parsed.document.querySelector("[data-hf-root]"); + expect(root?.getAttribute("data-width")).toBe("1920"); + expect(root?.getAttribute("data-height")).toBe("1080"); + expect(root?.getAttribute("style")).toContain("width: 1920px"); + }); + + it("inverse restores both channels", () => { + const parsed = parseMutable(ATTR_HTML); + const before = serializeDocument(parsed); + const { inverse } = applyOp(parsed, { type: "setCompositionMetadata", width: 1920 }); + applyPatchesToDocument(parsed, inverse); + expect(serializeDocument(parsed)).toBe(before); + }); + + it("does not mint data-* attributes on compositions without them", () => { + const parsed = fresh(); + applyOp(parsed, { type: "setCompositionMetadata", width: 1920 }); + const root = parsed.document.querySelector("[data-hf-root]"); + expect(root?.hasAttribute("data-width")).toBe(false); + expect(root?.getAttribute("style")).toContain("width: 1920px"); + }); +}); diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index 35751445f..a8fcfef87 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -304,11 +304,18 @@ function handleSetCompositionMetadata( const root = findRoot(parsed.document); if (!root) return result; + // The runtime treats data-width/data-height as a FORCED override of inline + // style when present (core/runtime/init.ts applyCompositionSizing). So: + // style is always written; the data-* attribute is updated only when the + // composition already carries it — otherwise a style-only write would be + // clobbered on load. Absent attributes stay absent (keeps inverses exact). if (op.width !== undefined) { const styles = getElementStyles(root); - const oldWidth = styles["width"] ?? null; + const oldAttr = root.getAttribute("data-width"); + const oldWidth = oldAttr ?? styles["width"] ?? null; const newVal = `${op.width}px`; setElementStyles(root, { width: newVal }); + if (oldAttr !== null) root.setAttribute("data-width", String(op.width)); const path = metaPath("width"); const p = scalarChange(path, oldWidth !== null ? parseFloat(oldWidth) : null, op.width); result.forward.push(p.forward); @@ -317,9 +324,11 @@ function handleSetCompositionMetadata( if (op.height !== undefined) { const styles = getElementStyles(root); - const oldHeight = styles["height"] ?? null; + const oldAttr = root.getAttribute("data-height"); + const oldHeight = oldAttr ?? styles["height"] ?? null; const newVal = `${op.height}px`; setElementStyles(root, { height: newVal }); + if (oldAttr !== null) root.setAttribute("data-height", String(op.height)); const path = metaPath("height"); const p = scalarChange(path, oldHeight !== null ? parseFloat(oldHeight) : null, op.height); result.forward.push(p.forward); diff --git a/packages/sdk/src/engine/patches.ts b/packages/sdk/src/engine/patches.ts index 412627422..1a146fecd 100644 --- a/packages/sdk/src/engine/patches.ts +++ b/packages/sdk/src/engine/patches.ts @@ -5,7 +5,7 @@ * /elements/{hfId}/inlineStyles/{camelCaseProp} * /elements/{hfId}/text * /elements/{hfId}/attributes/{name} - * /elements/{hfId}/timing/{start|duration|trackIndex} + * /elements/{hfId}/timing/{start|end|trackIndex} ← end = computed absolute data-end * /elements/{hfId}/hold/{start|end|fill} * /elements/{hfId} ← whole subtree (removeElement) * /variables/{variableId} diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 39d1f2668..d1944b01f 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -28,7 +28,10 @@ export interface SdkDocument { readonly width: number | null; readonly height: number | null; readonly compositionDuration: number | null; - /** ensureHfIds-stamped HTML used as the source of truth for serialization */ + /** + * BUILD-TIME snapshot of the ensureHfIds-stamped HTML. Never updated after + * mutations — use Composition.serialize() for the current document state. + */ readonly html: string; } @@ -105,6 +108,11 @@ export interface GsapTweenSpec { // ─── Patch layer (F2: RFC 6902 frozen contract) ─────────────────────────────── +/** + * Emit-only subset of RFC 6902: the SDK never emits move/copy/test, and + * applyPatches() ignores ops outside this subset. Hosts feeding patches back + * must restrict themselves to add/remove/replace. + */ export interface JsonPatchOp { op: "add" | "remove" | "replace"; path: string; @@ -131,8 +139,13 @@ export interface PatchEvent { * Reserved origin tag for applyPatches(). * Host listeners MUST skip this origin to prevent undo loops: * comp.on('patch', ({ origin }) => { if (origin === ORIGIN_APPLY_PATCHES) return; ... }) + * + * A namespaced string (not a unique symbol) so the sentinel survives realm + * boundaries — postMessage, structured clone, JSON — which T3 embedded hosts + * may forward patch events across. The namespace prefix keeps collision risk + * with host-chosen origins negligible. */ -export const ORIGIN_APPLY_PATCHES: unique symbol = Symbol("applyPatches"); +export const ORIGIN_APPLY_PATCHES = "@hyperframes/sdk:applyPatches" as const; /** Default origin when none specified — UI-driven dispatch. */ export const ORIGIN_LOCAL = "local" as const; From 5209f9b335f0c92e75d7e77a1cf18906c939f262 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 11 Jun 2026 09:46:11 -0700 Subject: [PATCH 5/6] 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 --- packages/sdk/src/engine/mutate.test.ts | 21 ++++++++++++++++----- packages/sdk/src/engine/mutate.ts | 21 +++++++++++++++++---- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/packages/sdk/src/engine/mutate.test.ts b/packages/sdk/src/engine/mutate.test.ts index 806a694df..7416199eb 100644 --- a/packages/sdk/src/engine/mutate.test.ts +++ b/packages/sdk/src/engine/mutate.test.ts @@ -330,7 +330,7 @@ describe("setCompositionMetadata", () => { // ─── moveElement ───────────────────────────────────────────────────────────── describe("moveElement", () => { - it("sets left and top as inline styles", () => { + it("sets data-x and data-y attributes (HF positioning convention)", () => { const parsed = fresh(); const result = applyOp(parsed, { type: "moveElement", @@ -338,11 +338,22 @@ describe("moveElement", () => { x: 100, y: 200, }); - expect(result.forward.some((p) => p.path.endsWith("/left"))).toBe(true); - expect(result.forward.some((p) => p.path.endsWith("/top"))).toBe(true); const el = parsed.document.querySelector('[data-hf-id="hf-title"]'); - expect(el?.getAttribute("style")).toContain("left: 100px"); - expect(el?.getAttribute("style")).toContain("top: 200px"); + expect(el?.getAttribute("data-x")).toBe("100"); + expect(el?.getAttribute("data-y")).toBe("200"); + expect(result.forward.some((p) => p.path.endsWith("/data-x"))).toBe(true); + expect(result.forward.some((p) => p.path.endsWith("/data-y"))).toBe(true); + }); + + it("inverse restores prior data-x/data-y", () => { + const parsed = fresh(); + const el = parsed.document.querySelector('[data-hf-id="hf-title"]') as Element; + el.setAttribute("data-x", "50"); + el.setAttribute("data-y", "75"); + const result = applyOp(parsed, { type: "moveElement", target: "hf-title", x: 100, y: 200 }); + applyPatchesToDocument(parsed, result.inverse); + expect(el.getAttribute("data-x")).toBe("50"); + expect(el.getAttribute("data-y")).toBe("75"); }); }); diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index a8fcfef87..ff01098f4 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -91,10 +91,7 @@ export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult { case "setHold": return handleSetHold(parsed, targets(op.target), op.hold); case "moveElement": - return handleSetStyle(parsed, targets(op.target), { - left: `${op.x}px`, - top: `${op.y}px`, - }); + return handleMoveElement(parsed, targets(op.target), op.x, op.y); case "removeElement": return handleRemoveElement(parsed, targets(op.target)); case "setCompositionMetadata": @@ -147,6 +144,22 @@ function handleSetStyle( return result; } +function handleMoveElement( + parsed: ParsedDocument, + ids: HfId[], + x: number, + y: number, +): MutationResult { + // HF elements are positioned via data-x / data-y (parsed by htmlParser.ts, + // emitted by hyperframes generator). CSS left/top is not the convention. + const rx = handleSetAttribute(parsed, ids, "data-x", String(x)); + const ry = handleSetAttribute(parsed, ids, "data-y", String(y)); + return { + forward: [...rx.forward, ...ry.forward], + inverse: [...ry.inverse, ...rx.inverse], + }; +} + function handleSetText(parsed: ParsedDocument, ids: HfId[], value: string): MutationResult { const result: MutationResult = { forward: [], inverse: [] }; for (const id of ids) { From cd8d35fef19fd7f28fb1e7ef183761b15c67cb96 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 11 Jun 2026 12:11:28 -0700 Subject: [PATCH 6/6] chore: update bun.lock after sdk package registration Co-Authored-By: Claude Sonnet 4.6 --- bun.lock | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/bun.lock b/bun.lock index b8502622b..f0eab27f8 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/aws-lambda": { "name": "@hyperframes/aws-lambda", - "version": "0.6.86", + "version": "0.6.90", "dependencies": { "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/client-sfn": "^3.700.0", @@ -54,7 +54,7 @@ }, "packages/cli": { "name": "@hyperframes/cli", - "version": "0.6.86", + "version": "0.6.90", "bin": { "hyperframes": "./dist/cli.js", }, @@ -101,11 +101,12 @@ }, "packages/core": { "name": "@hyperframes/core", - "version": "0.6.86", + "version": "0.6.90", "dependencies": { "@babel/parser": "^7.27.0", "@chenglou/pretext": "^0.0.5", "postcss": "^8.5.8", + "postcss-selector-parser": "^7.1.2", "recast": "^0.23.11", }, "devDependencies": { @@ -130,7 +131,7 @@ }, "packages/engine": { "name": "@hyperframes/engine", - "version": "0.6.86", + "version": "0.6.90", "dependencies": { "@hono/node-server": "^1.13.0", "@hyperframes/core": "workspace:^", @@ -148,7 +149,7 @@ }, "packages/gcp-cloud-run": { "name": "@hyperframes/gcp-cloud-run", - "version": "0.6.86", + "version": "0.6.90", "dependencies": { "@google-cloud/storage": "^7.14.0", "@google-cloud/workflows": "^4.2.0", @@ -168,7 +169,7 @@ }, "packages/player": { "name": "@hyperframes/player", - "version": "0.6.86", + "version": "0.6.90", "devDependencies": { "@types/bun": "^1.1.0", "gsap": "^3.12.5", @@ -180,7 +181,7 @@ }, "packages/producer": { "name": "@hyperframes/producer", - "version": "0.6.86", + "version": "0.6.90", "dependencies": { "@fontsource/archivo-black": "^5.2.8", "@fontsource/eb-garamond": "^5.2.7", @@ -234,7 +235,7 @@ }, "packages/shader-transitions": { "name": "@hyperframes/shader-transitions", - "version": "0.6.86", + "version": "0.6.90", "dependencies": { "html2canvas": "^1.4.1", }, @@ -246,7 +247,7 @@ }, "packages/studio": { "name": "@hyperframes/studio", - "version": "0.6.86", + "version": "0.6.90", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", @@ -1753,7 +1754,7 @@ "postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="], - "postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], + "postcss-selector-parser": ["postcss-selector-parser@7.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg=="], "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], @@ -2147,6 +2148,8 @@ "path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], + "proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], @@ -2163,6 +2166,8 @@ "tailwindcss/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + "tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], + "tar/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], "teeny-request/http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="],