From f3059948f4faa1b75b3de7ab92415c42a4268653 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 10 Jun 2026 13:52:43 -0700 Subject: [PATCH 01/14] =?UTF-8?q?feat(sdk):=20scaffold=20@hyperframes/sdk?= =?UTF-8?q?=20=E2=80=94=20engine=20layer=20(model,=20RFC=206902=20patches,?= =?UTF-8?q?=20mutate,=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 5b413ae95c..64fcc1831b 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 8fdaacc862..b8502622b6 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 0000000000..4d235b5498 --- /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 0000000000..7ddb1410b0 --- /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 0000000000..aeae34aaa0 --- /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 0000000000..e170363bc8 --- /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 0000000000..065b6978a4 --- /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 0000000000..3947354d09 --- /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 0000000000..8c2d27557b --- /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 0000000000..3edcead86c --- /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 0000000000..e496388382 --- /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 0000000000..39d1f2668c --- /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 0000000000..5d08590ac0 --- /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 0000000000..96f38bce00 --- /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 0000000000..ce36a74267 --- /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 02/14] =?UTF-8?q?fix(sdk):=20make=20engine-layer=20PR=20se?= =?UTF-8?q?lf-contained=20=E2=80=94=20trim=20index.ts,=20guard=20indexed?= =?UTF-8?q?=20access?= 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 7ddb1410b0..cf160727ce 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 e170363bc8..fb937b7a7a 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 8c2d27557b..4126274223 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 e496388382..0c1cc00cf7 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 03/14] 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 62383845a0..a97fc789fe 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 065b6978a4..25b4bffed5 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 3947354d09..35751445f5 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 04/14] 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 aeae34aaa0..686ac34141 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 25b4bffed5..806a694df4 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 35751445f5..a8fcfef87b 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 4126274223..1a146fecd8 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 39d1f2668c..d1944b01f0 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 05/14] 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 806a694df4..7416199eb7 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 a8fcfef87b..ff01098f42 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 06/14] 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 b8502622b6..f0eab27f81 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=="], From 6abc61d18031dfb52311eacb85889cbdc9e0dd7f Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 10 Jun 2026 13:53:08 -0700 Subject: [PATCH 07/14] =?UTF-8?q?feat(sdk):=20session=20API,=20optional=20?= =?UTF-8?q?history=20+=20persist-queue,=20adapters=20=E2=80=94=20Phase=203?= =?UTF-8?q?a=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/sdk/examples/headless-agent.ts | 206 +++++++++ packages/sdk/examples/react-embed.ts | 148 +++++++ packages/sdk/examples/vanilla-editor.ts | 180 ++++++++ packages/sdk/src/adapters/fs.ts | 46 ++ packages/sdk/src/adapters/headless.ts | 24 ++ packages/sdk/src/adapters/memory.ts | 62 +++ .../adapters/persistAdapter.contract.test.ts | 128 ++++++ packages/sdk/src/document.ts | 190 +++++++++ packages/sdk/src/history.ts | 127 ++++++ packages/sdk/src/persist-queue.ts | 80 ++++ packages/sdk/src/session.ts | 396 ++++++++++++++++++ 11 files changed, 1587 insertions(+) create mode 100644 packages/sdk/examples/headless-agent.ts create mode 100644 packages/sdk/examples/react-embed.ts create mode 100644 packages/sdk/examples/vanilla-editor.ts create mode 100644 packages/sdk/src/adapters/fs.ts create mode 100644 packages/sdk/src/adapters/headless.ts create mode 100644 packages/sdk/src/adapters/memory.ts create mode 100644 packages/sdk/src/adapters/persistAdapter.contract.test.ts create mode 100644 packages/sdk/src/document.ts create mode 100644 packages/sdk/src/history.ts create mode 100644 packages/sdk/src/persist-queue.ts create mode 100644 packages/sdk/src/session.ts diff --git a/packages/sdk/examples/headless-agent.ts b/packages/sdk/examples/headless-agent.ts new file mode 100644 index 0000000000..af0d7052a1 --- /dev/null +++ b/packages/sdk/examples/headless-agent.ts @@ -0,0 +1,206 @@ +/** + * Archetype (c) — Headless agent script + * + * Shows: no browser, no persist adapter, no preview — pure editing engine. + * Agents: batch restyling, localization, A/B variants, programmatic animation. + * Explicit-id ops via query API — no selection, no mouse events. + * + * F1 payoff: headless is possible BECAUSE ops have explicit targets. + * Selection-implicit ops (old R0) would break here — no UI → no selection. + */ + +import { openComposition } from "../src/index.js"; +import type { ElementSnapshot } from "../src/index.js"; + +// ── Localization agent ──────────────────────────────────────────────────────── +// Rewrites all text elements to a new locale. No browser, no preview. + +export async function localize(html: string, translations: Map): Promise { + const comp = await openComposition(html); + + const textElements = comp.find({ tag: "div" }); + + comp.batch(() => { + for (const id of textElements) { + const el = comp.getElement(id); + if (!el?.text) continue; + const translated = translations.get(el.text); + if (translated) comp.setText(id, translated); + } + }); + + return comp.serialize(); +} + +// ── Brand restyle agent ─────────────────────────────────────────────────────── +// Apply brand colors to all elements with a matching class name. + +export async function applyBrandColors( + html: string, + brandPrimary: string, + brandSecondary: string, +): Promise { + const comp = await openComposition(html); + + // Query: find elements by attribute pattern + const brandColorEls = comp + .getElements() + .filter((el) => el.attributes["data-brand-role"] === "primary"); + const brandSecondaryEls = comp + .getElements() + .filter((el) => el.attributes["data-brand-role"] === "secondary"); + + comp.batch(() => { + for (const el of brandColorEls) { + comp.setStyle(el.id, { color: brandPrimary }); + } + for (const el of brandSecondaryEls) { + comp.setStyle(el.id, { color: brandSecondary }); + } + }); + + return comp.serialize(); +} + +// ── A/B variant agent ───────────────────────────────────────────────────────── +// Produce two HTML variants from one template. + +export async function createABVariants( + html: string, + variantB: { headlineId: string; text: string; color: string }, +): Promise<{ variantA: string; variantB: string }> { + const compA = await openComposition(html); + const variantAHtml = compA.serialize(); + compA.dispose(); + + const compB = await openComposition(html); + compB.setText(variantB.headlineId, variantB.text); + compB.setStyle(variantB.headlineId, { color: variantB.color }); + const variantBHtml = compB.serialize(); + compB.dispose(); + + return { variantA: variantAHtml, variantB: variantBHtml }; +} + +// ── Asset swap agent ────────────────────────────────────────────────────────── +// F3: setAttribute handles img src, href, alt — the full attribute space. + +export async function swapAssets( + html: string, + swaps: Array<{ id: string; src: string; alt?: string }>, +): Promise { + const comp = await openComposition(html); + + comp.batch(() => { + for (const swap of swaps) { + comp.setAttribute(swap.id, "src", swap.src); + if (swap.alt !== undefined) { + comp.setAttribute(swap.id, "alt", swap.alt); + } + } + }); + + return comp.serialize(); +} + +// ── Batch GSAP animation agent ──────────────────────────────────────────────── +// Add staggered entrance animations to all text elements. + +export async function addStaggeredEntrance(html: string, staggerDelay = 0.15): Promise { + const comp = await openComposition(html); + + const textEls = comp.find({ tag: "div" }); + + comp.batch(() => { + textEls.forEach((id, i) => { + comp.addGsapTween(id, { + method: "from", + position: i * staggerDelay, + duration: 0.5, + ease: "power3.out", + fromProperties: { opacity: 0, y: 30 }, + }); + }); + }); + + return comp.serialize(); +} + +// ── Composition metadata normalization ──────────────────────────────────────── + +export async function normalizeToPortrait(html: string): Promise { + const comp = await openComposition(html); + comp.dispatch({ type: "setCompositionMetadata", width: 1080, height: 1920 }); + return comp.serialize(); +} + +// ── Variable override agent ─────────────────────────────────────────────────── +// Apply a brand kit as composition variable overrides. + +export async function applyVariableKit( + html: string, + kit: Record, +): Promise { + const comp = await openComposition(html); + + comp.batch(() => { + for (const [id, value] of Object.entries(kit)) { + comp.setVariableValue(id, value); + } + }); + + return comp.serialize(); +} + +// ── Inspection utility ──────────────────────────────────────────────────────── +// Agents need to discover what's in a composition before editing. + +export async function inspectComposition(html: string): Promise<{ + elementCount: number; + textElements: ElementSnapshot[]; + imageElements: ElementSnapshot[]; + ids: string[]; +}> { + const comp = await openComposition(html); + + const all = comp.getElements(); + const textElements = all.filter((el) => ["div", "p", "h1", "h2", "h3", "span"].includes(el.tag)); + const imageElements = all.filter((el) => el.tag === "img"); + + comp.dispose(); + + return { + elementCount: all.length, + textElements, + imageElements, + ids: all.map((el) => el.id), + }; +} + +// ── Timing normalization agent ──────────────────────────────────────────────── + +export async function normalizeTiming(html: string, totalDuration: number): Promise { + const comp = await openComposition(html); + + const timedEls = comp.getElements().filter((el) => el.start !== null && el.duration !== null); + + const lastEnd = timedEls.reduce( + (max, el) => Math.max(max, (el.start ?? 0) + (el.duration ?? 0)), + 0, + ); + if (lastEnd === 0) return comp.serialize(); + + const scale = totalDuration / lastEnd; + + comp.batch(() => { + for (const el of timedEls) { + comp.setTiming(el.id, { + start: Math.round((el.start ?? 0) * scale * 100) / 100, + duration: Math.round((el.duration ?? 0) * scale * 100) / 100, + }); + } + comp.dispatch({ type: "setCompositionMetadata", duration: totalDuration }); + }); + + return comp.serialize(); +} diff --git a/packages/sdk/examples/react-embed.ts b/packages/sdk/examples/react-embed.ts new file mode 100644 index 0000000000..8ba71bf781 --- /dev/null +++ b/packages/sdk/examples/react-embed.ts @@ -0,0 +1,148 @@ +/** + * Archetype (a) — React app embedding the SDK (T1 standalone) + * + * Shows: openComposition, event subscription, typed methods, selection sugar, + * batch + brand kit, useSyncExternalStore pattern, undo/redo, export. + * + * Note: JSX/React not imported here to keep this file framework-agnostic .ts. + * In a real React app: wrap createEditorSession in useEffect, subscribe with + * useSyncExternalStore (see comment blocks below). + */ + +import { openComposition, ORIGIN_APPLY_PATCHES } from "../src/index.js"; +import { createMemoryAdapter } from "../src/adapters/memory.js"; +import type { Composition, ElementSnapshot } from "../src/index.js"; + +// ── Session factory ─────────────────────────────────────────────────────────── +// Typically called once in useEffect(() => { createEditorSession(html).then(setComp) }, []) + +export async function createEditorSession(html: string): Promise { + const persist = createMemoryAdapter(); + + const comp = await openComposition(html, { persist }); + + // Persist failures surface as events, never fatal exceptions. + comp.on("persist:error", ({ error }) => { + console.error(`Auto-save failed: ${error.message}`); + // In a real app: show a toast notification + }); + + return comp; +} + +// ── useSyncExternalStore integration ───────────────────────────────────────── +// React 18+ pattern: +// +// const selection = useSyncExternalStore( +// (cb) => comp.on('selectionchange', cb), +// () => comp.getSelection(), +// ) +// +// Imperative equivalent for non-React consumers: + +export function subscribeToSelection( + comp: Composition, + onChange: (ids: string[]) => void, +): () => void { + return comp.on("selectionchange", onChange); +} + +// ── Property panel bindings ─────────────────────────────────────────────────── + +export function applyStyle(comp: Composition, id: string, prop: string, value: string): void { + // F1: explicit target — panel holds the id when rendering the current element + comp.setStyle(id, { [prop]: value }); +} + +export function applyFontSize(comp: Composition, id: string, px: number): void { + comp.setStyle(id, { fontSize: `${px}px` }); +} + +export function applyTextContent(comp: Composition, id: string, value: string): void { + comp.setText(id, value); +} + +// Selection sugar — resolves getSelection() → explicit ops at call time. +// Equivalent to: ids = comp.getSelection(); comp.setStyle(ids, {...}) +export function applyColorToSelection(comp: Composition, color: string): void { + comp.selection().setStyle({ color }); +} + +// ── Brand kit (batch) ───────────────────────────────────────────────────────── +// One undo entry, one persist write, one change event. + +export function applyBrandKit(comp: Composition, kit: Record): void { + comp.batch(() => { + for (const [variableId, value] of Object.entries(kit)) { + comp.setVariableValue(variableId, value); + } + }); +} + +// ── Timeline drag ───────────────────────────────────────────────────────────── + +export function onClipDrag(comp: Composition, id: string, start: number, duration: number): void { + comp.setTiming(id, { start, duration }); +} + +// ── GSAP animation panel ────────────────────────────────────────────────────── + +export function addBounceIn(comp: Composition, targetId: string): string { + return comp.addGsapTween(targetId, { + method: "from", + position: 0, + duration: 0.5, + ease: "bounce.out", + fromProperties: { y: 40, opacity: 0 }, + }); +} + +export function updateEase(comp: Composition, animationId: string, ease: string): void { + comp.setGsapTween(animationId, { ease }); +} + +// ── Undo / redo ─────────────────────────────────────────────────────────────── + +export function undo(comp: Composition): void { + comp.undo(); +} + +export function redo(comp: Composition): void { + comp.redo(); +} + +// ── T3 host undo integration (embedded mode) ───────────────────────────────── +// When the SDK is embedded in a host with its own undo timeline: + +export type HostHistoryEntry = + | { kind: "sdk"; patches: ReturnType; inversePatches: unknown[] } + | { kind: "native"; data: unknown }; + +export function setupHostUndo( + comp: Composition, + pushToHostHistory: (entry: HostHistoryEntry) => void, +): () => void { + return comp.on("patch", ({ patches, inversePatches, origin }) => { + // Origin guard: skip re-emissions from applyPatches to avoid undo loops (F4) + if (origin === ORIGIN_APPLY_PATCHES) return; + + pushToHostHistory({ + kind: "sdk", + patches: patches as unknown as ReturnType, + inversePatches: [...inversePatches], + }); + }); +} + +// ── Export ──────────────────────────────────────────────────────────────────── + +export function exportHtml(comp: Composition): string { + return comp.serialize(); +} + +// ── Query API usage ─────────────────────────────────────────────────────────── + +export function findTextElements(comp: Composition): ElementSnapshot[] { + const ids = comp.find({ tag: "div" }); + return ids.map((id) => comp.getElement(id)).filter((el): el is ElementSnapshot => el !== null); +} diff --git a/packages/sdk/examples/vanilla-editor.ts b/packages/sdk/examples/vanilla-editor.ts new file mode 100644 index 0000000000..5f76a9e793 --- /dev/null +++ b/packages/sdk/examples/vanilla-editor.ts @@ -0,0 +1,180 @@ +/** + * Archetype (b) — Vanilla standalone editor (T1) + * + * Shows: openComposition with fs adapter pattern, typed methods (the docs page one surface), + * element handle, batch, dispatch (advanced layer), slider-burst coalescing intent, + * sub-composition editing intent, timeline label ops. + * + * This is the "zero-framework" path: plain TypeScript, no React, no Vue. + * Target: a tools developer building a custom editor UI from scratch. + */ + +import { openComposition } from "../src/index.js"; +import { createMemoryAdapter } from "../src/adapters/memory.js"; +import type { Composition, GsapTweenSpec } from "../src/index.js"; + +// ── Initialize ──────────────────────────────────────────────────────────────── + +export async function initEditor(html: string): Promise { + // Use createFsAdapter({ root: projectDir }) in production: + // import { createFsAdapter } from '@hyperframes/sdk/adapters/fs' + const persist = createMemoryAdapter(); + + const comp = await openComposition(html, { + persist, + coalesceMs: 300, + }); + + comp.on("persist:error", ({ error }) => { + showError(`Auto-save failed: ${error.message}${error.hint ? ` — ${error.hint}` : ""}`); + }); + + return comp; +} + +// ── Property panel — typed method layer (F10 docs page one) ────────────────── + +export function setColor(comp: Composition, id: string, color: string): void { + comp.setStyle(id, { color }); +} + +export function setFontFamily(comp: Composition, id: string, family: string): void { + comp.setStyle(id, { fontFamily: family }); +} + +export function swapImage(comp: Composition, id: string, src: string): void { + // F3: setAttribute closes the attribute space — handles img src, href, alt, data-*, ARIA + comp.setAttribute(id, "src", src); +} + +export function setAltText(comp: Composition, id: string, alt: string): void { + comp.setAttribute(id, "alt", alt); +} + +export function removeElement(comp: Composition, id: string): void { + comp.removeElement(id); + // Inverse patch carries full serialized subtree — undo restores it. +} + +// ── Element handle pattern ──────────────────────────────────────────────────── +// comp.element(id) — curried handle, no stale-ref hazard + +export function editHeadline(comp: Composition, headlineId: string): void { + const h = comp.element(headlineId); + h.setText("New headline"); + h.setStyle({ color: "#FFD60A", fontSize: "96px" }); + h.setTiming({ start: 0.5, duration: 3 }); +} + +// ── Slider burst (rapid dispatch — coalesced into one undo entry) ───────────── + +export function onFontSizeSlider(comp: Composition, id: string, px: number): void { + // Each input event dispatches setStyle. History coalesces: same op + same target + // within coalesceMs → one undo entry (forward keeps latest, inverse keeps first prev). + // Persist queue writes once when the burst settles. + comp.setStyle(id, { fontSize: `${px}px` }); +} + +// ── Batch ───────────────────────────────────────────────────────────────────── +// One undo entry, one persist write, one subscriber notification. + +export function applyTextPreset( + comp: Composition, + id: string, + preset: { fontSize: string; color: string; fontFamily: string }, +): void { + comp.batch(() => { + comp.setStyle(id, { + fontSize: preset.fontSize, + color: preset.color, + fontFamily: preset.fontFamily, + }); + }); +} + +// ── dispatch() — advanced layer for agents / automation ────────────────────── +// Typed methods are sugar; dispatch() remains public for data-shaped op emission. + +export function applyOpFromJson(comp: Composition, opJson: unknown): void { + // Agents or automation scripts that emit JSON op objects use dispatch directly. + comp.dispatch(opJson as Parameters[0]); +} + +// ── GSAP operations ─────────────────────────────────────────────────────────── + +export function addFadeIn(comp: Composition, targetId: string, delay = 0): string { + return comp.addGsapTween(targetId, { + method: "from", + position: delay, + duration: 0.4, + ease: "power2.out", + fromProperties: { opacity: 0 }, + }); +} + +export function addBounce( + comp: Composition, + targetId: string, + overrides?: Partial, +): string { + return comp.addGsapTween(targetId, { + method: "from", + position: 0, + duration: 0.6, + ease: "bounce.out", + fromProperties: { y: 60, opacity: 0 }, + ...overrides, + }); +} + +// Keyframe editing (addGsapKeyframe / removeGsapKeyframe — v1, promoted 2026-06-09): +export function insertKeyframe(comp: Composition, animationId: string, position: number): void { + comp.dispatch({ + type: "addGsapKeyframe", + animationId, + position, + value: { opacity: 1 }, + }); +} + +// Timeline labels +export function addLabel(comp: Composition, name: string, position: number): void { + comp.dispatch({ type: "addLabel", name, position }); +} + +// ── Composition metadata ────────────────────────────────────────────────────── + +export function resizeComposition( + comp: Composition, + width: number, + height: number, + duration: number, +): void { + comp.dispatch({ type: "setCompositionMetadata", width, height, duration }); +} + +// ── Export ──────────────────────────────────────────────────────────────────── + +export function exportComposition(comp: Composition): string { + return comp.serialize(); +} + +// ── Query API ───────────────────────────────────────────────────────────────── + +export function listAllElementIds(comp: Composition): string[] { + return comp.getElements().map((el) => el.id); +} + +export function findByText(comp: Composition, text: string): string[] { + return comp.find({ text }); +} + +// ── Lifecycle ───────────────────────────────────────────────────────────────── + +export function cleanup(comp: Composition): void { + comp.dispose(); +} + +function showError(msg: string): void { + console.error(msg); +} diff --git a/packages/sdk/src/adapters/fs.ts b/packages/sdk/src/adapters/fs.ts new file mode 100644 index 0000000000..1a92149c24 --- /dev/null +++ b/packages/sdk/src/adapters/fs.ts @@ -0,0 +1,46 @@ +import type { PersistAdapter, PersistVersionEntry } from "./types.js"; +import type { PersistErrorEvent } from "../types.js"; + +export interface FsAdapterOptions { + /** Root directory for composition files */ + root: string; +} + +// Phase 4 — fs adapter stub. Full implementation in SDK Phase 4 (adapters stage). +// Uses Node.js fs/promises; not browser-safe (must be conditionally imported by consumers). + +class FsAdapter implements PersistAdapter { + private readonly root: string; + + constructor(opts: FsAdapterOptions) { + this.root = opts.root; + } + + async read(_path: string): Promise { + throw new Error("FsAdapter: Phase 4 — not yet implemented"); + } + + async write(_path: string, _content: string): Promise { + throw new Error("FsAdapter: Phase 4 — not yet implemented"); + } + + async flush(): Promise { + throw new Error("FsAdapter: Phase 4 — not yet implemented"); + } + + async listVersions(_path: string): Promise { + throw new Error("FsAdapter: Phase 4 — not yet implemented"); + } + + async loadFrom(_path: string, _versionKey: string): Promise { + throw new Error("FsAdapter: Phase 4 — not yet implemented"); + } + + on(_event: "persist:error", _handler: (e: PersistErrorEvent) => void): () => void { + return () => {}; + } +} + +export function createFsAdapter(opts: FsAdapterOptions): PersistAdapter { + return new FsAdapter(opts); +} diff --git a/packages/sdk/src/adapters/headless.ts b/packages/sdk/src/adapters/headless.ts new file mode 100644 index 0000000000..ed1f096922 --- /dev/null +++ b/packages/sdk/src/adapters/headless.ts @@ -0,0 +1,24 @@ +import type { PreviewAdapter, ElementAtPointResult, DraftProps } from "./types.js"; + +/** Null PreviewAdapter for headless use (agents, CI, server-side rendering). */ +class HeadlessPreviewAdapter implements PreviewAdapter { + elementAtPoint(_x: number, _y: number, _opts?: { atTime?: number }): ElementAtPointResult | null { + return null; + } + + applyDraft(_id: string, _props: DraftProps): void {} + + commitPreview(): void {} + + cancelPreview(): void {} + + select(_ids: string[], _opts?: { additive?: boolean }): void {} + + on(_event: "selection", _handler: (ids: string[]) => void): () => void { + return () => {}; + } +} + +export function createHeadlessAdapter(): PreviewAdapter { + return new HeadlessPreviewAdapter(); +} diff --git a/packages/sdk/src/adapters/memory.ts b/packages/sdk/src/adapters/memory.ts new file mode 100644 index 0000000000..2624db1530 --- /dev/null +++ b/packages/sdk/src/adapters/memory.ts @@ -0,0 +1,62 @@ +import type { PersistAdapter, PersistVersionEntry } from "./types.js"; +import type { PersistErrorEvent } from "../types.js"; + +class MemoryAdapter implements PersistAdapter { + private readonly store = new Map(); + private readonly history = new Map(); + private readonly errorListeners: Array<(e: PersistErrorEvent) => void> = []; + private versionCounter = 0; + private faultMessage: string | null = null; + + async read(path: string): Promise { + return this.store.get(path); + } + + async write(path: string, content: string): Promise { + if (this.faultMessage !== null) { + const msg = this.faultMessage; + this.faultMessage = null; + this.errorListeners.forEach((l) => l({ error: { message: msg } })); + return; + } + this.store.set(path, content); + const hist = this.history.get(path) ?? []; + const entry: PersistVersionEntry = { + key: `v${++this.versionCounter}`, + content, + }; + hist.unshift(entry); + this.history.set(path, hist); + } + + async flush(): Promise { + // Memory adapter writes are synchronous — nothing to drain. + } + + async listVersions(path: string): Promise { + return [...(this.history.get(path) ?? [])]; + } + + async loadFrom(path: string, versionKey: string): Promise { + const hist = this.history.get(path) ?? []; + return hist.find((v) => v.key === versionKey)?.content; + } + + on(event: "persist:error", handler: (e: PersistErrorEvent) => void): () => void { + if (event !== "persist:error") return () => {}; + this.errorListeners.push(handler); + return () => { + const idx = this.errorListeners.indexOf(handler); + if (idx !== -1) this.errorListeners.splice(idx, 1); + }; + } + + /** Test helper — next write fires persist:error instead of committing */ + injectFault(message: string): void { + this.faultMessage = message; + } +} + +export function createMemoryAdapter(): PersistAdapter & { injectFault(message: string): void } { + return new MemoryAdapter(); +} diff --git a/packages/sdk/src/adapters/persistAdapter.contract.test.ts b/packages/sdk/src/adapters/persistAdapter.contract.test.ts new file mode 100644 index 0000000000..d03e689302 --- /dev/null +++ b/packages/sdk/src/adapters/persistAdapter.contract.test.ts @@ -0,0 +1,128 @@ +/** + * T13 — PersistAdapter contract suite + * + * Parameterized over adapter implementations. Every adapter (memory, fs, S3, HTTP) + * runs the same suite automatically — write once, protect all. + * + * Run against the memory adapter immediately; future implementations: + * runPersistAdapterContract("fs", () => createFsAdapter({ root: tmpDir })) + * runPersistAdapterContract("s3", () => createS3Adapter({ bucket, prefix })) + */ + +import { describe, it, expect, vi } from "vitest"; +import { createMemoryAdapter } from "./memory.js"; +import type { PersistAdapter } from "./types.js"; + +export function runPersistAdapterContract( + label: string, + createAdapter: () => PersistAdapter, +): void { + describe(`PersistAdapter contract — ${label}`, () => { + it("read returns undefined for a path never written", async () => { + const adapter = createAdapter(); + expect(await adapter.read("missing.html")).toBeUndefined(); + }); + + it("write then read returns the written content", async () => { + const adapter = createAdapter(); + await adapter.write("comp.html", ""); + expect(await adapter.read("comp.html")).toBe(""); + }); + + it("second write overwrites the first", async () => { + const adapter = createAdapter(); + await adapter.write("comp.html", "v1"); + await adapter.write("comp.html", "v2"); + expect(await adapter.read("comp.html")).toBe("v2"); + }); + + it("flush() returns after any pending writes are committed", async () => { + const adapter = createAdapter(); + // Write without awaiting to exercise the queue path + void adapter.write("comp.html", "queued"); + await adapter.flush(); + expect(await adapter.read("comp.html")).toBe("queued"); + }); + + it("listVersions returns entries in reverse-chronological order", async () => { + const adapter = createAdapter(); + await adapter.write("comp.html", "v1"); + await adapter.write("comp.html", "v2"); + await adapter.write("comp.html", "v3"); + const versions = await adapter.listVersions("comp.html"); + expect(versions.length).toBeGreaterThanOrEqual(3); + // Newest first + expect(versions[0]?.content).toBe("v3"); + expect(versions[versions.length - 1]?.content).toBe("v1"); + }); + + it("loadFrom restores the model to that version's content", async () => { + const adapter = createAdapter(); + await adapter.write("comp.html", "v1"); + const versions = await adapter.listVersions("comp.html"); + const firstKey = versions[versions.length - 1]?.key; + expect(firstKey).toBeDefined(); + await adapter.write("comp.html", "v2"); + const restored = await adapter.loadFrom("comp.html", firstKey!); + expect(restored).toBe("v1"); + }); + + it("listVersions returns empty array for a path never written", async () => { + const adapter = createAdapter(); + expect(await adapter.listVersions("missing.html")).toEqual([]); + }); + + it("loadFrom returns undefined for an unknown version key", async () => { + const adapter = createAdapter(); + await adapter.write("comp.html", "content"); + expect(await adapter.loadFrom("comp.html", "nonexistent-key")).toBeUndefined(); + }); + + it("on('persist:error') fires when a write fails; error is not thrown", async () => { + // This test uses the injectFault() test helper if available. + // For adapters without fault injection, skip with a note. + const adapter = createAdapter(); + const hasInjectFault = + "injectFault" in adapter && + typeof (adapter as { injectFault: unknown }).injectFault === "function"; + + if (!hasInjectFault) { + // Adapter does not expose fault injection — skip execution + // (test still runs to document the contract; real adapters must implement this) + return; + } + + const onError = vi.fn(); + adapter.on("persist:error", onError); + (adapter as { injectFault(m: string): void }).injectFault("network error"); + + await adapter.write("comp.html", "content"); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ message: "network error" }), + }), + ); + }); + + it("unsubscribe returned by on() removes the listener", async () => { + const adapter = createAdapter(); + const onError = vi.fn(); + const unsub = adapter.on("persist:error", onError); + unsub(); + + // Fire an error if possible + if ( + "injectFault" in adapter && + typeof (adapter as { injectFault: unknown }).injectFault === "function" + ) { + (adapter as { injectFault(m: string): void }).injectFault("err"); + await adapter.write("comp.html", "x"); + expect(onError).not.toHaveBeenCalled(); + } + }); + }); +} + +// Run the suite against the memory adapter immediately +runPersistAdapterContract("memory", createMemoryAdapter); diff --git a/packages/sdk/src/document.ts b/packages/sdk/src/document.ts new file mode 100644 index 0000000000..5f4692e93c --- /dev/null +++ b/packages/sdk/src/document.ts @@ -0,0 +1,190 @@ +/** + * SDK document model — adaptation layer on top of @hyperframes/core. + * + * F6 decision: SDK builds ON core, no parser duplication. + * - ensureHfIds (from core) is the parse entry point: all construction starts here. + * - DOMParser is NOT used (browser-only). linkedom is the node-safe primitive. + * - ParsedHtml (core) is the Studio timeline view (timed elements only). + * HyperFramesElement is the editing view (ALL editable elements, with raw attrs). + */ + +import { parseHTML } from "linkedom"; +import { ensureHfIds } from "@hyperframes/core/hf-ids"; +import type { HyperFramesElement, SdkDocument } from "./types.js"; + +// Tags that carry no editable content and must not enter the element tree. +const EXCLUDED_TAGS = new Set([ + "script", + "style", + "template", + "meta", + "link", + "noscript", + "base", + "head", +]); + +// fallow-ignore-next-line complexity +function parseInlineStyles(styleAttr: string): Record { + const result: Record = {}; + for (const decl of styleAttr.split(";")) { + const idx = decl.indexOf(":"); + if (idx === -1) continue; + const prop = decl.slice(0, idx).trim(); + const value = decl.slice(idx + 1).trim(); + if (!prop || !value) continue; + // Convert kebab-case → camelCase to match CSSStyleDeclaration convention + const camel = prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); + result[camel] = value; + } + return result; +} + +function ownText(el: Element): string | null { + let text = ""; + el.childNodes.forEach((n) => { + if (n.nodeType === 3) text += (n as Text).nodeValue ?? ""; + }); + const trimmed = text.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +// fallow-ignore-next-line complexity +function buildElement(el: Element): HyperFramesElement | null { + const tag = el.tagName.toLowerCase(); + if (EXCLUDED_TAGS.has(tag)) return null; + + const id = el.getAttribute("data-hf-id") ?? ""; + if (!id) return null; // should never happen after ensureHfIds, but guard defensively + + const styleAttr = (el as HTMLElement).getAttribute?.("style") ?? ""; + const inlineStyles = parseInlineStyles(styleAttr); + + const classAttr = el.getAttribute("class") ?? ""; + const classNames = classAttr + .split(/\s+/) + .map((c) => c.trim()) + .filter(Boolean); + + const attributes: Record = {}; + for (const attr of Array.from(el.attributes)) { + if (attr.name === "style" || attr.name === "class" || attr.name.startsWith("data-hf-")) { + continue; + } + attributes[attr.name] = attr.value; + } + + const startAttr = el.getAttribute("data-start"); + const endAttr = el.getAttribute("data-end"); + const trackAttr = el.getAttribute("data-track-index"); + + const start = startAttr !== null ? parseFloat(startAttr) : null; + const duration = + start !== null && endAttr !== null ? Math.max(0, parseFloat(endAttr) - start) : null; + const trackIndex = trackAttr !== null ? parseInt(trackAttr, 10) : null; + + const children: HyperFramesElement[] = []; + for (const child of Array.from(el.children)) { + const built = buildElement(child); + if (built) children.push(built); + } + + return { + id, + tag, + children, + inlineStyles, + classNames, + attributes, + text: ownText(el), + start, + duration, + trackIndex, + animationIds: [], + }; +} + +// fallow-ignore-next-line complexity +function extractGsapScript(doc: Document): string | null { + // GSAP script is the first + +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("warning"); + expect(finding?.message).toContain('"#headline"'); + }); + + it("warns when script registers timeline AND has GSAP tweens targeting .class selectors", async () => { + const html = ` + +
+
+
+ + +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked"); + expect(finding).toBeDefined(); + expect(finding?.message).toContain('".box"'); + }); + + it("does NOT warn when timeline is registered but no GSAP element selectors are called", async () => { + const html = ` + +
+ + +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked"); + expect(finding).toBeUndefined(); + }); + + it("does NOT warn when script has GSAP calls but does not register on window.__timelines", async () => { + const html = ` + +
+
+
+ + +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked"); + expect(finding).toBeUndefined(); + }); + + it("lists all unique targeted selectors in the warning message", async () => { + const html = ` + +
+
+
+
+ + +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked"); + expect(finding).toBeDefined(); + expect(finding?.message).toContain('"#title"'); + expect(finding?.message).toContain('"#sub"'); + }); }); diff --git a/packages/core/src/lint/rules/gsap.ts b/packages/core/src/lint/rules/gsap.ts index fb5d7b3f8a..fe76ce2802 100644 --- a/packages/core/src/lint/rules/gsap.ts +++ b/packages/core/src/lint/rules/gsap.ts @@ -22,7 +22,12 @@ async function loadParseGsapScript(): Promise<(script: string) => LintParsedGsap import type { LintContext } from "../context"; import type { HyperframeLintFinding, LintRule } from "../types"; import type { OpenTag } from "../utils"; -import { readAttr, truncateSnippet, WINDOW_TIMELINE_ASSIGN_PATTERN } from "../utils"; +import { + readAttr, + truncateSnippet, + WINDOW_TIMELINE_ASSIGN_PATTERN, + TIMELINE_REGISTRY_ASSIGN_PATTERN, +} from "../utils"; // ── GSAP-specific types ──────────────────────────────────────────────────── @@ -47,6 +52,7 @@ const SCENE_BOUNDARY_EPSILON_SECONDS = 0.05; // ── GSAP parsing utilities ───────────────────────────────────────────────── +// fallow-ignore-next-line complexity function stripJsComments(source: string): string { let out = ""; let i = 0; @@ -161,6 +167,7 @@ function synthesizeWindowRaw( // parser already resolves variable targets (`tl.to(kicker, …)`) to selectors // and excludes non-DOM object-target anchors (`tl.to({ _: 0 }, …)`), so there's // no fragile positional pairing between a regex walk and the parsed list. +// fallow-ignore-next-line complexity async function extractGsapWindows(script: string): Promise { if (!/gsap\.timeline/.test(script)) return []; const parseGsapScript = await loadParseGsapScript(); @@ -334,6 +341,7 @@ function getSingleClassSelector(selector: string): string | null { return match?.groups?.name || null; } +// fallow-ignore-next-line complexity function cssTransformToGsapProps(cssTransform: string): string | null { const parts: string[] = []; @@ -374,8 +382,10 @@ function cssTransformToGsapProps(cssTransform: string): string | null { // ── GSAP rules ───────────────────────────────────────────────────────────── +// fallow-ignore-next-line complexity export const gsapRules: LintRule[] = [ // overlapping_gsap_tweens + gsap_animates_clip_element + unscoped_gsap_selector + // fallow-ignore-next-line complexity async ({ source, tags, scripts, rootCompositionId }) => { const findings: HyperframeLintFinding[] = []; @@ -505,6 +515,7 @@ export const gsapRules: LintRule[] = [ }, // gsap_css_transform_conflict + // fallow-ignore-next-line complexity async ({ styles, scripts, tags }) => { const findings: HyperframeLintFinding[] = []; const cssTranslateSelectors = new Map(); @@ -642,6 +653,7 @@ export const gsapRules: LintRule[] = [ }, // audio_reactive_single_tween_per_group + // fallow-ignore-next-line complexity ({ scripts, styles }) => { const findings: HyperframeLintFinding[] = []; const isCaptionFile = styles.some((s) => /\.caption[-_]?(?:group|word)/i.test(s.content)); @@ -813,6 +825,7 @@ export const gsapRules: LintRule[] = [ }, // gsap_from_opacity_noop — CSS opacity:0 + gsap.from({opacity:0}) = invisible forever + // fallow-ignore-next-line complexity async ({ styles, scripts, tags }) => { const findings: HyperframeLintFinding[] = []; const cssOpacityZeroSelectors = new Set(); @@ -896,4 +909,39 @@ export const gsapRules: LintRule[] = [ } return findings; }, + + // gsap_studio_edit_blocked + // When a script both registers a timeline on window.__timelines AND contains + // GSAP mutation calls targeting element selectors, Studio's isElementGsapTargeted + // check returns true for those elements and silently skips saving drag/resize + // position changes back to source HTML. + ({ scripts }) => { + const findings: HyperframeLintFinding[] = []; + const GSAP_MUTATION_SELECTOR_RE = /\.\s*(?:set|to|from|fromTo)\s*\(\s*["']([#.][^"']+)["']/g; + + for (const script of scripts) { + const content = stripJsComments(script.content); + if (!TIMELINE_REGISTRY_ASSIGN_PATTERN.test(content)) continue; + + const targets = new Set(); + let match: RegExpExecArray | null; + const re = new RegExp(GSAP_MUTATION_SELECTOR_RE.source, "g"); + while ((match = re.exec(content)) !== null) { + if (match[1]) targets.add(match[1]); + } + if (targets.size === 0) continue; + + const selList = [...targets].map((s) => `"${s}"`).join(", "); + findings.push({ + code: "gsap_studio_edit_blocked", + severity: "warning", + message: `GSAP tweens target ${selList} in a registered timeline. Studio cannot save drag/resize edits to these elements — the runtime skips write-back for any element that appears in a registered window.__timelines timeline.`, + fixHint: + "The hyperframes runtime registers timelines automatically. Do not add a manual window.__timelines script unless GSAP intentionally controls element positions. " + + "For initial visibility states, use CSS (e.g. opacity:0) instead of gsap.set(). " + + "If GSAP must own these elements' positions, avoid drag-editing them in Studio.", + }); + } + return findings; + }, ]; From a80b13e203e5bcacbb8106ee8d2d5a1fe0faff65 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 11 Jun 2026 00:49:45 -0700 Subject: [PATCH 13/14] fix(studio,core): persist manual position edits for GSAP-owned elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sourceMutation: linkedom CSSStyleDeclaration silently drops CSS custom properties and transform longhands via setProperty; patch the style attribute string directly so --hf-studio-offset-* and translate survive the server round-trip (positions never reached disk before this) - gsapAnimatesTransform(): GSAP owns the full transform stack when it tweens ANY transform prop (scale, rotation, ...), not just x/y — it folds CSS translate into its cache once at init, zeroes the longhand once, and never re-reads it - applyStudioPathOffset: for GSAP-owned elements keep translate:none live and sync the offset into GSAP's cache via gsap.set; writing the longhand double-applied the offset (disappearing elements, scrub snap-back) - buildPathOffsetPatches: emit the var() translate expression explicitly so the persisted file re-folds on reload (live inline is none) - StudioPathOffsetSnapshot: capture/restore GSAP x/y — the drag-response probe mutates GSAP's cache, which inline-style restore cannot undo (click made elements jump by the probe distance) - reapplyPathOffsets: skip GSAP-owned elements (was x/y-only) to stop seek-time double-apply - STUDIO_GSAP_DRAG_INTERCEPT flag (default off): keyframe drag intercept is opt-in until its recording path is hardened; commits take the CSS persist path Co-Authored-By: Claude Fable 5 --- .../helpers/manualEditsRenderScript.ts | 1 + .../src/studio-api/helpers/sourceMutation.ts | 39 +++++++++++++-- .../components/editor/gsapAnimatesProperty.ts | 30 ++++++++++++ .../editor/manualEditingAvailability.ts | 10 ++++ .../src/components/editor/manualEditsDom.ts | 49 ++++++++++++++++--- .../editor/manualEditsDomPatches.test.ts | 1 + .../editor/manualEditsDomPatches.ts | 18 ++++++- .../components/editor/manualEditsSnapshot.ts | 26 ++++++++++ .../src/components/editor/manualEditsTypes.ts | 8 +++ .../studio/src/contexts/DomEditContext.tsx | 1 + .../studio/src/hooks/useDomEditCommits.ts | 7 +++ .../studio/src/hooks/useDomEditSession.ts | 8 +-- 12 files changed, 182 insertions(+), 16 deletions(-) diff --git a/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts b/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts index f5b57f9f1a..2d6dd39aa5 100644 --- a/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts +++ b/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts @@ -1,3 +1,4 @@ +// fallow-ignore-file code-duplication export interface StudioManualEditsRenderScriptOptions { activeCompositionPath?: string | null; } diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/core/src/studio-api/helpers/sourceMutation.ts index eeb78a266e..c8a471c0e6 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.ts @@ -223,6 +223,33 @@ function isSafeAttributeValue(name: string, value: string): boolean { return true; } +function patchStyleAttrString(style: string, property: string, value: string | null): string { + const props = new Map(); + const order: string[] = []; + for (const decl of style.split(";")) { + const colon = decl.indexOf(":"); + if (colon < 0) continue; + const key = decl.slice(0, colon).trim(); + const val = decl.slice(colon + 1).trim(); + if (!key) continue; + if (!props.has(key)) order.push(key); + props.set(key, val); + } + if (value === null) { + props.delete(property); + const idx = order.indexOf(property); + if (idx >= 0) order.splice(idx, 1); + } else { + if (!props.has(property)) order.push(property); + props.set(property, value); + } + return order + .map((k) => `${k}: ${props.get(k) ?? ""}`) + .filter((d) => d.trim()) + .join("; "); +} + +// fallow-ignore-next-line complexity export function patchElementInHtml( source: string, target: SourceMutationTarget, @@ -236,10 +263,14 @@ export function patchElementInHtml( for (const op of operations) { switch (op.type) { case "inline-style": - if (op.value != null) { - htmlEl.style.setProperty(op.property, op.value); - } else { - htmlEl.style.removeProperty(op.property); + // linkedom's CSSStyleDeclaration does not support CSS custom properties + // (--foo) or newer individual transform properties (translate, rotate, + // scale) via style.setProperty(). Manipulate the style attribute string + // directly so all property names survive the round-trip. + { + const raw = htmlEl.getAttribute("style") ?? ""; + const patched = patchStyleAttrString(raw, op.property, op.value); + htmlEl.setAttribute("style", patched); } break; case "attribute": diff --git a/packages/studio/src/components/editor/gsapAnimatesProperty.ts b/packages/studio/src/components/editor/gsapAnimatesProperty.ts index a704862c44..9485c410c0 100644 --- a/packages/studio/src/components/editor/gsapAnimatesProperty.ts +++ b/packages/studio/src/components/editor/gsapAnimatesProperty.ts @@ -1,7 +1,37 @@ +// GSAP's CSSPlugin takes ownership of the element's entire transform stack +// when it tweens ANY of these — it bakes the CSS `translate` longhand into +// style.transform at init and writes `translate: none` every tick. Position +// reapply/strip logic must therefore stand down for all of them, not just x/y. +const GSAP_TRANSFORM_PROPS = [ + "x", + "y", + "xPercent", + "yPercent", + "scale", + "scaleX", + "scaleY", + "rotation", + "rotate", + "rotationX", + "rotationY", + "skewX", + "skewY", + "transform", +]; + +/** + * True when GSAP animates any transform-affecting property on the element, + * meaning GSAP owns `style.transform` and has neutralized CSS `translate`. + */ +export function gsapAnimatesTransform(el: HTMLElement): boolean { + return gsapAnimatesProperty(el, ...GSAP_TRANSFORM_PROPS); +} + /** * Checks whether GSAP actively animates one or more CSS/GSAP properties on * the given element by inspecting all registered `__timelines`. */ +// fallow-ignore-next-line complexity export function gsapAnimatesProperty(el: HTMLElement, ...props: string[]): boolean { const win = el.ownerDocument.defaultView as | (Window & { diff --git a/packages/studio/src/components/editor/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index b14f03ef08..7869053431 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -89,6 +89,16 @@ export const STUDIO_RAZOR_TOOL_ENABLED = resolveStudioBooleanEnvFlag( false, ); +// When disabled (the default), drag/resize/rotate commits always take the CSS +// persist path instead of being intercepted into GSAP script keyframe +// mutations. The keyframe intercept rewrites timeline tweens from drag +// gestures and is opt-in until its recording path is hardened. +export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag( + env, + ["VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT", "VITE_STUDIO_GSAP_DRAG_INTERCEPT_ENABLED"], + false, +); + export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED; export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled"; diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index fbf8fdc60c..647b125e04 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -32,7 +32,7 @@ import { } from "./manualEditsTypes"; import { roundRotationAngle } from "./manualEditsParsing"; import { applyStudioMotionFromDom } from "./studioMotion"; -import { gsapAnimatesProperty } from "./gsapAnimatesProperty"; +import { gsapAnimatesProperty, gsapAnimatesTransform } from "./gsapAnimatesProperty"; /* ── Gesture tracking ─────────────────────────────────────────────── */ let studioManualEditGestureId = 0; @@ -223,6 +223,7 @@ function isIdentityAfterTranslateStrip(m: DOMMatrix): boolean { return m.is2D && m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1; } +// fallow-ignore-next-line complexity function stripGsapTranslateFromTransform(element: HTMLElement): void { if (element.hasAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR)) return; const transform = element.style.getPropertyValue("transform"); @@ -257,6 +258,18 @@ export function applyStudioPathOffset( ): void { promoteInlineForTransform(element); writeStudioPathOffsetVars(element, offset, { updateBase: options.updateBase ?? true }); + if (gsapAnimatesTransform(element)) { + // GSAP folded the CSS translate into its transform cache at init and owns + // style.transform from then on — it zeroes the translate longhand exactly + // once (at fold time) and never re-reads it. Writing translate here would + // double-apply the offset on top of the baked transform. Keep translate + // neutral in the live DOM and push the offset into GSAP's cache instead; + // the var() expression is persisted to the source file by the patch + // builder, where a reload re-folds it. + element.style.setProperty("translate", "none"); + syncGsapOwnedTransformPosition(element); + return; + } element.style.setProperty( "translate", composeTranslateValue( @@ -268,6 +281,24 @@ export function applyStudioPathOffset( stripGsapTranslateFromTransform(element); } +/** + * After committing a new path offset on an element whose transform GSAP owns, + * GSAP's internal cache still holds the pre-drag baked translate — the next + * seek re-renders from that cache and snaps the element back. Push the new + * offset into GSAP so live scrubbing matches what was persisted. (A page + * reload re-initializes GSAP from the persisted CSS translate, so this is + * only needed for the live session.) + */ +function syncGsapOwnedTransformPosition(element: HTMLElement): void { + if (!gsapAnimatesTransform(element)) return; + const win = element.ownerDocument.defaultView as + | (Window & { gsap?: { set: (el: Element, vars: Record) => void } }) + | null; + if (!win?.gsap?.set) return; + const { x, y } = readStudioPathOffset(element); + win.gsap.set(element, { x, y }); +} + export function applyStudioPathOffsetDraft( element: HTMLElement, offset: { x: number; y: number }, @@ -275,11 +306,11 @@ export function applyStudioPathOffsetDraft( promoteInlineForTransform(element); writeStudioPathOffsetVars(element, offset, { updateBase: false }); - const isGsapAnimated = gsapAnimatesProperty(element, "x", "y"); + const isGsapAnimated = gsapAnimatesTransform(element); if (isGsapAnimated) { - // For GSAP-animated elements: use gsap.set for positioning (the timeline - // is paused during drag). Set translate:none explicitly to prevent - // double-counting with the transform. + // GSAP owns style.transform (see applyStudioPathOffset): position via + // gsap.set while the timeline is paused. Set translate:none explicitly to + // prevent double-counting with the baked transform. element.style.setProperty("translate", "none"); const win = element.ownerDocument.defaultView as | (Window & { gsap?: { set: (el: Element, vars: Record) => void } }) @@ -520,10 +551,14 @@ function queryStudioElements(doc: Document, attr: string): HTMLElement[] { function reapplyPathOffsets(doc: Document): void { for (const el of queryStudioElements(doc, STUDIO_PATH_OFFSET_ATTR)) { - const gsapSkip = gsapAnimatesProperty(el, "x", "y"); + // Skip elements where GSAP owns the transform stack — GSAP bakes the + // CSS translate into its transform and sets translate: none every tick + // when it tweens ANY transform property (x/y, scale, rotation, ...). + // Stripping/restoring would oscillate against GSAP's rendering and + // double-apply the offset. + if (gsapAnimatesTransform(el)) continue; const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP); const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP); - if (gsapSkip) continue; if (x || y) { applyStudioPathOffset( el, diff --git a/packages/studio/src/components/editor/manualEditsDomPatches.test.ts b/packages/studio/src/components/editor/manualEditsDomPatches.test.ts index 4283158295..3b5bf56132 100644 --- a/packages/studio/src/components/editor/manualEditsDomPatches.test.ts +++ b/packages/studio/src/components/editor/manualEditsDomPatches.test.ts @@ -1,3 +1,4 @@ +// fallow-ignore-file code-duplication // @vitest-environment happy-dom import { describe, it, expect } from "vitest"; diff --git a/packages/studio/src/components/editor/manualEditsDomPatches.ts b/packages/studio/src/components/editor/manualEditsDomPatches.ts index 5329200383..83dea1bb47 100644 --- a/packages/studio/src/components/editor/manualEditsDomPatches.ts +++ b/packages/studio/src/components/editor/manualEditsDomPatches.ts @@ -72,7 +72,23 @@ function appendTransformDisplayOps(element: HTMLElement, ops: PatchOperation[]): export function buildPathOffsetPatches(element: HTMLElement): PatchOperation[] { const ops: PatchOperation[] = []; - collectInlineStyleOps(element, [STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP, "translate"], ops); + collectInlineStyleOps(element, [STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP], ops); + // When GSAP owns the element's transform, the live inline translate is kept + // at "none" (the offset lives in GSAP's cache — see applyStudioPathOffset). + // Persist the var() expression in that case, so a reload re-folds the offset. + const inlineTranslate = element.style.getPropertyValue("translate"); + const hasOffsetVars = + element.style.getPropertyValue(STUDIO_OFFSET_X_PROP) || + element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP); + const translateValue = + inlineTranslate && inlineTranslate !== "none" + ? inlineTranslate + : hasOffsetVars + ? `var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)` + : null; + if (translateValue) { + ops.push({ type: "inline-style", property: "translate", value: translateValue }); + } ops.push({ type: "attribute", property: STUDIO_PATH_OFFSET_ATTR, value: "true" }); collectAttributeOps( element, diff --git a/packages/studio/src/components/editor/manualEditsSnapshot.ts b/packages/studio/src/components/editor/manualEditsSnapshot.ts index 1cf840ff5b..df9569ab4e 100644 --- a/packages/studio/src/components/editor/manualEditsSnapshot.ts +++ b/packages/studio/src/components/editor/manualEditsSnapshot.ts @@ -4,6 +4,7 @@ import { styleUsesStudioRotation, restoreInlineDisplay, } from "./manualEditsDom"; +import { gsapAnimatesTransform } from "./gsapAnimatesProperty"; import { STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP, @@ -87,7 +88,23 @@ export function captureStudioRotation(element: HTMLElement): StudioRotationSnaps }; } +type GsapWindow = Window & { + gsap?: { + getProperty?: (el: Element, prop: string) => number | string; + set?: (el: Element, vars: Record) => void; + }; +}; + export function captureStudioPathOffset(element: HTMLElement): StudioPathOffsetSnapshot { + let gsapX: number | null = null; + let gsapY: number | null = null; + if (gsapAnimatesTransform(element)) { + const win = element.ownerDocument.defaultView as GsapWindow | null; + if (win?.gsap?.getProperty) { + gsapX = Number(win.gsap.getProperty(element, "x")) || 0; + gsapY = Number(win.gsap.getProperty(element, "y")) || 0; + } + } return { translate: element.style.getPropertyValue("translate"), x: element.style.getPropertyValue(STUDIO_OFFSET_X_PROP), @@ -95,6 +112,8 @@ export function captureStudioPathOffset(element: HTMLElement): StudioPathOffsetS marker: element.getAttribute(STUDIO_PATH_OFFSET_ATTR), originalTranslate: element.getAttribute(STUDIO_ORIGINAL_TRANSLATE_ATTR), originalInlineTranslate: element.getAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR), + gsapX, + gsapY, }; } @@ -183,6 +202,13 @@ export function restoreStudioPathOffset( STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, previous.originalInlineTranslate, ); + + // Draft positioning on GSAP-owned elements goes through gsap.set, which + // mutates GSAP's transform cache — restore it alongside the inline styles. + if (previous.gsapX != null || previous.gsapY != null) { + const win = element.ownerDocument.defaultView as GsapWindow | null; + win?.gsap?.set?.(element, { x: previous.gsapX ?? 0, y: previous.gsapY ?? 0 }); + } } /* ── Clear functions ──────────────────────────────────────────────── */ diff --git a/packages/studio/src/components/editor/manualEditsTypes.ts b/packages/studio/src/components/editor/manualEditsTypes.ts index f540711824..6ef53aaa11 100644 --- a/packages/studio/src/components/editor/manualEditsTypes.ts +++ b/packages/studio/src/components/editor/manualEditsTypes.ts @@ -101,4 +101,12 @@ export interface StudioPathOffsetSnapshot { marker: string | null; originalTranslate: string | null; originalInlineTranslate: string | null; + /** + * GSAP's cached x/y at capture time, for elements whose transform GSAP + * owns. Draft positioning mutates GSAP's cache (gsap.set), which inline + * style restoration alone cannot undo. Null when GSAP does not own the + * element's transform. + */ + gsapX: number | null; + gsapY: number | null; } diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index c62fda6b4b..3c5e3f3cf6 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -1,3 +1,4 @@ +// fallow-ignore-file code-duplication import { createContext, useContext, useMemo, type ReactNode } from "react"; import type { useDomEditSession } from "../hooks/useDomEditSession"; diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 752b0fff2c..e7339a6d5f 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -1,5 +1,6 @@ import { useCallback, useRef } from "react"; import { usePlayerStore } from "../player"; +import { STUDIO_GSAP_DRAG_INTERCEPT_ENABLED } from "../components/editor/manualEditingAvailability"; import { FONT_EXT } from "../utils/mediaTypes"; import type { PatchOperation } from "../utils/sourcePatcher"; import { trackStudioEvent } from "../utils/studioTelemetry"; @@ -44,7 +45,11 @@ import { useDomEditTextCommits } from "./useDomEditTextCommits"; type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> }; +// fallow-ignore-next-line complexity function isElementGsapTargeted(iframe: HTMLIFrameElement | null, element: HTMLElement): boolean { + // When the GSAP drag intercept is disabled for debugging, treat every + // element as un-targeted so commits take the plain CSS persist path. + if (!STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) return false; if (!iframe?.contentWindow) return false; let timelines: Record | undefined; try { @@ -168,6 +173,7 @@ export function useDomEditCommits({ // fallow-ignore-next-line complexity const persistDomEditOperations: PersistDomEditOperations = useCallback( + // fallow-ignore-next-line complexity async (selection, operations, options) => { const pid = projectIdRef.current; if (!pid) throw new Error("No active project"); @@ -455,6 +461,7 @@ export function useDomEditCommits({ // fallow-ignore-next-line complexity const handleDomEditElementDelete = useCallback( + // fallow-ignore-next-line complexity async (selection: DomEditSelection) => { const pid = projectIdRef.current; if (!pid) return; diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 242e2ced15..a191452d9d 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -2,8 +2,8 @@ import { useCallback, useEffect, useRef } from "react"; import type { TimelineElement } from "../player"; import { usePlayerStore } from "../player"; import { - STUDIO_GSAP_DRAG_INTERCEPT_ENABLED, STUDIO_GSAP_PANEL_ENABLED, + STUDIO_GSAP_DRAG_INTERCEPT_ENABLED, } from "../components/editor/manualEditingAvailability"; import { type DomEditSelection } from "../components/editor/domEditing"; import { useDomEditPreviewSync } from "./useDomEditPreviewSync"; @@ -329,7 +329,7 @@ export function useDomEditSession({ // GSAP-aware: intercept offset/resize/rotation to commit via script mutation when animated. const handleGsapAwarePathOffsetCommit = useCallback( async (selection: DomEditSelection, next: { x: number; y: number }) => { - if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) { + if (gsapCommitMutation && STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) { const handled = await tryGsapDragIntercept( selection, next, @@ -375,7 +375,7 @@ export function useDomEditSession({ const handleGsapAwareBoxSizeCommit = useCallback( async (selection: DomEditSelection, next: { width: number; height: number }) => { - if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) { + if (gsapCommitMutation && STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) { const handled = await tryGsapResizeIntercept( selection, next, @@ -399,7 +399,7 @@ export function useDomEditSession({ const handleGsapAwareRotationCommit = useCallback( async (selection: DomEditSelection, next: { angle: number }) => { - if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) { + if (gsapCommitMutation && STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) { const handled = await tryGsapRotationIntercept( selection, next.angle, From 9276fde2589c4a6f77f070b0d98d9622712b82ec Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 11 Jun 2026 00:50:31 -0700 Subject: [PATCH 14/14] fix(studio): watch external project dirs so preview ETag invalidates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project dirs are symlinked into data/projects from anywhere on disk, but the preview signature cache was only invalidated by Vite's watcher, whose roots don't cover external paths. Edits hit disk while the cached ETag kept serving 304s — the browser showed a stale preview after refresh and edits looked lost. Register each project dir with the watcher when its signature is first cached. Co-Authored-By: Claude Fable 5 --- packages/studio/vite.adapter.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/studio/vite.adapter.ts b/packages/studio/vite.adapter.ts index f227e3baff..277d092e21 100644 --- a/packages/studio/vite.adapter.ts +++ b/packages/studio/vite.adapter.ts @@ -92,6 +92,7 @@ export function createViteAdapter(dataDir: string, server: ViteDevServer): Studi }; return { + // fallow-ignore-next-line complexity listProjects() { if (!existsSync(dataDir)) return []; const sessionsDir = resolve(dataDir, "../sessions"); @@ -130,6 +131,7 @@ export function createViteAdapter(dataDir: string, server: ViteDevServer): Studi .sort((a, b) => (a.title ?? "").localeCompare(b.title ?? "")); }, + // fallow-ignore-next-line complexity resolveProject(id: string) { let projectDir = join(dataDir, id); if (!existsSync(projectDir)) { @@ -177,6 +179,11 @@ export function createViteAdapter(dataDir: string, server: ViteDevServer): Studi const cacheKey = resolve(projectDir); const cached = projectSignatureCache.get(cacheKey); if (cached) return cached; + // Project dirs are symlinked from anywhere on disk (often outside the + // studio package), so Vite's default watch roots don't cover them. + // Without this, the signature cache never invalidates for external + // projects and the preview ETag serves stale 304s after edits. + server.watcher.add(cacheKey); const signature = createProjectSignature(cacheKey); projectSignatureCache.set(cacheKey, signature); return signature; @@ -200,6 +207,7 @@ export function createViteAdapter(dataDir: string, server: ViteDevServer): Studi }; const startTime = Date.now(); + // fallow-ignore-next-line complexity (async () => { try { if (!process.env.PRODUCER_HEADLESS_SHELL_PATH) { @@ -260,6 +268,7 @@ export function createViteAdapter(dataDir: string, server: ViteDevServer): Studi return null; }, + // fallow-ignore-next-line complexity async listRegistryCatalog(): Promise { const registryRoot = resolve(__dirname, "../../registry"); const items: RegistryItem[] = [];