feat(sdk): scaffold @hyperframes/sdk — engine layer (model, RFC 6902 patches, mutate, apply-patches) (#1324)

* feat(sdk): scaffold @hyperframes/sdk — engine layer (model, RFC 6902 patches, mutate, apply-patches)

* fix(sdk): make engine-layer PR self-contained — trim index.ts, guard indexed access

- 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 <noreply@anthropic.com>

* fix(sdk): fail loudly on Phase 3b ops; add sdk to root build pipeline

- 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 <noreply@anthropic.com>

* fix(sdk): cross-realm origin sentinel, dual width/height channel, contract docs

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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* chore: update bun.lock after sdk package registration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-06-11 12:19:51 -07:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent 2c64f99694
commit 22bb6737c5
16 changed files with 1841 additions and 11 deletions
+1
View File
@@ -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
+25 -10
View File
@@ -22,7 +22,7 @@
},
"packages/aws-lambda": {
"name": "@hyperframes/aws-lambda",
"version": "0.6.88",
"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.88",
"version": "0.6.90",
"bin": {
"hyperframes": "./dist/cli.js",
},
@@ -101,7 +101,7 @@
},
"packages/core": {
"name": "@hyperframes/core",
"version": "0.6.88",
"version": "0.6.90",
"dependencies": {
"@babel/parser": "^7.27.0",
"@chenglou/pretext": "^0.0.5",
@@ -131,7 +131,7 @@
},
"packages/engine": {
"name": "@hyperframes/engine",
"version": "0.6.88",
"version": "0.6.90",
"dependencies": {
"@hono/node-server": "^1.13.0",
"@hyperframes/core": "workspace:^",
@@ -149,7 +149,7 @@
},
"packages/gcp-cloud-run": {
"name": "@hyperframes/gcp-cloud-run",
"version": "0.6.88",
"version": "0.6.90",
"dependencies": {
"@google-cloud/storage": "^7.14.0",
"@google-cloud/workflows": "^4.2.0",
@@ -169,7 +169,7 @@
},
"packages/player": {
"name": "@hyperframes/player",
"version": "0.6.88",
"version": "0.6.90",
"devDependencies": {
"@types/bun": "^1.1.0",
"gsap": "^3.12.5",
@@ -181,7 +181,7 @@
},
"packages/producer": {
"name": "@hyperframes/producer",
"version": "0.6.88",
"version": "0.6.90",
"dependencies": {
"@fontsource/archivo-black": "^5.2.8",
"@fontsource/eb-garamond": "^5.2.7",
@@ -220,9 +220,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.90",
"dependencies": {
"html2canvas": "^1.4.1",
},
@@ -234,7 +247,7 @@
},
"packages/studio": {
"name": "@hyperframes/studio",
"version": "0.6.88",
"version": "0.6.90",
"dependencies": {
"@codemirror/autocomplete": "^6.20.1",
"@codemirror/commands": "^6.10.3",
@@ -631,6 +644,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 +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@7.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Wjvt4scRFouioIInHf51IFNP4ltJ2EngJM+cZPGiqbKetBfmP3vpdPV8ID2S6JS6/jdo74N8+aEYH9lQr2C6sA=="],
"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=="],
+1 -1
View File
@@ -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",
+73
View File
@@ -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"
}
}
+75
View File
@@ -0,0 +1,75 @@
// Consumed by session.ts + adapter implementations in the next stacked PR (#1325).
// fallow-ignore-file unused-file
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<string | undefined>;
write(path: string, content: string): Promise<void>;
/** Force all pending writes to commit before returning */
flush(): Promise<void>;
listVersions(path: string): Promise<PersistVersionEntry[]>;
loadFrom(path: string, versionKey: string): Promise<string | undefined>;
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;
}
+197
View File
@@ -0,0 +1,197 @@
/**
* 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).
*
* 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";
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;
// 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 });
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 });
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));
}
break;
}
}
}
+141
View File
@@ -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 <html> 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 = /<!doctype|<html[\s>]/i.test(stamped);
const wrapped = !hasShell;
const { document } = wrapped
? parseHTML(`<!DOCTYPE html><html><head></head><body>${stamped}</body></html>`)
: 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<string, string> {
const result: Record<string, string> = {};
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, string>): string {
return Object.entries(styles)
.map(([k, v]) => `${toKebab(k)}: ${v}`)
.join("; ");
}
export function getElementStyles(el: Element): Record<string, string> {
const attr = el.getAttribute("style") ?? "";
return parseStyleAttr(attr);
}
export function setElementStyles(el: Element, updates: Record<string, string | null>): 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);
}
+436
View File
@@ -0,0 +1,436 @@
/**
* 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 = `
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px; background: #000" data-duration="5">
<h1 data-hf-id="hf-title" data-start="0" data-end="3" data-track-index="0"
style="color: #fff; font-size: 64px">Hello World</h1>
<img data-hf-id="hf-logo" src="/logo.png" alt="Logo" />
<div data-hf-id="hf-sub">
<span data-hf-id="hf-span" style="opacity: 0.5">sub text</span>
</div>
</div>
`.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 data-x and data-y attributes (HF positioning convention)", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "moveElement",
target: "hf-title",
x: 100,
y: 200,
});
const el = parsed.document.querySelector('[data-hf-id="hf-title"]');
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");
});
});
// ─── 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);
});
});
// ─── 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);
});
});
// ─── setCompositionMetadata — data-width/data-height forced override ─────────
describe("setCompositionMetadata data-* channel", () => {
const ATTR_HTML = `
<div data-hf-id="hf-stage" data-hf-root data-width="1280" data-height="720" style="width: 1280px; height: 720px">
<h1 data-hf-id="hf-title">Hi</h1>
</div>
`.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");
});
});
+407
View File
@@ -0,0 +1,407 @@
/**
* 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: [] };
/** 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[] {
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 handleMoveElement(parsed, targets(op.target), op.x, op.y);
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 — 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":
case "setGsapKeyframe":
case "addGsapKeyframe":
case "removeGsapKeyframe":
case "removeGsapTween":
case "addLabel":
case "removeLabel":
throw new UnsupportedOpError(op.type);
}
}
// ─── Op handlers ────────────────────────────────────────────────────────────
function handleSetStyle(
parsed: ParsedDocument,
ids: HfId[],
styles: Record<string, string | null>,
): 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 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) {
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;
// 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 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);
result.inverse.push(p.inverse);
}
if (op.height !== undefined) {
const styles = getElementStyles(root);
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);
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 — not implemented yet; can() must report false so callers
// can feature-detect instead of hitting UnsupportedOpError.
default:
return !PHASE3B_OPS.has(op.type);
}
}
+153
View File
@@ -0,0 +1,153 @@
/**
* 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|end|trackIndex} ← end = computed absolute data-end
* /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 ──────────────────────────────────────────────────────
// 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[],
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),
};
}
+22
View File
@@ -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 `<!DOCTYPE html>\n${doc.documentElement.outerHTML}`;
}
+19
View File
@@ -0,0 +1,19 @@
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";
+256
View File
@@ -0,0 +1,256 @@
// ─── 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<Record<string, string>>;
readonly classNames: readonly string[];
/** All attributes except style, class, and data-hf-* (those are model-level) */
readonly attributes: Readonly<Record<string, string>>;
/** 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;
/**
* BUILD-TIME snapshot of the ensureHfIds-stamped HTML. Never updated after
* mutations — use Composition.serialize() for the current document state.
*/
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<string, string | number | boolean | null>;
// ─── 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<string, string | null> }
| { 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<string, string | null> }
| { 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<GsapTweenSpec> }
| {
type: "setGsapKeyframe";
animationId: string;
keyframeIndex: number;
position?: number;
value?: Record<string, unknown>;
ease?: string;
}
| {
type: "addGsapKeyframe";
animationId: string;
position: number;
value: Record<string, unknown>;
}
| { 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<string, unknown>;
toProperties?: Record<string, unknown>;
/** For 'to' tweens — the properties to animate toward */
properties?: Record<string, unknown>;
repeat?: number;
yoyo?: boolean;
}
// ─── 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;
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; ... })
*
* 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 = "@hyperframes/sdk:applyPatches" as const;
/** 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<string, string | null>): 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<string, string | null>): 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<string, string | null>): 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<GsapTweenSpec>): 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;
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"noEmit": true
},
"include": ["src/**/*", "examples/**/*"],
"exclude": ["node_modules", "dist"]
}
+18
View File
@@ -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"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
environment: "node",
},
});