chore: import upstream snapshot with attribution
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:35 +08:00
commit 85453da49f
4031 changed files with 710987 additions and 0 deletions
+217
View File
@@ -0,0 +1,217 @@
/**
* 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<string, string>): Promise<string> {
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<string> {
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<string> {
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<string> {
const comp = await openComposition(html);
const textEls = comp.find({ tag: "div" });
// Phase 3b feature-detect: addGsapTween throws UnsupportedOpError until the
// parser-backed engine lands — skip animation rather than crash the job.
const probeTween = {
method: "from",
position: 0,
duration: 0.5,
ease: "power3.out",
fromProperties: { opacity: 0, y: 30 },
} as const;
const first = textEls[0];
if (
!first ||
!comp.can({ type: "addGsapTween", target: first, id: "preflight", tween: probeTween })
) {
return comp.serialize();
}
comp.batch(() => {
textEls.forEach((id, i) => {
comp.addGsapTween(id, { ...probeTween, position: i * staggerDelay });
});
});
return comp.serialize();
}
// ── Composition metadata normalization ────────────────────────────────────────
export async function normalizeToPortrait(html: string): Promise<string> {
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<string, string | number | boolean>,
): Promise<string> {
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<string> {
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();
}
+154
View File
@@ -0,0 +1,154 @@
/**
* 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<Composition> {
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<string, string>): 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 ──────────────────────────────────────────────────────
// Phase 3b: GSAP ops throw UnsupportedOpError until the parser-backed engine
// lands — feature-detect with can() and disable the panel control if false.
export function addBounceIn(comp: Composition, targetId: string): string | null {
const tween = {
method: "from",
position: 0,
duration: 0.5,
ease: "bounce.out",
fromProperties: { y: 40, opacity: 0 },
} as const;
if (!comp.can({ type: "addGsapTween", target: targetId, id: "preflight", tween })) return null;
return comp.addGsapTween(targetId, tween);
}
export function updateEase(comp: Composition, animationId: string, ease: string): void {
if (!comp.can({ type: "setGsapTween", animationId, properties: { ease } })) return;
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<Composition["getOverrides"]>; 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<Composition["getOverrides"]>,
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);
}
+187
View File
@@ -0,0 +1,187 @@
/**
* 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<Composition> {
// 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<Composition["dispatch"]>[0]);
}
// ── GSAP operations ───────────────────────────────────────────────────────────
// NOTE (Phase 3b): GSAP ops require the parser-backed engine and throw
// UnsupportedOpError until it lands. Feature-detect with can() first.
export function addFadeIn(comp: Composition, targetId: string, delay = 0): string | null {
const tween: GsapTweenSpec = {
method: "from",
position: delay,
duration: 0.4,
ease: "power2.out",
fromProperties: { opacity: 0 },
};
if (!comp.can({ type: "addGsapTween", target: targetId, id: "preflight", tween })) return null;
return comp.addGsapTween(targetId, tween);
}
export function addBounce(
comp: Composition,
targetId: string,
overrides?: Partial<GsapTweenSpec>,
): string | null {
const tween: GsapTweenSpec = {
method: "from",
position: 0,
duration: 0.6,
ease: "bounce.out",
fromProperties: { y: 60, opacity: 0 },
...overrides,
};
if (!comp.can({ type: "addGsapTween", target: targetId, id: "preflight", tween })) return null;
return comp.addGsapTween(targetId, tween);
}
// 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);
}
+83
View File
@@ -0,0 +1,83 @@
{
"name": "@hyperframes/sdk",
"version": "0.7.55",
"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"
},
"./editing": {
"import": "./src/editing/affordances.ts",
"types": "./src/editing/affordances.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"
},
"./editing": {
"import": "./dist/editing/affordances.js",
"types": "./dist/editing/affordances.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:*",
"@hyperframes/parsers": "workspace:*",
"linkedom": "^0.18.12"
},
"devDependencies": {
"@types/node": "^25.0.10",
"happy-dom": "^20.9.0",
"typescript": "^5.0.0",
"vitest": "^3.2.4"
}
}
+143
View File
@@ -0,0 +1,143 @@
import type { PersistAdapter, PersistVersionEntry } from "./types.js";
import type { PersistErrorEvent } from "../types.js";
import { readFile, writeFile, mkdir, readdir, unlink } from "node:fs/promises";
import { join, dirname } from "node:path";
export interface FsAdapterOptions {
/** Root directory for composition files */
root: string;
/** Max versions to keep per file. Default: 20 */
maxVersions?: number;
}
const DEFAULT_MAX_VERSIONS = 20;
let _versionCounter = 0;
class FsAdapter implements PersistAdapter {
private readonly root: string;
private readonly maxVersions: number;
private errorHandlers: Array<(e: PersistErrorEvent) => void> = [];
private readonly inflightWrites = new Set<Promise<void>>();
private _writeLocks = new Map<string, Promise<void>>();
constructor(opts: FsAdapterOptions) {
this.root = opts.root;
this.maxVersions = opts.maxVersions ?? DEFAULT_MAX_VERSIONS;
}
async read(path: string): Promise<string | undefined> {
try {
return await readFile(this.abs(path), "utf8");
} catch (err: unknown) {
if (isNotFound(err)) return undefined;
throw err;
}
}
async write(path: string, content: string): Promise<void> {
const p = this.doWrite(path, content);
this.inflightWrites.add(p);
try {
await p;
} finally {
this.inflightWrites.delete(p);
}
}
private async doWrite(path: string, content: string): Promise<void> {
try {
const abs = this.abs(path);
await mkdir(dirname(abs), { recursive: true });
await writeFile(abs, content, "utf8");
await this.appendVersion(path, content);
} catch (err) {
for (const h of this.errorHandlers) h({ error: { message: String(err), cause: err } });
}
}
async flush(): Promise<void> {
// Promise.all rejects on the first write failure; per-write errors are also
// surfaced individually through the persist:error event channel.
await Promise.all([...this.inflightWrites]);
}
async listVersions(path: string): Promise<PersistVersionEntry[]> {
const dir = this.versionsDir(path);
try {
const entries = await readdir(dir);
const sorted = entries
.filter((f) => f.endsWith(".html"))
.sort()
.reverse();
return Promise.all(
sorted.map(async (f) => {
const key = f.replace(/\.html$/, "");
return {
key,
content: await readFile(join(dir, f), "utf8"),
timestamp: Number(key.split("-")[0]),
};
}),
);
} catch {
return [];
}
}
async loadFrom(path: string, versionKey: string): Promise<string | undefined> {
try {
return await readFile(join(this.versionsDir(path), `${versionKey}.html`), "utf8");
} catch {
return undefined;
}
}
on(event: "persist:error", handler: (e: PersistErrorEvent) => void): () => void {
if (event !== "persist:error") return () => {};
this.errorHandlers.push(handler);
return () => {
const i = this.errorHandlers.indexOf(handler);
if (i !== -1) this.errorHandlers.splice(i, 1);
};
}
private abs(path: string): string {
return join(this.root, path);
}
private versionsDir(path: string): string {
return join(this.root, ".hf-versions", path);
}
private async appendVersion(path: string, content: string): Promise<void> {
const prior = this._writeLocks.get(path) ?? Promise.resolve();
const next = prior.then(() => this._doAppendVersion(path, content));
this._writeLocks.set(
path,
next.catch(() => {}),
);
return next;
}
private async _doAppendVersion(path: string, content: string): Promise<void> {
const dir = this.versionsDir(path);
await mkdir(dir, { recursive: true });
const key = `${Date.now()}-${String(++_versionCounter).padStart(4, "0")}`;
await writeFile(join(dir, `${key}.html`), content, "utf8");
// prune oldest beyond maxVersions
const all = (await readdir(dir)).filter((f) => f.endsWith(".html")).sort();
const excess = all.length - this.maxVersions;
if (excess > 0) {
await Promise.all(all.slice(0, excess).map((f) => unlink(join(dir, f)).catch(() => {})));
}
}
}
function isNotFound(err: unknown): boolean {
return (err as NodeJS.ErrnoException)?.code === "ENOENT";
}
export function createFsAdapter(opts: FsAdapterOptions): PersistAdapter {
return new FsAdapter(opts);
}
+29
View File
@@ -0,0 +1,29 @@
import type { PreviewAdapter, ElementAtPointResult, DraftProps } from "./types.js";
import type { Composition } 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 () => {};
}
attachSync(_comp: Composition): () => void {
return () => {};
}
}
export function createHeadlessAdapter(): PreviewAdapter {
return new HeadlessPreviewAdapter();
}
@@ -0,0 +1,254 @@
// @vitest-environment happy-dom
/**
* attachSync mirrors every SDK edit (including undo/redo) onto a real live
* document — this file needs happy-dom (the package's default vitest
* environment is "node") because it exercises iframe.contentDocument.
*/
import { describe, it, expect } from "vitest";
import { createIframePreviewAdapter } from "./iframe.js";
import { openComposition } from "../session.js";
const BASE_HTML = `
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px" data-duration="5">
<h1 data-hf-id="hf-title" style="color: #fff; font-size: 64px">Hello World</h1>
</div>
`.trim();
/** A same-origin iframe seeded with the given HTML, ready for contentDocument access. */
function mountIframe(html: string): HTMLIFrameElement {
const iframe = document.createElement("iframe");
document.body.appendChild(iframe);
iframe.contentDocument!.open();
iframe.contentDocument!.write(html);
iframe.contentDocument!.close();
return iframe;
}
describe("IframePreviewAdapter.attachSync", () => {
it("mirrors comp.getOverrides() onto the iframe immediately on attach", async () => {
const iframe = mountIframe(BASE_HTML);
const comp = await openComposition(BASE_HTML);
comp.setStyle("hf-title", { color: "#f00" }); // edit BEFORE attaching
const adapter = createIframePreviewAdapter(iframe);
adapter.attachSync(comp);
const liveTitle = iframe.contentDocument!.querySelector(
'[data-hf-id="hf-title"]',
) as HTMLElement;
expect(liveTitle.style.getPropertyValue("color")).toBe("#f00");
});
it("mirrors a style edit dispatched AFTER attaching", async () => {
const iframe = mountIframe(BASE_HTML);
const comp = await openComposition(BASE_HTML);
const adapter = createIframePreviewAdapter(iframe);
adapter.attachSync(comp);
comp.setStyle("hf-title", { fontSize: "96px" });
const liveTitle = iframe.contentDocument!.querySelector(
'[data-hf-id="hf-title"]',
) as HTMLElement;
expect(liveTitle.style.getPropertyValue("font-size")).toBe("96px");
});
it("mirrors setText, setAttribute, and removeElement", async () => {
const iframe = mountIframe(BASE_HTML);
const comp = await openComposition(BASE_HTML);
const adapter = createIframePreviewAdapter(iframe);
adapter.attachSync(comp);
const liveDoc = iframe.contentDocument!;
comp.setText("hf-title", "Goodbye");
expect(liveDoc.querySelector('[data-hf-id="hf-title"]')?.textContent).toContain("Goodbye");
comp.setAttribute("hf-title", "data-test", "1");
expect(liveDoc.querySelector('[data-hf-id="hf-title"]')?.getAttribute("data-test")).toBe("1");
comp.removeElement("hf-title");
expect(liveDoc.querySelector('[data-hf-id="hf-title"]')).toBeNull();
});
it("mirrors undo — restores the live DOM to the pre-edit state", async () => {
const iframe = mountIframe(BASE_HTML);
const comp = await openComposition(BASE_HTML);
const adapter = createIframePreviewAdapter(iframe);
adapter.attachSync(comp);
const liveDoc = iframe.contentDocument!;
comp.setStyle("hf-title", { color: "#f00" });
expect(
(liveDoc.querySelector('[data-hf-id="hf-title"]') as HTMLElement).style.getPropertyValue(
"color",
),
).toBe("#f00");
comp.undo();
expect(
(liveDoc.querySelector('[data-hf-id="hf-title"]') as HTMLElement).style.getPropertyValue(
"color",
),
).toBe("#fff");
});
it("mirrors redo after undo", async () => {
const iframe = mountIframe(BASE_HTML);
const comp = await openComposition(BASE_HTML);
const adapter = createIframePreviewAdapter(iframe);
adapter.attachSync(comp);
const liveDoc = iframe.contentDocument!;
comp.setStyle("hf-title", { color: "#f00" });
comp.undo();
comp.redo();
expect(
(liveDoc.querySelector('[data-hf-id="hf-title"]') as HTMLElement).style.getPropertyValue(
"color",
),
).toBe("#f00");
});
it("does NOT mirror a /script/gsap patch onto the live <script> tag", async () => {
const html = `<!DOCTYPE html>
<html><body>
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px" data-duration="5">
<div data-hf-id="hf-box" style="opacity:0"></div>
</div>
<script>var tl = gsap.timeline({ paused: true });
window.__timelines = { t: tl };</script>
</body></html>`;
const iframe = mountIframe(html);
const liveScriptBefore = iframe.contentDocument!.querySelector("script")!.textContent;
const comp = await openComposition(html);
const adapter = createIframePreviewAdapter(iframe);
adapter.attachSync(comp);
comp.addGsapTween("hf-box", { method: "to", properties: { opacity: 1 }, duration: 1 });
// The offscreen model's script changed (proves the edit really happened)...
expect(comp.serialize()).not.toBe(html);
// ...but the LIVE script tag is untouched — script patches are never mirrored.
expect(iframe.contentDocument!.querySelector("script")!.textContent).toBe(liveScriptBefore);
});
it("DOES mirror a /style/css (stylesheet) patch onto the live <style> tag", async () => {
const html = `<!DOCTYPE html>
<html><body>
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px" data-duration="5">
<div data-hf-id="hf-box" class="boxy"></div>
</div>
<style>.boxy { color: blue; }</style>
</body></html>`;
const iframe = mountIframe(html);
const comp = await openComposition(html);
const adapter = createIframePreviewAdapter(iframe);
adapter.attachSync(comp);
comp.dispatch({ type: "setClassStyle", selector: ".boxy", styles: { color: "green" } });
const liveStyle = iframe.contentDocument!.querySelector("style")!.textContent ?? "";
expect(liveStyle).toContain("green");
});
it("does not throw when a patch arrives after the iframe is removed from the DOM", async () => {
const iframe = mountIframe(BASE_HTML);
const comp = await openComposition(BASE_HTML);
const adapter = createIframePreviewAdapter(iframe);
adapter.attachSync(comp);
iframe.remove(); // contentDocument becomes null (or inaccessible) once detached
expect(() => comp.setStyle("hf-title", { color: "#0f0" })).not.toThrow();
});
it("re-attaching detaches the previous subscription — old comp's edits stop mirroring", async () => {
const iframe = mountIframe(BASE_HTML);
const compA = await openComposition(BASE_HTML);
const compB = await openComposition(BASE_HTML);
const adapter = createIframePreviewAdapter(iframe);
adapter.attachSync(compA);
adapter.attachSync(compB); // should detach compA's subscription
compA.setStyle("hf-title", { color: "#f00" }); // must NOT mirror — stale subscription
compB.setStyle("hf-title", { fontSize: "10px" }); // must mirror — active subscription
const liveTitle = iframe.contentDocument!.querySelector(
'[data-hf-id="hf-title"]',
) as HTMLElement;
expect(liveTitle.style.getPropertyValue("color")).not.toBe("#f00");
expect(liveTitle.style.getPropertyValue("font-size")).toBe("10px");
});
it("the returned unsubscribe function detaches the subscription", async () => {
const iframe = mountIframe(BASE_HTML);
const comp = await openComposition(BASE_HTML);
const adapter = createIframePreviewAdapter(iframe);
const detach = adapter.attachSync(comp);
detach();
comp.setStyle("hf-title", { color: "#f00" });
const liveTitle = iframe.contentDocument!.querySelector(
'[data-hf-id="hf-title"]',
) as HTMLElement;
expect(liveTitle.style.getPropertyValue("color")).not.toBe("#f00");
});
it("mirrors setVariableValue onto the live root's CSS custom property", async () => {
const html = `<!DOCTYPE html>
<html data-composition-variables='[{"id":"accent","default":"#fff"}]'>
<body>
<div data-hf-id="hf-stage" data-hf-root style="--accent: #fff; width: 1280px; height: 720px" data-duration="5">
<h1 data-hf-id="hf-title" style="color: var(--accent)">Hello World</h1>
</div>
</body>
</html>`;
const iframe = mountIframe(html);
const comp = await openComposition(html);
const adapter = createIframePreviewAdapter(iframe);
adapter.attachSync(comp);
comp.setVariableValue("accent", "#0f0");
const liveRoot = iframe.contentDocument!.querySelector(
'[data-hf-id="hf-stage"]',
) as HTMLElement;
expect(liveRoot.style.getPropertyValue("--accent")).toBe("#0f0");
});
it("mirrors declareVariable/removeVariable onto the live document's schema attribute", async () => {
// Full document (not a fragment): declareVariable refuses fragment sources.
const fullDoc = `<!DOCTYPE html><html><body>${BASE_HTML}</body></html>`;
const iframe = mountIframe(fullDoc); // no data-composition-variables at all
const comp = await openComposition(fullDoc);
const adapter = createIframePreviewAdapter(iframe);
adapter.attachSync(comp);
comp.declareVariable({ id: "accent", type: "string", label: "Accent", default: "#fff" });
const liveDocEl = iframe.contentDocument!.documentElement;
expect(liveDocEl.getAttribute("data-composition-variables")).toContain("accent");
comp.removeVariable("accent");
// Removing the last declaration drops the attribute entirely (null).
expect(liveDocEl.getAttribute("data-composition-variables") ?? "").not.toContain("accent");
});
it("mirrors setTiming onto the live element's data-start/data-end attributes", async () => {
const iframe = mountIframe(BASE_HTML);
const comp = await openComposition(BASE_HTML);
const adapter = createIframePreviewAdapter(iframe);
adapter.attachSync(comp);
comp.setTiming("hf-title", { start: 1, duration: 2 });
const liveTitle = iframe.contentDocument!.querySelector(
'[data-hf-id="hf-title"]',
) as HTMLElement;
expect(liveTitle.getAttribute("data-start")).toBe("1");
expect(liveTitle.getAttribute("data-end")).toBe("3");
});
});
+898
View File
@@ -0,0 +1,898 @@
/**
* Unit tests for the pure functions in iframe.ts (no browser needed).
*
* elementFromPoint requires a real layout engine — the adapter's elementAtPoint()
* is NOT tested here. Cover it with an integration test mounting a same-origin
* iframe (WS-A1 follow-on).
*
* applyDraft / commitPreview / cancelPreview require HTMLElement.style + querySelector
* which are also browser-only. They are tested via a lightweight fake-DOM helper
* that simulates style.setProperty / getAttribute / removeProperty.
*
* WS-G image-alpha tests cover:
* - alphaIsOpaque (pure predicate)
* - mapPointToImagePixel (pure coordinate mapping)
* - z-stack fallthrough via mock elementsFromPoint
* - canvas taint → opaque fallback
* - non-image regression (WS-A1 opacity behavior unchanged)
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
resolveNearestHfElement,
computeDraftPosition,
createIframePreviewAdapter,
alphaIsOpaque,
mapPointToImagePixel,
_imgCanvasCache,
} from "./iframe.js";
import type { ElementAtPointResult } from "./types.js";
import type { EditOp } from "../types.js";
// ─── Minimal fake element ────────────────────────────────────────────────────
interface FakeEl {
attrs: Record<string, string>;
tagName: string;
parentElement: FakeEl | null;
getAttribute(name: string): string | null;
hasAttribute(name: string): boolean;
}
function fakeEl(
attrs: Record<string, string>,
tagName: string,
parent: FakeEl | null = null,
): FakeEl {
return {
attrs,
tagName,
parentElement: parent,
getAttribute(name) {
return Object.prototype.hasOwnProperty.call(this.attrs, name) ? this.attrs[name] : null;
},
hasAttribute(name) {
return Object.prototype.hasOwnProperty.call(this.attrs, name);
},
};
}
const visible = () => true;
const invisible = () => false;
// ─── resolveNearestHfElement ──────────────────────────────────────────────────
describe("resolveNearestHfElement", () => {
it("returns null for a null input", () => {
expect(resolveNearestHfElement(null, visible)).toBeNull();
});
it("returns the element itself when it carries data-hf-id", () => {
const el = fakeEl({ "data-hf-id": "hf-abc" }, "div");
const result = resolveNearestHfElement(el as unknown as Element, visible);
expect(result).toEqual<ElementAtPointResult>({ id: "hf-abc", tag: "div" });
});
it("walks up to a parent that carries data-hf-id", () => {
const parent = fakeEl({ "data-hf-id": "hf-parent" }, "section");
const child = fakeEl({}, "span", parent);
const result = resolveNearestHfElement(child as unknown as Element, visible);
expect(result).toEqual<ElementAtPointResult>({ id: "hf-parent", tag: "section" });
});
it("returns null when the nearest data-hf-id node is data-hf-root", () => {
const root = fakeEl({ "data-hf-id": "hf-stage", "data-hf-root": "" }, "div");
const child = fakeEl({}, "p", root);
expect(resolveNearestHfElement(child as unknown as Element, visible)).toBeNull();
});
it("returns null when the element itself is data-hf-root", () => {
const root = fakeEl({ "data-hf-id": "hf-stage", "data-hf-root": "" }, "div");
expect(resolveNearestHfElement(root as unknown as Element, visible)).toBeNull();
});
it("returns null when isVisible returns false for the matching element", () => {
const el = fakeEl({ "data-hf-id": "hf-abc" }, "div");
expect(resolveNearestHfElement(el as unknown as Element, invisible)).toBeNull();
});
it("skips an opacity-0 element and returns null (isVisible called on the resolved node)", () => {
const parent = fakeEl({ "data-hf-id": "hf-parent" }, "div");
const child = fakeEl({}, "span", parent);
const isVisible = vi.fn((el: Element) => {
const fe = el as unknown as FakeEl;
return fe.attrs["data-hf-id"] !== "hf-parent";
});
expect(resolveNearestHfElement(child as unknown as Element, isVisible)).toBeNull();
expect(isVisible).toHaveBeenCalledTimes(1);
});
it("returns null when no data-hf-id found in any ancestor", () => {
const grandparent = fakeEl({}, "body");
const parent = fakeEl({}, "div", grandparent);
const child = fakeEl({}, "span", parent);
expect(resolveNearestHfElement(child as unknown as Element, visible)).toBeNull();
});
it("tag is lowercased", () => {
const el = fakeEl({ "data-hf-id": "hf-xyz" }, "DIV");
const result = resolveNearestHfElement(el as unknown as Element, visible);
expect(result?.tag).toBe("div");
});
it("stops at the nearest ancestor — does not continue past first data-hf-id", () => {
const outer = fakeEl({ "data-hf-id": "hf-outer" }, "section");
const inner = fakeEl({ "data-hf-id": "hf-inner" }, "div", outer);
const child = fakeEl({}, "span", inner);
const result = resolveNearestHfElement(child as unknown as Element, visible);
expect(result?.id).toBe("hf-inner");
});
});
// ─── computeDraftPosition ─────────────────────────────────────────────────────
describe("computeDraftPosition", () => {
it("applies delta to base data-x/data-y", () => {
expect(computeDraftPosition("100", "200", 30, -10)).toEqual({ x: 130, y: 190 });
});
it("defaults missing data-x/data-y to 0", () => {
expect(computeDraftPosition(null, null, 50, 25)).toEqual({ x: 50, y: 25 });
});
it("defaults non-numeric data-x/data-y to 0", () => {
expect(computeDraftPosition("abc", "xyz", 10, 5)).toEqual({ x: 10, y: 5 });
});
it("works with zero delta (no-move commit)", () => {
expect(computeDraftPosition("40", "80", 0, 0)).toEqual({ x: 40, y: 80 });
});
it("handles negative base positions", () => {
expect(computeDraftPosition("-20", "0", 5, 10)).toEqual({ x: -15, y: 10 });
});
});
// ─── IframePreviewAdapter selection ──────────────────────────────────────────
function stubIframe() {
return {} as HTMLIFrameElement;
}
describe("IframePreviewAdapter selection", () => {
it("on('selection') fires when select() is called", () => {
const adapter = createIframePreviewAdapter(stubIframe());
const cb = vi.fn();
adapter.on("selection", cb);
adapter.select(["hf-abc"]);
expect(cb).toHaveBeenCalledWith(["hf-abc"]);
});
it("off unsubscribes the handler", () => {
const adapter = createIframePreviewAdapter(stubIframe());
const cb = vi.fn();
const off = adapter.on("selection", cb);
off();
adapter.select(["hf-abc"]);
expect(cb).not.toHaveBeenCalled();
});
it("additive select merges with prior selection", () => {
const adapter = createIframePreviewAdapter(stubIframe());
const cb = vi.fn();
adapter.on("selection", cb);
adapter.select(["hf-a"]);
adapter.select(["hf-b"], { additive: true });
expect(cb).toHaveBeenLastCalledWith(expect.arrayContaining(["hf-a", "hf-b"]));
});
it("non-additive select replaces prior selection", () => {
const adapter = createIframePreviewAdapter(stubIframe());
const cb = vi.fn();
adapter.on("selection", cb);
adapter.select(["hf-a"]);
adapter.select(["hf-b"]);
expect(cb).toHaveBeenLastCalledWith(["hf-b"]);
});
it("multiple handlers all fire", () => {
const adapter = createIframePreviewAdapter(stubIframe());
const cb1 = vi.fn();
const cb2 = vi.fn();
adapter.on("selection", cb1);
adapter.on("selection", cb2);
adapter.select(["hf-abc"]);
expect(cb1).toHaveBeenCalledOnce();
expect(cb2).toHaveBeenCalledOnce();
});
});
// ─── applyDraft / commitPreview / cancelPreview ───────────────────────────────
// Tests use a fake iframe+element because HTMLElement.style requires a browser.
interface FakeStyle {
_props: Record<string, string>;
setProperty(name: string, value: string): void;
getPropertyValue(name: string): string;
removeProperty(name: string): void;
}
interface FakeDomEl {
_attrs: Record<string, string>;
style: FakeStyle;
isConnected: boolean;
getAttribute(name: string): string | null;
setAttribute(name: string, value: string): void;
hasAttribute(name: string): boolean;
querySelector(sel: string): FakeDomEl | null;
}
function fakeDomEl(id: string, dataX: string | null, dataY: string | null): FakeDomEl {
const style: FakeStyle = {
_props: {},
setProperty(name, value) {
this._props[name] = value;
},
getPropertyValue(name) {
return this._props[name] ?? "";
},
removeProperty(name) {
delete this._props[name];
},
};
const attrs: Record<string, string> = { "data-hf-id": id };
if (dataX !== null) attrs["data-x"] = dataX;
if (dataY !== null) attrs["data-y"] = dataY;
const el: FakeDomEl = {
_attrs: attrs,
style,
isConnected: true,
getAttribute(name) {
return this._attrs[name] ?? null;
},
setAttribute(name, value) {
this._attrs[name] = value;
},
hasAttribute(name) {
return name in this._attrs;
},
querySelector(_sel: string) {
return null;
},
};
return el;
}
function fakeIframe(el: FakeDomEl | null): HTMLIFrameElement {
return {
contentDocument: {
querySelector(_sel: string) {
return el;
},
},
} as unknown as HTMLIFrameElement;
}
describe("IframePreviewAdapter draft / commit / cancel", () => {
it("commitPreview without applyDraft is a no-op", () => {
const dispatch = vi.fn();
const adapter = createIframePreviewAdapter(stubIframe(), dispatch);
adapter.commitPreview();
expect(dispatch).not.toHaveBeenCalled();
});
it("cancelPreview without applyDraft is a no-op", () => {
const dispatch = vi.fn();
const adapter = createIframePreviewAdapter(stubIframe(), dispatch);
adapter.cancelPreview();
expect(dispatch).not.toHaveBeenCalled();
});
it("commitPreview dispatches moveElement with correct absolute position", () => {
const dispatch = vi.fn();
const el = fakeDomEl("hf-abc", "100", "200");
const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch);
adapter.applyDraft("hf-abc", { dx: 30, dy: -20 });
adapter.commitPreview();
expect(dispatch).toHaveBeenCalledWith<[EditOp]>({
type: "moveElement",
target: "hf-abc",
x: 130,
y: 180,
});
});
it("commitPreview with missing data-x/data-y defaults base to 0", () => {
const dispatch = vi.fn();
const el = fakeDomEl("hf-abc", null, null);
const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch);
adapter.applyDraft("hf-abc", { dx: 50, dy: 25 });
adapter.commitPreview();
expect(dispatch).toHaveBeenCalledWith<[EditOp]>({
type: "moveElement",
target: "hf-abc",
x: 50,
y: 25,
});
});
it("commitPreview mirrors the move onto the live element and applies the translate", () => {
const el = fakeDomEl("hf-abc", "100", "200");
const adapter = createIframePreviewAdapter(fakeIframe(el), vi.fn());
adapter.applyDraft("hf-abc", { dx: 30, dy: -20 });
adapter.commitPreview();
expect(el.getAttribute("data-x")).toBe("130");
expect(el.getAttribute("data-y")).toBe("180");
// Baseline captured from the pre-drag values.
expect(el.getAttribute("data-hf-edit-base-x")).toBe("100");
expect(el.getAttribute("data-hf-edit-base-y")).toBe("200");
// Final translate = delta from the baseline, held without a reload.
expect(el.getAttribute("data-hf-edit-original-translate")).toBe("");
expect(el.style.getPropertyValue("translate")).toBe("30px -20px");
// A second drag composes from the committed state and keeps the baseline.
adapter.applyDraft("hf-abc", { dx: 10, dy: 10 });
expect(el.style.getPropertyValue("translate")).toBe("40px -10px");
adapter.commitPreview();
expect(el.getAttribute("data-x")).toBe("140");
expect(el.getAttribute("data-hf-edit-base-x")).toBe("100");
expect(el.style.getPropertyValue("translate")).toBe("40px -10px");
});
it("applyDraft translates the element live and cancelPreview restores it", () => {
const el = fakeDomEl("hf-abc", "0", "0");
el.style.setProperty("translate", "5px 6px");
const adapter = createIframePreviewAdapter(fakeIframe(el), vi.fn());
adapter.applyDraft("hf-abc", { dx: 30, dy: -20 });
expect(el.style.getPropertyValue("translate")).toBe("35px -14px");
adapter.cancelPreview();
expect(el.style.getPropertyValue("translate")).toBe("5px 6px");
expect(el.getAttribute("data-hf-edit-base-x")).toBeNull();
});
it("cancelPreview removes a draft translate when there was none before", () => {
const el = fakeDomEl("hf-abc", "0", "0");
const adapter = createIframePreviewAdapter(fakeIframe(el), vi.fn());
adapter.applyDraft("hf-abc", { dx: 30 });
expect(el.style.getPropertyValue("translate")).toBe("30px 0px");
adapter.cancelPreview();
expect(el.style.getPropertyValue("translate")).toBe("");
});
it("applyDraft reuses the cached element across repeated calls (no re-query)", () => {
const el = fakeDomEl("hf-abc", "0", "0");
let queryCount = 0;
const iframe = {
contentDocument: {
querySelector(_sel: string) {
queryCount++;
return el;
},
},
} as unknown as HTMLIFrameElement;
const adapter = createIframePreviewAdapter(iframe);
adapter.applyDraft("hf-abc", { dx: 1, dy: 1 });
adapter.applyDraft("hf-abc", { dx: 2, dy: 2 });
adapter.applyDraft("hf-abc", { dx: 3, dy: 3 });
// Queried once on the first call; the next two reuse the connected cache.
expect(queryCount).toBe(1);
});
it("commitPreview without a dispatch callback is a no-op", () => {
const el = fakeDomEl("hf-abc", "0", "0");
const adapter = createIframePreviewAdapter(fakeIframe(el));
adapter.applyDraft("hf-abc", { dx: 10, dy: 10 });
// should not throw
adapter.commitPreview();
});
it("cancelPreview reverts the draft translate without dispatching", () => {
const dispatch = vi.fn();
const el = fakeDomEl("hf-abc", "100", "200");
const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch);
adapter.applyDraft("hf-abc", { dx: 30, dy: 20 });
expect(el.style.getPropertyValue("translate")).toBe("30px 20px");
adapter.cancelPreview();
expect(dispatch).not.toHaveBeenCalled();
expect(el.style.getPropertyValue("translate")).toBe("");
});
it("second commitPreview after first is a no-op (draft cleared)", () => {
const dispatch = vi.fn();
const el = fakeDomEl("hf-abc", "0", "0");
const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch);
adapter.applyDraft("hf-abc", { dx: 10, dy: 5 });
adapter.commitPreview();
adapter.commitPreview();
expect(dispatch).toHaveBeenCalledTimes(1);
});
it("switching applyDraft to a new id reverts the abandoned element", () => {
const elA = fakeDomEl("hf-a", "0", "0");
const elB = fakeDomEl("hf-b", "0", "0");
const iframe = {
contentDocument: {
querySelector(sel: string) {
return sel.includes("hf-a") ? elA : elB;
},
},
} as unknown as HTMLIFrameElement;
const adapter = createIframePreviewAdapter(iframe, vi.fn());
adapter.applyDraft("hf-a", { dx: 80, dy: 0 });
expect(elA.style.getPropertyValue("translate")).toBe("80px 0px");
adapter.applyDraft("hf-b", { dx: 10, dy: 10 });
// The abandoned element is restored; the delta does not carry over.
expect(elA.style.getPropertyValue("translate")).toBe("");
expect(elB.style.getPropertyValue("translate")).toBe("10px 10px");
});
it("commitPreview reverts the draft translate when dispatch throws", () => {
const el = fakeDomEl("hf-abc", "0", "0");
el.style.setProperty("translate", "5px 6px");
const dispatch = vi.fn(() => {
throw new Error("element_not_found");
});
const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch);
adapter.applyDraft("hf-abc", { dx: 30, dy: 20 });
expect(() => adapter.commitPreview()).toThrow("element_not_found");
expect(el.style.getPropertyValue("translate")).toBe("5px 6px");
expect(el.getAttribute("data-hf-edit-base-x")).toBeNull();
});
it("cancelPreview does not promote a computed (stylesheet) translate to inline", () => {
const el = fakeDomEl("hf-abc", "0", "0");
// Simulate a stylesheet-authored translate visible only via computed style.
(el as unknown as { ownerDocument: unknown }).ownerDocument = {
defaultView: {
getComputedStyle: () => ({ getPropertyValue: () => "-50% -50%" }),
},
};
const adapter = createIframePreviewAdapter(fakeIframe(el), vi.fn());
adapter.applyDraft("hf-abc", { dx: 30, dy: 20 });
// Draft composes onto the computed baseline (calc for non-px units).
expect(el.style.getPropertyValue("translate")).toBe("calc(-50% + 30px) calc(-50% + 20px)");
adapter.cancelPreview();
// Inline translate removed — the stylesheet value stays authoritative.
expect(el.style.getPropertyValue("translate")).toBe("");
});
});
// ─── WS-G: alphaIsOpaque ──────────────────────────────────────────────────────
describe("alphaIsOpaque", () => {
function makeImageData(alpha: number): ImageData {
const data = new Uint8ClampedArray([255, 0, 0, alpha]);
return { data, width: 1, height: 1, colorSpace: "srgb" } as unknown as ImageData;
}
it("returns false for a fully transparent pixel (a=0)", () => {
expect(alphaIsOpaque(makeImageData(0))).toBe(false);
});
it("returns true for a fully opaque pixel (a=255)", () => {
expect(alphaIsOpaque(makeImageData(255))).toBe(true);
});
it("returns true for alpha at default threshold (a=1 >= 1)", () => {
expect(alphaIsOpaque(makeImageData(1), 1)).toBe(true);
});
it("respects a custom threshold: a=100 < threshold=128 → false", () => {
expect(alphaIsOpaque(makeImageData(100), 128)).toBe(false);
});
it("respects a custom threshold: a=200 >= threshold=128 → true", () => {
expect(alphaIsOpaque(makeImageData(200), 128)).toBe(true);
});
it("threshold edge: a === threshold → true", () => {
expect(alphaIsOpaque(makeImageData(64), 64)).toBe(true);
});
});
// ─── WS-G: mapPointToImagePixel ───────────────────────────────────────────────
describe("mapPointToImagePixel", () => {
// A 200×100 CSS box displaying a 400×200 natural image.
const rect = { left: 10, top: 20, width: 200, height: 100 };
const natural = { width: 400, height: 200 };
it("fill: maps center of box to center of natural image", () => {
const result = mapPointToImagePixel(rect, natural, "fill", "50% 50%", {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
});
expect(result).toEqual({ px: 200, py: 100 });
});
it("fill: maps top-left corner to natural (0, 0)", () => {
const result = mapPointToImagePixel(rect, natural, "fill", "50% 50%", {
x: rect.left,
y: rect.top,
});
expect(result).toEqual({ px: 0, py: 0 });
});
it("fill: maps bottom-right corner to natural (399, 199)", () => {
const result = mapPointToImagePixel(rect, natural, "fill", "50% 50%", {
x: rect.left + rect.width,
y: rect.top + rect.height,
});
// rect.width maps to naturalWidth → px = floor(200/200 * 400) = 400, clamped to 399
expect(result).toEqual({ px: 399, py: 199 });
});
it("returns null when point is outside the CSS box (left)", () => {
const result = mapPointToImagePixel(rect, natural, "fill", "50% 50%", {
x: rect.left - 1,
y: rect.top + 10,
});
expect(result).toBeNull();
});
it("returns null when point is outside the CSS box (above)", () => {
const result = mapPointToImagePixel(rect, natural, "fill", "50% 50%", {
x: rect.left + 10,
y: rect.top - 1,
});
expect(result).toBeNull();
});
describe("cover", () => {
// 200×100 box, 100×100 natural → scale = max(2, 1) = 2 (cover clips Y;
// the 200×200 rendered image overflows top/bottom — cover never letterboxes)
// rendered: 200×200; centered by default (50% 50%)
// imgTop = (100 - 200)/2 = -50; imgLeft = (200-200)/2 = 0
const coverRect = { left: 0, top: 0, width: 200, height: 100 };
const coverNatural = { width: 100, height: 100 };
it("cover: point in center maps to center of natural image", () => {
// x=100, y=50 → rx=100-0=100, ry=50-(-50)=100; px=100/2=50, py=100/2=50
const result = mapPointToImagePixel(coverRect, coverNatural, "cover", "50% 50%", {
x: 100,
y: 50,
});
expect(result).toEqual({ px: 50, py: 50 });
});
});
describe("contain", () => {
// 200×100 box, 400×100 natural → scale = min(0.5, 1) = 0.5
// rendered: 200×50; centered by default
// imgLeft = (200-200)/2 = 0; imgTop = (100-50)/2 = 25
const containRect = { left: 0, top: 0, width: 200, height: 100 };
const containNatural = { width: 400, height: 100 };
it("contain: point in rendered area maps to natural pixel", () => {
// y=50 → ry = 50-25 = 25; py = 25/0.5 = 50
// x=100 → rx = 100-0 = 100; px = 100/0.5 = 200
const result = mapPointToImagePixel(containRect, containNatural, "contain", "50% 50%", {
x: 100,
y: 50,
});
expect(result).toEqual({ px: 200, py: 50 });
});
it("contain: point in letterbox region (above image) → null", () => {
// imgTop = 25; y=10 → ry=10-25 = -15 → null
const result = mapPointToImagePixel(containRect, containNatural, "contain", "50% 50%", {
x: 100,
y: 10,
});
expect(result).toBeNull();
});
it("contain: point in letterbox region (below image) → null", () => {
// imgTop = 25; rendered height = 50 → imgBottom = 75; y=80 > 75 → null
const result = mapPointToImagePixel(containRect, containNatural, "contain", "50% 50%", {
x: 100,
y: 80,
});
expect(result).toBeNull();
});
});
it("out-of-box point returns null regardless of object-fit", () => {
const r = { left: 50, top: 50, width: 100, height: 100 };
const n = { width: 200, height: 200 };
// Point to the left of the CSS box
expect(mapPointToImagePixel(r, n, "cover", "50% 50%", { x: 49, y: 100 })).toBeNull();
});
describe("none", () => {
// object-fit:none draws the image at natural size, positioned in the box.
// box 100×100, natural 50×50, "50% 50%" → availX=availY=50, offset=25.
const r = { left: 0, top: 0, width: 100, height: 100 };
const n = { width: 50, height: 50 };
it("none: box center maps to image center", () => {
// click (50,50): px=floor(50-25)=25, py=25
expect(mapPointToImagePixel(r, n, "none", "50% 50%", { x: 50, y: 50 })).toEqual({
px: 25,
py: 25,
});
});
it("none: point inside the box but outside the natural-size image → null", () => {
// image occupies 25..75; click at (10,10) is left/above it
expect(mapPointToImagePixel(r, n, "none", "50% 50%", { x: 10, y: 10 })).toBeNull();
});
});
describe("object-position", () => {
// contain so object-position has an effect on the vertical axis.
// box 200×100, natural 400×100 → scale 0.5, rendered 200×50; availX=0, availY=50.
const r = { left: 0, top: 0, width: 200, height: 100 };
const n = { width: 400, height: 100 };
it("'left top' aligns the image to the top of the box", () => {
expect(mapPointToImagePixel(r, n, "contain", "left top", { x: 0, y: 0 })).toEqual({
px: 0,
py: 0,
});
});
it("reversed keyword pair 'top left' resolves identically to 'left top'", () => {
const reversed = mapPointToImagePixel(r, n, "contain", "top left", { x: 0, y: 0 });
const canonical = mapPointToImagePixel(r, n, "contain", "left top", { x: 0, y: 0 });
expect(reversed).toEqual(canonical);
expect(reversed).toEqual({ px: 0, py: 0 });
});
it("'bottom left' (vertical-first) and 'left bottom' both align bottom-left", () => {
// bottom → imgTop=availY=50; bottom edge click y=100 → ry=50 → py=floor(50/0.5)=100→clamp 99
const vh = mapPointToImagePixel(r, n, "contain", "bottom left", { x: 0, y: 100 });
const hv = mapPointToImagePixel(r, n, "contain", "left bottom", { x: 0, y: 100 });
expect(vh).toEqual(hv);
expect(vh).toEqual({ px: 0, py: 99 });
});
it("supports pixel object-position values", () => {
// "0px 10px" → imgTop=10; click (0,10) → ry=0 → py=0
expect(mapPointToImagePixel(r, n, "contain", "0px 10px", { x: 0, y: 10 })).toEqual({
px: 0,
py: 0,
});
});
});
it("cover/contain returns null for a zero-dimension natural image", () => {
const r = { left: 0, top: 0, width: 200, height: 100 };
expect(
mapPointToImagePixel(r, { width: 0, height: 100 }, "cover", "50% 50%", { x: 10, y: 10 }),
).toBeNull();
});
});
// ─── WS-G: z-stack fallthrough (mock elementsFromPoint) ──────────────────────
/**
* WS-G z-stack tests.
*
* The adapter's elementAtPoint uses elementsFromPoint (z-stack) and checks
* `candidate instanceof win.HTMLImageElement`. Since happy-dom / the Bun test
* runner doesn't expose a global HTMLImageElement, we supply a local stub
* class and wire it into the fake contentWindow so the instanceof check works.
*
* Canvas pixel reads (getImageData) are unavailable in the test environment, so
* we patch globalThis.OffscreenCanvas to control alpha values:
* - opaque: getImageData → alpha=255
* - transparent: getImageData → alpha=0
* - tainted: getImageData → throws SecurityError
*/
// ─── Stub HTMLImageElement (no global in Node/Bun) ───────────────────────────
class FakeHTMLImageElement {
attrs: Record<string, string>;
tagName: string;
parentElement: FakeHTMLImageElement | null;
naturalWidth: number;
naturalHeight: number;
currentSrc: string;
src: string;
constructor(id: string, parent: FakeHTMLImageElement | null = null) {
this.attrs = { "data-hf-id": id };
this.tagName = "IMG";
this.parentElement = parent;
this.naturalWidth = 100;
this.naturalHeight = 100;
this.currentSrc = `http://example.com/img-${id}.png`;
this.src = `http://example.com/img-${id}.png`;
}
getBoundingClientRect(): DOMRect {
return { left: 0, top: 0, width: 100, height: 100, right: 100, bottom: 100 } as DOMRect;
}
getAttribute(name: string): string | null {
return Object.prototype.hasOwnProperty.call(this.attrs, name) ? this.attrs[name] : null;
}
hasAttribute(name: string): boolean {
return Object.prototype.hasOwnProperty.call(this.attrs, name);
}
}
// WS-G tests reuse the existing FakeEl / fakeEl helper from above.
// buildFakeIframeWithStack accepts FakeEl or FakeHTMLImageElement.
function buildFakeIframeWithStack(stack: Array<FakeEl | FakeHTMLImageElement>) {
return {
contentDocument: {
elementsFromPoint(_x: number, _y: number) {
return stack;
},
},
contentWindow: {
// Supply our stub class so `candidate instanceof win.HTMLImageElement` works
HTMLImageElement: FakeHTMLImageElement,
getComputedStyle(_el: Element) {
return { opacity: "1" } as CSSStyleDeclaration;
},
},
} as unknown as HTMLIFrameElement;
}
// ─── OffscreenCanvas stub helpers ────────────────────────────────────────────
type CanvasAlphaBehavior = "opaque" | "transparent" | "tainted";
function stubOffscreenCanvas(behavior: CanvasAlphaBehavior): () => void {
const orig = globalThis.OffscreenCanvas as typeof OffscreenCanvas | undefined;
globalThis.OffscreenCanvas = class {
width: number;
height: number;
constructor(w: number, h: number) {
this.width = w;
this.height = h;
}
getContext(_type: string) {
return {
drawImage() {},
getImageData() {
if (behavior === "tainted") {
throw new DOMException("Tainted canvases may not be exported.", "SecurityError");
}
const alpha = behavior === "opaque" ? 255 : 0;
return { data: new Uint8ClampedArray([255, 0, 0, alpha]), width: 1, height: 1 };
},
};
}
} as unknown as typeof OffscreenCanvas;
return () => {
if (orig === undefined) {
delete (globalThis as Record<string, unknown>).OffscreenCanvas;
} else {
globalThis.OffscreenCanvas = orig;
}
};
}
/**
* Run a z-stack image-alpha test with a controlled canvas behavior.
* Abstracts the restore/try/finally boilerplate shared across multiple tests.
*/
function withCanvasStub(
behavior: CanvasAlphaBehavior,
fn: (
makeImgStack: (
imgId: string,
behind?: Array<FakeEl | FakeHTMLImageElement>,
) => HTMLIFrameElement,
) => void,
): void {
const restore = stubOffscreenCanvas(behavior);
try {
fn((imgId, behind = []) => {
const img = new FakeHTMLImageElement(imgId);
return buildFakeIframeWithStack([img, ...behind]);
});
} finally {
restore();
}
}
describe("WS-G: z-stack fallthrough via mock elementsFromPoint", () => {
// Clear the module-level canvas cache before each test so stubs from a
// previous test don't affect the next one.
beforeEach(() => {
_imgCanvasCache.clear();
});
it("non-image hit resolves normally (WS-A1 regression)", () => {
// No images in the stack — should behave exactly like WS-A1.
const div = fakeEl({ "data-hf-id": "hf-div" }, "DIV");
const iframe = buildFakeIframeWithStack([div]);
const adapter = createIframePreviewAdapter(iframe);
const result = adapter.elementAtPoint(50, 50);
expect(result).toEqual({ id: "hf-div", tag: "div" });
});
it("opaque image hit resolves to the image element", () => {
withCanvasStub("opaque", (makeImgStack) => {
const iframe = makeImgStack("hf-img", [fakeEl({ "data-hf-id": "hf-behind" }, "DIV")]);
const result = createIframePreviewAdapter(iframe).elementAtPoint(50, 50);
expect(result).toEqual({ id: "hf-img", tag: "img" });
});
});
it("transparent image pixel falls through to the element behind", () => {
withCanvasStub("transparent", (makeImgStack) => {
const iframe = makeImgStack("hf-img", [fakeEl({ "data-hf-id": "hf-behind" }, "DIV")]);
const result = createIframePreviewAdapter(iframe).elementAtPoint(50, 50);
expect(result).toEqual({ id: "hf-behind", tag: "div" });
});
});
it("tainted canvas (SecurityError) falls back to treating pixel as opaque", () => {
// Taint fallback → opaque → hit the image, not the behind-layer element
withCanvasStub("tainted", (makeImgStack) => {
const iframe = makeImgStack("hf-tainted", [fakeEl({ "data-hf-id": "hf-behind" }, "DIV")]);
const result = createIframePreviewAdapter(iframe).elementAtPoint(50, 50);
expect(result).toEqual({ id: "hf-tainted", tag: "img" });
});
});
it("transparent image over transparent image falls through to the div behind both", () => {
// Two consecutive transparent fallthroughs — exercises the loop iterating
// past more than one transparent image before hitting an opaque layer.
withCanvasStub("transparent", (makeImgStack) => {
const img2 = new FakeHTMLImageElement("hf-img2");
const iframe = makeImgStack("hf-img1", [img2, fakeEl({ "data-hf-id": "hf-behind" }, "DIV")]);
const result = createIframePreviewAdapter(iframe).elementAtPoint(50, 50);
expect(result).toEqual({ id: "hf-behind", tag: "div" });
});
});
it("transparent image with no element behind returns null", () => {
// No behind element in stack — transparent hit returns null.
withCanvasStub("transparent", (makeImgStack) => {
const iframe = makeImgStack("hf-img");
const result = createIframePreviewAdapter(iframe).elementAtPoint(50, 50);
expect(result).toBeNull();
});
});
it("survives a window without HTMLImageElement and a doc without elementsFromPoint", () => {
const div = fakeEl({ "data-hf-id": "hf-div" }, "DIV");
const fakeIframe = (doc: unknown, win: unknown) =>
({ contentDocument: doc, contentWindow: win }) as unknown as HTMLIFrameElement;
const expected = { id: "hf-div", tag: "div" };
// No HTMLImageElement on the window — `instanceof` must not throw.
const noCtor = fakeIframe(
{ elementsFromPoint: () => [div] },
{ getComputedStyle: () => ({ opacity: "1" }) },
);
expect(createIframePreviewAdapter(noCtor).elementAtPoint(50, 50)).toEqual(expected);
// No elementsFromPoint on the doc — fall back to elementFromPoint.
const noStack = fakeIframe(
{ elementFromPoint: () => div },
{ HTMLImageElement: FakeHTMLImageElement, getComputedStyle: () => ({ opacity: "1" }) },
);
expect(createIframePreviewAdapter(noStack).elementAtPoint(50, 50)).toEqual(expected);
});
});
+794
View File
@@ -0,0 +1,794 @@
/**
* Same-origin iframe PreviewAdapter — WS-A1 (hit-test + selection) +
* WS-A2 (applyDraft / commitPreview / cancelPreview → moveElement) +
* WS-G (image-alpha hit-test, phase 1).
*
* Requirements:
* - The iframe MUST be same-origin (srcdoc / blob URL). Cross-origin access to
* contentDocument throws a DOMException; this adapter does not guard that —
* the caller is responsible for ensuring same-origin.
*
* Image-alpha (phase 1):
* - Replaces elementFromPoint with elementsFromPoint (z-stack) so transparent
* image hits fall through to the element behind.
* - For <img> hits, maps the client point to the natural-pixel coordinate
* (object-fit/object-position aware), draws to an offscreen canvas (cached
* by src + natural dimensions, so a srcset re-render gets a fresh canvas),
* samples alpha. Transparent pixel → miss, continue the stack.
* - Cross-origin images taint the canvas → getImageData throws SecurityError
* → falls back to treating the pixel as OPAQUE (never drop an unverifiable
* hit) and warns once per src. Callers must ensure CORS or accept the fallback.
* - A CSS rotation/skew on the image or an ancestor also falls back to opaque
* (the axis-aligned rect mapping can't sample a rotated image correctly);
* transform-inverse mapping is phase 2.
* - Images above a pixel budget skip alpha-testing (opaque) to bound canvas
* memory. Limitation: animated <img> (gif) or src swaps invalidate the cache
* only when currentSrc/dimensions change. Phase 1 is optimized for static images.
* - Phase 2 (full per-pixel alpha via drawElement rasterization) is NOT built
* here — gated on a perf spike.
*/
import {
EDIT_BASE_X_ATTR,
EDIT_BASE_Y_ATTR,
EDIT_ORIGINAL_TRANSLATE_ATTR,
applyPositionEditToElement,
composeTranslate,
readCurrentTranslate,
} from "@hyperframes/core/runtime/position-edits";
import type { PreviewAdapter, ElementAtPointResult, DraftProps } from "./types.js";
import type { EditOp, Composition } from "../types.js";
import { applyPatchesToDocument, applyOverrideSet } from "../engine/apply-patches.js";
// ─── Pure resolver (testable without a browser) ───────────────────────────────
/**
* Walk from `el` upward through parentElement, looking for the nearest node
* that carries `[data-hf-id]` and is NOT `[data-hf-root]`.
*
* Returns null when:
* - The walk exits the tree without finding `[data-hf-id]`
* - The matching node is `[data-hf-root]` (transparent to hit-testing)
* - `isVisible(node)` returns false for the matching node
*
* Keeping this a pure function (no elementFromPoint, no window access) makes
* it unit-testable in a plain Node environment.
*/
export function resolveNearestHfElement(
el: Element | null,
isVisible: (el: Element) => boolean,
): ElementAtPointResult | null {
let node = el;
while (node !== null) {
const id = node.getAttribute("data-hf-id");
if (id !== null) {
if (node.hasAttribute("data-hf-root")) return null;
if (!isVisible(node)) return null;
return { id, tag: node.tagName.toLowerCase() };
}
node = node.parentElement;
}
return null;
}
// ─── Draft position math (pure — testable without a browser) ─────────────────
/**
* Compute the new absolute x/y for a moveElement op given:
* - the element's current `data-x` / `data-y` string values (may be null)
* - the accumulated drag delta (dx, dy) from applyDraft calls
*
* `data-x` / `data-y` default to 0 when absent or non-numeric.
*/
export function computeDraftPosition(
dataX: string | null,
dataY: string | null,
dx: number,
dy: number,
): { x: number; y: number } {
const baseX = parseFloat(dataX ?? "0") || 0;
const baseY = parseFloat(dataY ?? "0") || 0;
return { x: baseX + dx, y: baseY + dy };
}
// ─── Image-alpha pure helpers (WS-G phase 1) ──────────────────────────────────
/**
* Returns true when the first pixel in `imageData` has alpha >= threshold.
*
* Pure — no DOM access; unit-testable with a plain Uint8ClampedArray.
* threshold defaults to 1 so a fully-transparent pixel (a=0) is a miss.
*/
export function alphaIsOpaque(imageData: ImageData, threshold = 1): boolean {
// ImageData.data is [R, G, B, A, R, G, B, A, ...]
const alpha = imageData.data[3] ?? 0;
return alpha >= threshold;
}
/**
* Map a client-space point to the natural-pixel coordinates of the image.
*
* Handles object-fit: fill | cover | contain (default=fill when unset).
* object-position is parsed as two percentage/px values (default "50% 50%").
*
* Returns null when the point falls outside the rendered image area (e.g.
* the letterbox region of a contain-fitted image). A null result means the
* image does not own this pixel — the caller should continue the z-stack.
*
* Pure — no DOM/window access; unit-testable with plain objects.
*/
// fallow-ignore-next-line complexity
export function mapPointToImagePixel(
rect: { left: number; top: number; width: number; height: number },
natural: { width: number; height: number },
objectFit: string,
objectPosition: string,
point: { x: number; y: number },
): { px: number; py: number } | null {
// Local coords within the CSS box
const lx = point.x - rect.left;
const ly = point.y - rect.top;
if (lx < 0 || ly < 0 || lx > rect.width || ly > rect.height) return null;
const fit = objectFit || "fill";
// For fill (or unrecognized values): the natural image is stretched to the
// box; direct linear mapping.
if (fit !== "cover" && fit !== "contain" && fit !== "none") {
if (rect.width === 0 || rect.height === 0) return null;
const px = Math.floor((lx / rect.width) * natural.width);
const py = Math.floor((ly / rect.height) * natural.height);
return { px: clamp(px, 0, natural.width - 1), py: clamp(py, 0, natural.height - 1) };
}
// For none: image is drawn at its natural size; no scaling.
if (fit === "none") {
const pos = parseObjectPosition(objectPosition, rect, natural);
const ox = pos.x;
const oy = pos.y;
const px = Math.floor(lx - ox);
const py = Math.floor(ly - oy);
if (px < 0 || py < 0 || px >= natural.width || py >= natural.height) return null;
return { px, py };
}
// cover: scale uniformly so the image covers the box; may clip edges.
// contain: scale uniformly so the image fits within the box; may letterbox.
if (natural.width === 0 || natural.height === 0) return null;
const scaleX = rect.width / natural.width;
const scaleY = rect.height / natural.height;
const scale = fit === "cover" ? Math.max(scaleX, scaleY) : Math.min(scaleX, scaleY);
const renderedW = natural.width * scale;
const renderedH = natural.height * scale;
const pos = parseObjectPosition(objectPosition, rect, {
width: renderedW,
height: renderedH,
});
// Offset of the rendered image's top-left within the CSS box
const imgLeft = pos.x;
const imgTop = pos.y;
// Local coords relative to the rendered image's top-left
const rx = lx - imgLeft;
const ry = ly - imgTop;
if (rx < 0 || ry < 0 || rx > renderedW || ry > renderedH) return null;
if (scale === 0) return null;
const px = Math.floor(rx / scale);
const py = Math.floor(ry / scale);
return { px: clamp(px, 0, natural.width - 1), py: clamp(py, 0, natural.height - 1) };
}
// ─── object-position parser (pure) ───────────────────────────────────────────
/**
* Parse a CSS object-position value into x/y offsets (top-left of the
* rendered content relative to the CSS box top-left).
*
* Supports the common subset: keyword pairs, percentage pairs, pixel pairs,
* and single-value shorthand. Mixed units (e.g. "50% 10px") are supported.
*
* Pure — no DOM access.
*/
function parseObjectPosition(
objectPosition: string,
box: { width: number; height: number },
content: { width: number; height: number },
): { x: number; y: number } {
const raw = (objectPosition || "50% 50%").trim();
const parts = raw.split(/\s+/);
// Resolve a single token into a pixel offset along the given axis.
// `available` is the "slack" (box dimension - content dimension).
// fallow-ignore-next-line complexity
function resolveToken(token: string, available: number): number {
if (token === "left" || token === "top") return 0;
if (token === "right" || token === "bottom") return available;
if (token === "center") return available / 2;
if (token.endsWith("%")) {
const pct = parseFloat(token) / 100;
return isNaN(pct) ? available / 2 : pct * available;
}
if (token.endsWith("px")) {
const px = parseFloat(token);
return isNaN(px) ? available / 2 : px;
}
// Bare number — treat as px
const n = parseFloat(token);
return isNaN(n) ? available / 2 : n;
}
const availX = box.width - content.width;
const availY = box.height - content.height;
const isVert = (t: string) => t === "top" || t === "bottom";
const isHoriz = (t: string) => t === "left" || t === "right";
if (parts.length === 1) {
const tokenX = parts[0] ?? "50%";
// Single value: if it's a vertical keyword the x defaults to center
if (isVert(tokenX)) {
return { x: availX / 2, y: resolveToken(tokenX, availY) };
}
return { x: resolveToken(tokenX, availX), y: availY / 2 };
}
// Keyword pairs may be given vertical-first ("bottom left"); normalize so the
// first token addresses the x-axis and the second the y-axis.
let xToken = parts[0] ?? "50%";
let yToken = parts[1] ?? "50%";
if (isVert(xToken) || isHoriz(yToken)) {
[xToken, yToken] = [yToken, xToken];
}
return {
x: resolveToken(xToken, availX),
y: resolveToken(yToken, availY),
};
}
function clamp(v: number, min: number, max: number): number {
return v < min ? min : v > max ? max : v;
}
// ─── Visibility check ─────────────────────────────────────────────────────────
/**
* Returns true when no element in the ancestor chain (inclusive) has
* computed opacity === 0. Checks ancestors because a parent at opacity:0
* makes the child invisible even if the child's own opacity is 1.
*
* This reflects the current GSAP timeline state (whatever the player has
* seeked to). For atTime values matching the live playhead this is always
* accurate. For speculative times this is NOT seeked — WS-A1 does not mutate
* the timeline; accurate out-of-band opacity queries are WS-G follow-on.
*/
function isOpacityVisible(el: Element, win: Window & typeof globalThis): boolean {
let node: Element | null = el;
while (node !== null) {
const style = win.getComputedStyle(node);
if (parseFloat(style.opacity) === 0) return false;
node = node.parentElement;
}
return true;
}
// ─── Image-alpha canvas cache (WS-G phase 1) ─────────────────────────────────
/**
* Cache of offscreen canvases keyed by image currentSrc.
*
* Canvases are drawn once; the same canvas is reused across hit-tests.
* Animated images (gif) or dynamic src swaps are NOT tracked — this is a
* phase-1 static-image optimization. A tainted entry stores null to record
* that the image is cross-origin and all pixels should be treated as opaque.
*
* Exported for tests that need to reset the cache between runs.
*/
export const _imgCanvasCache = new Map<string, OffscreenCanvas | null>();
/**
* Bounded cap so a long session can't accumulate one full-resolution
* OffscreenCanvas per image src indefinitely.
* ponytail: FIFO eviction, upgrade to LRU if cache hit-rate matters.
*/
const _IMG_CANVAS_CACHE_MAX = 64;
function cacheCanvas(key: string, value: OffscreenCanvas | null): void {
if (!_imgCanvasCache.has(key) && _imgCanvasCache.size >= _IMG_CANVAS_CACHE_MAX) {
const oldest = _imgCanvasCache.keys().next().value;
if (oldest !== undefined) _imgCanvasCache.delete(oldest);
}
_imgCanvasCache.set(key, value);
}
/**
* Skip alpha-testing images above this pixel budget — allocating a full-res
* OffscreenCanvas for one hit-test is memory-prohibitive (a 4000×3000 image is
* ~46 MB). Above the cap we fail safe to opaque.
* ponytail: per-image pixel cap; a byte-budget cache is phase 2.
*/
const _MAX_ALPHA_TEST_PIXELS = 16_000_000;
/**
* Srcs we've already warned about taint for — keeps the cross-origin warning to
* once per image instead of once per hit-test. Cleared with `_imgCanvasCache`
* is not necessary; suppressing duplicate warnings across resets is harmless.
*/
const _warnedTaintSrcs = new Set<string>();
function warnTaintOnce(src: string): void {
if (_warnedTaintSrcs.has(src)) return;
_warnedTaintSrcs.add(src);
// Visibility for the silent-failure path: a cross-origin / uncorsed image
// taints the canvas, so alpha hit-test is unavailable and we fall back to
// opaque. Without this, the fall-back is invisible ("hit-test feels wrong").
console.warn(
`[hyperframes] image-alpha hit-test unavailable for cross-origin/tainted image; treating as opaque: ${src}`,
);
}
/**
* True when the element or any ancestor carries a CSS rotation or skew. Such a
* transform makes getBoundingClientRect() return the axis-aligned bounding box,
* so the rect→natural-pixel mapping would sample the wrong pixel. Pure translate
* / scale keep the matrix b and c components at 0 and map correctly. No-op
* (returns false) when DOMMatrix is unavailable (e.g. the test env), preserving
* existing behavior there.
*/
function hasRotationOrSkew(el: Element | null, win: Window & typeof globalThis): boolean {
if (typeof win.DOMMatrix !== "function") return false;
for (let node: Element | null = el; node; node = node.parentElement) {
const t = win.getComputedStyle(node).transform;
if (!t || t === "none") continue;
try {
const m = new win.DOMMatrix(t);
if (Math.abs(m.b) > 1e-6 || Math.abs(m.c) > 1e-6) return true;
} catch {
return true; // unparseable transform — fail safe (treat as non-axis-aligned)
}
}
return false;
}
/**
* Sample the alpha at (clientX, clientY) for an <img> element.
*
* Returns true (opaque) when:
* - The image has not finished loading (naturalWidth/naturalHeight === 0)
* - The point maps outside the rendered image area (not this image's pixel)
* - The canvas is tainted (cross-origin, SecurityError) — fallback: opaque
* - Alpha >= 1
*
* Returns false (transparent/miss) only when the canvas is readable AND the
* alpha at the mapped pixel is 0. A click on the element's border/padding maps
* outside the content box → also false (the click falls through to the layer
* behind), since border/padding pixels aren't part of the image — intentional.
*
* `win` is the iframe's contentWindow, used to call getComputedStyle on the
* element which lives in the iframe's document.
*/
// fallow-ignore-next-line complexity
function imageAlphaOpaqueAt(
img: HTMLImageElement,
clientX: number,
clientY: number,
win: Window & typeof globalThis,
): boolean {
// Not loaded yet — treat as opaque (safe fallback)
if (img.naturalWidth === 0 || img.naturalHeight === 0) return true;
const src = img.currentSrc || img.src;
if (!src) return true;
// CSS rotation/skew on the image or an ancestor breaks the axis-aligned
// rect→natural-pixel mapping below (getBoundingClientRect returns the AABB),
// so we'd sample the wrong pixel. Fail safe to opaque rather than guess.
// Full transform-inverse mapping is phase 2.
if (hasRotationOrSkew(img, win)) return true;
// Pathological-size guard: don't allocate a huge canvas for one hit-test.
if (img.naturalWidth * img.naturalHeight > _MAX_ALPHA_TEST_PIXELS) return true;
// object-fit/object-position lay the image out within the CONTENT box, not
// the border box that getBoundingClientRect() returns. Inset by border +
// padding so the mapping is correct for an <img> that has a border or padding.
const rect = img.getBoundingClientRect();
const style = win.getComputedStyle(img);
const borderL = parseFloat(style.borderLeftWidth) || 0;
const borderT = parseFloat(style.borderTopWidth) || 0;
const borderR = parseFloat(style.borderRightWidth) || 0;
const borderB = parseFloat(style.borderBottomWidth) || 0;
const padL = parseFloat(style.paddingLeft) || 0;
const padT = parseFloat(style.paddingTop) || 0;
const padR = parseFloat(style.paddingRight) || 0;
const padB = parseFloat(style.paddingBottom) || 0;
const objectFit = style.objectFit || "fill";
const objectPosition = style.objectPosition || "50% 50%";
const mapped = mapPointToImagePixel(
{
left: rect.left + borderL + padL,
top: rect.top + borderT + padT,
width: rect.width - borderL - borderR - padL - padR,
height: rect.height - borderT - borderB - padT - padB,
},
{ width: img.naturalWidth, height: img.naturalHeight },
objectFit,
objectPosition,
{ x: clientX, y: clientY },
);
// Point is outside the rendered image area — not this image's pixel.
// Continue the z-stack (return false = miss on this element).
if (mapped === null) return false;
// Retrieve or build the offscreen canvas. Key on src + natural dimensions: a
// srcset/responsive layout can serve the same URL at a different natural size,
// and keying on src alone would reuse a canvas drawn at the prior dimensions.
const cacheKey = `${src}@${img.naturalWidth}x${img.naturalHeight}`;
let canvas: OffscreenCanvas | null | undefined = _imgCanvasCache.get(cacheKey);
if (canvas === undefined) {
// First time: draw to an offscreen canvas and cache.
try {
const oc = new OffscreenCanvas(img.naturalWidth, img.naturalHeight);
const ctx = oc.getContext("2d");
if (!ctx) {
// OffscreenCanvas 2D unavailable — treat as opaque.
cacheCanvas(cacheKey, null);
return true;
}
ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight);
cacheCanvas(cacheKey, oc);
canvas = oc;
} catch {
// SecurityError from tainted canvas — record null and fall back opaque.
warnTaintOnce(src);
cacheCanvas(cacheKey, null);
return true;
}
}
// null means we already know this src is tainted — treat as opaque.
if (canvas === null) return true;
try {
const ctx = canvas.getContext("2d");
if (!ctx) return true;
// The mapped-pixel read also surfaces lazy canvas taint (SecurityError),
// so no separate taint probe is needed.
const data = ctx.getImageData(mapped.px, mapped.py, 1, 1);
return alphaIsOpaque(data);
} catch {
// Taint discovered on getImageData — update cache and fall back opaque.
warnTaintOnce(src);
cacheCanvas(cacheKey, null);
return true;
}
}
// ─── IframePreviewAdapter ─────────────────────────────────────────────────────
/**
* The hit-test z-stack at (x, y): the full elementsFromPoint stack, or a
* single-element fallback for hosts that lack elementsFromPoint.
*/
function hitStack(doc: Document, x: number, y: number): Element[] {
if (typeof doc.elementsFromPoint === "function") return doc.elementsFromPoint(x, y);
const top = doc.elementFromPoint(x, y);
return top ? [top] : [];
}
type SelectionHandler = (ids: string[]) => void;
class IframePreviewAdapter implements PreviewAdapter {
private readonly iframe: HTMLIFrameElement;
private readonly _dispatch: ((op: EditOp) => void) | undefined;
private _selection: string[] = [];
private _handlers: SelectionHandler[] = [];
/** Tracked id and element for the in-progress drag. */
private _draftId: string | null = null;
private _draftEl: HTMLElement | null = null;
/** Accumulated drag deltas from applyDraft calls. */
private _draftDx = 0;
private _draftDy = 0;
/**
* The element's effective `translate` when the drag started (inline value,
* or computed when no inline one was set; "" = none). Drafts compose onto
* this.
*/
private _draftPrevTranslate: string | null = null;
/**
* The element's raw INLINE `translate` when the drag started ("" = not
* inline). Reverts restore exactly this, so a stylesheet-authored translate
* is never promoted to a permanent inline style.
*/
private _draftPrevInlineTranslate: string | null = null;
/** Unsubscribe for the current attachSync subscription, if any. */
private _syncDetach: (() => void) | null = null;
constructor(iframe: HTMLIFrameElement, dispatch?: (op: EditOp) => void) {
this.iframe = iframe;
this._dispatch = dispatch;
}
/**
* Synchronous hit-test. Returns the nearest `[data-hf-id]` element under
* (x, y) in the iframe's coordinate space, or null for a transparent hit
* (root, opacity-0, nothing at all, or a transparent image pixel).
*
* WS-G phase 1: uses elementsFromPoint (z-stack) so a transparent-image hit
* falls through to the layer behind. For <img> elements, the alpha at the
* mapped natural pixel is sampled from an offscreen canvas. Cross-origin
* images that taint the canvas are treated as opaque (safe fallback).
*
* atTime: reflects the GSAP state at the playhead when this is called.
* Seeking to a different time to check visibility is WS-G follow-on.
*/
elementAtPoint(x: number, y: number, _opts?: { atTime?: number }): ElementAtPointResult | null {
const doc = this.iframe.contentDocument;
if (!doc) return null;
const win = this.iframe.contentWindow as (Window & typeof globalThis) | null;
if (!win) return null;
const stack = hitStack(doc, x, y);
for (const candidate of stack) {
// One opacity walk per candidate (candidate → root). An opacity:0 element
// is skipped, so the click falls through to the layer painted behind it.
if (!isOpacityVisible(candidate, win)) continue;
// Image-alpha check: if this is an <img>, verify the pixel is opaque.
if (win.HTMLImageElement && candidate instanceof win.HTMLImageElement) {
if (!imageAlphaOpaqueAt(candidate, x, y, win)) {
// Transparent pixel — fall through to the next element in the stack.
continue;
}
}
// The candidate's whole ancestor chain is already known visible (the walk
// above covers it, and the hf node is on that chain), so the resolver
// needs no second visibility walk.
const result = resolveNearestHfElement(candidate, () => true);
if (result !== null) return result;
}
return null;
}
/**
* Visually translate the target element inside the iframe at 60fps without
* touching the model: sets the element's `translate` to its pre-drag value
* composed with the accumulated delta. `translate` set after GSAP's first
* parse is untouched by seeks, so this renders correctly on animated
* elements too. (The `--hf-studio-dx/dy` custom properties are no longer
* written — compositions with the authored Studio drag-bridge CSS would
* move by twice the delta if both channels applied.)
*
* Calling applyDraft with a new id switches the tracked element, reverting
* the previous element's draft translate first.
*
* width/height in DraftProps are not yet wired (resize → setStyle, future op).
*/
applyDraft(id: string, props: DraftProps): void {
const el = this._resolveDraftElement(id);
if (!el) return;
if (props.dx !== undefined) this._draftDx = props.dx;
if (props.dy !== undefined) this._draftDy = props.dy;
el.style.setProperty(
"translate",
composeTranslate(this._draftPrevTranslate ?? "", `${this._draftDx}px`, `${this._draftDy}px`),
);
}
/**
* Resolve and track the drag target. Reuses the tracked element across the
* 60fps drag; only re-queries when the id changes or the cached node
* detached (e.g. an iframe reload mid-drag). Switching to a different
* element reverts the previous one's draft first, then captures the new
* element's pre-drag translate.
*/
private _resolveDraftElement(id: string): HTMLElement | null {
const doc = this.iframe.contentDocument;
if (!doc) return null;
const cached = id === this._draftId && this._draftEl?.isConnected ? this._draftEl : null;
const el =
cached ??
doc.querySelector<HTMLElement>(
`[data-hf-id="${id.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`,
);
if (!el) return null;
if (el !== this._draftEl) {
// Abandoning a prior target mid-drag must not leave it displaced.
this._revertDraftTranslate();
this._draftDx = 0;
this._draftDy = 0;
this._draftPrevTranslate = readCurrentTranslate(el);
const inline = el.style.getPropertyValue("translate").trim();
this._draftPrevInlineTranslate = inline === "none" ? "" : inline;
}
this._draftId = id;
this._draftEl = el;
return el;
}
/**
* Read the accumulated draft deltas, derive a moveElement op, dispatch it,
* then clear the draft state.
*
* No-ops (reverting any draft translate) when:
* - No applyDraft was called (nothing to commit)
* - No dispatch callback was provided at construction
*
* If dispatch throws (e.g. the model no longer has the element), the draft
* translate is reverted and the error propagates — the element is never
* left displaced by an uncommitted draft.
*/
commitPreview(): void {
if (!this._draftId || !this._draftEl || !this._dispatch) {
this._revertDraftTranslate();
this._clearDraft();
return;
}
const el = this._draftEl;
const dataX = el.getAttribute("data-x");
const dataY = el.getAttribute("data-y");
const { x, y } = computeDraftPosition(dataX, dataY, this._draftDx, this._draftDy);
try {
this._dispatch({ type: "moveElement", target: this._draftId, x, y });
} catch (err) {
this._revertDraftTranslate();
this._clearDraft();
throw err;
}
this._mirrorCommittedMove(el, dataX, dataY, x, y);
this._clearDraft();
}
/**
* Mirror a committed move onto the live element so the position holds
* without a document reload — same attributes handleMoveElement writes
* into the model, rendered by the runtime's position-edit translate.
*
* The pre-edit translate is stamped from the value captured at drag start
* (the element's current inline translate is the draft-composed one, which
* must not be mistaken for the original), then the final translate is
* recomputed the same way the runtime does at bind time.
*/
private _mirrorCommittedMove(
el: HTMLElement,
dataX: string | null,
dataY: string | null,
x: number,
y: number,
): void {
if (el.getAttribute(EDIT_BASE_X_ATTR) === null) {
el.setAttribute(EDIT_BASE_X_ATTR, dataX ?? "0");
}
if (el.getAttribute(EDIT_BASE_Y_ATTR) === null) {
el.setAttribute(EDIT_BASE_Y_ATTR, dataY ?? "0");
}
if (el.getAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR) === null) {
el.setAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR, this._draftPrevTranslate ?? "");
}
el.setAttribute("data-x", String(x));
el.setAttribute("data-y", String(y));
applyPositionEditToElement(el, { force: true });
}
/** Revert the draft translate without dispatching any op. */
cancelPreview(): void {
this._revertDraftTranslate();
this._clearDraft();
}
/**
* Restore the element's pre-drag INLINE `translate` (removing it when there
* was none, so a stylesheet-authored translate is never promoted to inline).
* NOT called on a successful commit — the committed position-edit translate
* is recomputed onto the element by _mirrorCommittedMove.
*/
private _revertDraftTranslate(): void {
if (!this._draftEl || this._draftPrevInlineTranslate === null) return;
if (this._draftPrevInlineTranslate === "") {
this._draftEl.style.removeProperty("translate");
} else {
this._draftEl.style.setProperty("translate", this._draftPrevInlineTranslate);
}
}
private _clearDraft(): void {
this._draftId = null;
this._draftEl = null;
this._draftDx = 0;
this._draftDy = 0;
this._draftPrevTranslate = null;
this._draftPrevInlineTranslate = null;
}
// Selection -----------------------------------------------------------------
select(ids: string[], opts?: { additive?: boolean }): void {
if (opts?.additive) {
const merged = new Set([...this._selection, ...ids]);
this._selection = [...merged];
} else {
this._selection = [...ids];
}
this._emit();
}
on(event: "selection", handler: SelectionHandler): () => void {
if (event !== "selection") return () => {};
this._handlers.push(handler);
return () => {
this._handlers = this._handlers.filter((h) => h !== handler);
};
}
private _emit(): void {
const ids = [...this._selection];
for (const h of this._handlers) h(ids);
}
/**
* Mirror `comp`'s edits onto this.iframe.contentDocument. See the
* PreviewAdapter interface doc for the full contract.
*/
attachSync(comp: Composition): () => void {
this._syncDetach?.();
const doc = this.iframe.contentDocument;
if (doc) {
try {
applyOverrideSet({ document: doc, wrapped: false, stamped: "" }, comp.getOverrides());
} catch (err) {
// Don't let a bad initial snapshot prevent the ongoing subscription
// below from attaching — future patches should still mirror even if
// this composition's current overrides couldn't be applied.
console.warn("[hyperframes] attachSync: initial override sync failed:", err);
}
}
const rawUnsubscribe = comp.on("patch", ({ patches }) => {
const liveDoc = this.iframe.contentDocument;
if (!liveDoc) return;
applyPatchesToDocument(
{ document: liveDoc, wrapped: false, stamped: "" },
// "Never mirror script-tag rewrites" is the documented contract, not
// just today's one known path — startsWith so a future script kind
// (e.g. "/script/label") is covered by the same intent, not just an
// exact string this filter happens to know about today.
patches.filter((p) => !p.path.startsWith("/script/")),
);
});
const detach = (): void => {
rawUnsubscribe();
if (this._syncDetach === detach) this._syncDetach = null;
};
this._syncDetach = detach;
return detach;
}
}
export function createIframePreviewAdapter(
iframe: HTMLIFrameElement,
dispatch?: (op: EditOp) => void,
): PreviewAdapter {
return new IframePreviewAdapter(iframe, dispatch);
}
+62
View File
@@ -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<string, string>();
private readonly history = new Map<string, PersistVersionEntry[]>();
private readonly errorListeners: Array<(e: PersistErrorEvent) => void> = [];
private versionCounter = 0;
private faultMessage: string | null = null;
async read(path: string): Promise<string | undefined> {
return this.store.get(path);
}
async write(path: string, content: string): Promise<void> {
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<void> {
// Memory adapter writes are synchronous — nothing to drain.
}
async listVersions(path: string): Promise<PersistVersionEntry[]> {
return [...(this.history.get(path) ?? [])];
}
async loadFrom(path: string, versionKey: string): Promise<string | undefined> {
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();
}
@@ -0,0 +1,137 @@
/**
* 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 { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, it, expect, vi } from "vitest";
import { createMemoryAdapter } from "./memory.js";
import { createFsAdapter } from "./fs.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", "<html></html>");
expect(await adapter.read("comp.html")).toBe("<html></html>");
});
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);
// Run against the fs adapter — each test gets an isolated tmpdir
runPersistAdapterContract("fs", () =>
createFsAdapter({ root: mkdtempSync(join(tmpdir(), "hf-fs-test-")) }),
);
+96
View File
@@ -0,0 +1,96 @@
import type { PersistErrorEvent, Composition } from "../types.js";
// ─── PersistAdapter ───────────────────────────────────────────────────────────
export interface PersistVersionEntry {
/** Opaque key identifying this version (adapter-defined format) */
key: string;
/** Full HTML content — may be omitted by adapters that load content lazily via loadFrom() */
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;
// Stage 8 prep: fired when the preview host changes selection (e.g. user clicks an element).
// Not wired up in stage 7 — callers listen to the session's own selectionchange event instead.
on(event: "selection", handler: (ids: string[]) => void): () => void;
/**
* Mirror this composition's edits onto the adapter's own live document —
* an immediate full sync of the composition's CURRENT overrides, then a
* subscription that replays every future patch (including undo/redo).
* `/script/gsap` patches are never mirrored (re-executing a live <script>
* tag doesn't work and would conflict with running GSAP state).
* Calling this again while already attached detaches the previous
* subscription first. Returns an unsubscribe.
*/
attachSync(comp: Composition): () => void;
/**
* Optional: apply composition-variable values to the preview so it renders
* as `window.__hfVariables` injection would at render time (values must be
* visible to the runtime BEFORE composition scripts run — typically a
* preview reload with injection, not a live poke). Pass null to restore
* declared defaults. Values are ephemeral preview state, never persisted.
*/
setPreviewVariables?(values: Record<string, unknown> | null): void;
}
+292
View File
@@ -0,0 +1,292 @@
/**
* 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, isCompositionTemplate } from "@hyperframes/parsers/hf-ids";
import { parseGsapScriptAcornForWrite } from "@hyperframes/core/gsap-parser-acorn";
import {
findRoot,
getElementStyles,
getGsapScripts,
getOwnText,
isNewHostBoundary,
querySelectorAllDeep,
} from "./engine/model.js";
import type { HyperFramesElement, SdkDocument } from "./types.js";
// Tags that carry no editable content and must not enter the element tree.
const EXCLUDED_TAGS = new Set([
"script",
"style",
"template",
"meta",
"link",
"noscript",
"base",
"head",
]);
// Snapshot text is TRIMMED for display (markup indentation produces noisy
// whitespace text nodes). The raw text target is shared with setText so shadow
// value checks and dispatch serialization use the same DOM target.
function snapshotText(el: Element): string | null {
const trimmed = getOwnText(el).trim();
return trimmed.length > 0 ? trimmed : null;
}
// Parsing the GSAP script (acorn AST walk) is the expensive part and depends
// only on the script text, so memoize the {tween id, selector} pairs by script.
// Selector→hf-id resolution still runs each call — it depends on the live DOM,
// which changes on dispatch. Single-entry cache covers the hot path (same comp,
// repeated getElements() rebuilds) and stays bounded.
let gsapLocatedCacheKey: string | null = null;
let gsapLocatedCacheVal: Array<{ id: string; selector: string }> = [];
function parseLocatedCached(script: string): Array<{ id: string; selector: string }> {
if (gsapLocatedCacheKey === script) return gsapLocatedCacheVal;
const parsed = parseGsapScriptAcornForWrite(script);
gsapLocatedCacheVal = parsed
? parsed.located.map(({ id, animation }) => ({ id, selector: animation.targetSelector }))
: [];
gsapLocatedCacheKey = script;
return gsapLocatedCacheVal;
}
/**
* Map each element's data-hf-id → the GSAP tween ids targeting it. Tween ids
* come from the acorn parser's stable `targetSelector-method-position` scheme —
* the SAME id-space the studio-api read path and the SDK GSAP ops use, so these
* ids are dispatchable as-is via setGsapTween/removeGsapTween. Best-effort: a
* malformed selector or unparseable script yields no entries (animationIds: []).
*/
function buildAnimationIdMap(document: Document): Map<string, string[]> {
const map = new Map<string, string[]>();
for (const script of getGsapScripts(document)) {
for (const { id, selector } of parseLocatedCached(script)) {
appendAnimationIdsForSelector(map, document, id, selector);
}
}
return map;
}
function appendAnimationIdsForSelector(
map: Map<string, string[]>,
document: Document,
animationId: string,
selector: string,
): void {
if (!selector) return;
let matches: Element[];
try {
matches = querySelectorAllDeep(document, selector);
} catch {
return; // selector not valid for querySelectorAll — skip
}
for (const el of matches) {
const hfId = el.getAttribute("data-hf-id");
if (!hfId) continue;
const list = map.get(hfId);
if (list) list.push(animationId);
else map.set(hfId, [animationId]);
}
}
/**
* Every GSAP tween id `parseLocatedCached` finds in the script, with no DOM
* matching at all — the same id space the server-side script ops
* (removeAllKeyframesFromScript et al.) resolve against. Unlike
* buildAnimationIdMap's per-element map, this never drops an id just because
* its selector doesn't currently CSS-match a live element — that gap is what
* caused a false animation_not_found divergence in the resolver-shadow
* tripwire (a tween on a renamed/duplicate/scoped selector still resolves on
* the server, which reads the script directly).
*/
export function parsedAnimationIds(script: string): Set<string> {
return new Set(parseLocatedCached(script).map(({ id }) => id));
}
/**
* Build the element list for a parent's children, treating a COMPOSITION
* template (`<template data-composition-id>`) as a TRANSPARENT container: its
* inner elements are spliced in at the template's position, the template
* itself gets no node. This mirrors the studio preview, which unwraps exactly
* that pattern into the served body — so template-based sub-comps expose the
* same elements (and hf-ids) here as the timeline reads from the live preview
* DOM. A plain <template> (runtime clone-source) stays fully excluded: its
* inert interior is not editable and its content is duplicated at runtime.
*/
function buildChildren(
parent: Element,
scopePrefix: string,
animationIdsByHfId: Map<string, string[]>,
): HyperFramesElement[] {
const out: HyperFramesElement[] = [];
for (const child of Array.from(parent.children)) {
if (child.tagName.toLowerCase() === "template") {
if (isCompositionTemplate(child)) {
out.push(...buildChildren(child, scopePrefix, animationIdsByHfId));
}
continue;
}
const built = buildElement(child, scopePrefix, animationIdsByHfId);
if (built) out.push(built);
}
return out;
}
// fallow-ignore-next-line complexity
function buildElement(
el: Element,
scopePrefix: string,
animationIdsByHfId: Map<string, string[]>,
): 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
// scopedId: if we're inside a sub-comp scope, prefix with "scopePrefix/".
// The host element itself is in the PARENT scope (no prefix change for its own id).
const scopedId = scopePrefix ? `${scopePrefix}/${id}` : id;
// Children inherit the scope prefix from their parent.
// If this element is a new host boundary (starts a new sub-comp scope), its
// children use THIS element's scopedId as their prefix.
// Otherwise, children inherit the same prefix that this element used.
const childPrefix = isNewHostBoundary(el) ? scopedId : scopePrefix;
const inlineStyles = getElementStyles(el);
const classAttr = el.getAttribute("class") ?? "";
const classNames = classAttr
.split(/\s+/)
.map((c) => c.trim())
.filter(Boolean);
const attributes: Record<string, string> = {};
for (const attr of Array.from(el.attributes)) {
if (attr.name === "style" || attr.name === "class" || attr.name.startsWith("data-hf-")) {
continue;
}
attributes[attr.name] = attr.value;
}
const startAttr = el.getAttribute("data-start");
const endAttr = el.getAttribute("data-end");
const trackAttr = el.getAttribute("data-track-index");
const start = startAttr !== null ? parseFloat(startAttr) : null;
const duration =
start !== null && endAttr !== null ? Math.max(0, parseFloat(endAttr) - start) : null;
const trackIndex = trackAttr !== null ? parseInt(trackAttr, 10) : null;
const children = buildChildren(el, childPrefix, animationIdsByHfId);
return {
id,
scopedId,
tag,
children,
inlineStyles,
classNames,
attributes,
text: snapshotText(el),
start,
duration,
trackIndex,
animationIds: animationIdsByHfId.get(id) ?? [],
};
}
// fallow-ignore-next-line complexity
function extractGsapScript(doc: Document): string | null {
return getGsapScripts(doc)[0] ?? null;
}
function extractStyles(doc: Document): string | null {
const styleEl = doc.querySelector("style");
return styleEl ? styleEl.textContent : null;
}
// Root resolution delegates to the engine's findRoot so dimension extraction
// and mutations agree on which element is the composition root.
// fallow-ignore-next-line complexity
function extractDimensions(doc: Document): { width: number | null; height: number | null } {
const stage = findRoot(doc);
if (!stage) return { width: null, height: null };
// data-width/data-height are the runtime's forced override — prefer them.
const wAttr = stage.getAttribute("data-width");
const hAttr = stage.getAttribute("data-height");
const style = (stage as HTMLElement).getAttribute?.("style") ?? "";
const wm = /width:\s*(\d+)px/.exec(style);
const hm = /height:\s*(\d+)px/.exec(style);
return {
width: wAttr !== null ? parseInt(wAttr, 10) : wm ? parseInt(wm[1] ?? "", 10) : null,
height: hAttr !== null ? parseInt(hAttr, 10) : hm ? parseInt(hm[1] ?? "", 10) : null,
};
}
function extractDuration(doc: Document): number | null {
const root = findRoot(doc) ?? doc.body;
const dur = root?.getAttribute("data-duration");
return dur ? parseFloat(dur) : null;
}
/**
* Build the element tree from an already-parsed (hf-id-stamped) linkedom Document.
* Walks the live DOM directly — no serialize/re-parse round trip. This is what
* the session's query API uses against its mutable document.
*/
export function buildRoots(document: Document): HyperFramesElement[] {
const body = document.body;
if (!body) return [];
return buildChildren(body, "", buildAnimationIdMap(document));
}
/**
* Parse an HTML string into the SDK document model.
* Calls ensureHfIds first so every element has a stable data-hf-id.
* Uses linkedom — node-safe (works in agents, CI, server-side).
*/
export function buildDocument(html: string): SdkDocument {
const stamped = ensureHfIds(html);
const hasShell = /<!doctype|<html[\s>]/i.test(stamped);
const wrapped = !hasShell;
const { document } = wrapped
? parseHTML(`<!DOCTYPE html><html><head></head><body>${stamped}</body></html>`)
: parseHTML(stamped);
const dims = extractDimensions(document);
return {
roots: buildRoots(document),
gsapScript: extractGsapScript(document),
styles: extractStyles(document),
width: dims.width,
height: dims.height,
compositionDuration: extractDuration(document),
html: stamped,
};
}
/** Flat walk of the element tree — returns every element in document order */
export function flatElements(roots: readonly HyperFramesElement[]): HyperFramesElement[] {
const result: HyperFramesElement[] = [];
function walk(el: HyperFramesElement) {
result.push(el);
for (const child of el.children) walk(child);
}
for (const root of roots) walk(root);
return result;
}
@@ -0,0 +1,46 @@
import { describe, expect, it, beforeEach } from "vitest";
import { Window } from "happy-dom";
import { resolveElementAffordances } from "./affordances";
let doc: Document;
beforeEach(() => {
const win = new Window();
doc = win.document as unknown as Document;
});
function el(html: string): HTMLElement {
doc.body.innerHTML = html;
const node = doc.body.firstElementChild;
const view = doc.defaultView;
if (!view) throw new Error("no defaultView");
if (!(node instanceof view.HTMLElement)) throw new Error("expected HTMLElement");
return node as unknown as HTMLElement;
}
describe("resolveElementAffordances (live DOM)", () => {
it("video element with model => media + colorGrading, existsInSource", () => {
const v = el(`<video data-hf-id="hf-v" style="position:absolute;left:10px;top:20px"></video>`);
const a = resolveElementAffordances(v, { text: null, animationIds: [], start: null });
expect(a.sections).toMatchObject({ media: true, colorGrading: true });
expect(a.capabilities.canSelect).toBe(true);
});
it("absolutely-positioned div with inline left/top => canMove", () => {
const d = el(`<div style="position:absolute;left:5px;top:5px"></div>`);
const a = resolveElementAffordances(d, { text: null, animationIds: [], start: null });
expect(a.capabilities.canMove).toBe(true);
});
it("model text => text section; model animationIds => timing+animation", () => {
const d = el(`<div></div>`);
const a = resolveElementAffordances(d, { text: "hello", animationIds: ["t1", "t2"], start: 0 });
expect(a.sections).toMatchObject({ text: true, timing: true, animation: true });
});
it("null model => existsInSource false (not in model)", () => {
const d = el(`<div></div>`);
const a = resolveElementAffordances(d, null);
expect(a.capabilities.canEditStyles).toBe(false);
});
});
+62
View File
@@ -0,0 +1,62 @@
/**
* Browser-only editing-affordance resolver. Reads live layout (getComputedStyle)
* from a rendered element and combines it with the SDK model element to call the
* pure core resolver. MUST NOT be imported on the static/Node SDK path — it
* touches getComputedStyle and only resolves meaningfully against a laid-out DOM.
*/
import { resolveEditingAffordances, type EditingAffordances } from "@hyperframes/core/editing";
import type { HyperFramesElement } from "../types.js";
export interface AffordanceContext {
/** Studio-app concepts; default false for a generic consumer with no such notion. */
isCompositionHost?: boolean;
isCompositionRoot?: boolean;
isInsideLockedComposition?: boolean;
isMasterView?: boolean;
}
type ModelFacts = Pick<HyperFramesElement, "text" | "animationIds" | "start">;
export function resolveElementAffordances(
liveEl: HTMLElement,
modelEl: ModelFacts | null,
ctx: AffordanceContext = {},
): EditingAffordances {
const view = liveEl.ownerDocument.defaultView;
const cs = view ? view.getComputedStyle(liveEl) : null;
const computedStyles: Record<string, string> | undefined = cs
? {
position: cs.getPropertyValue("position"),
left: cs.getPropertyValue("left"),
top: cs.getPropertyValue("top"),
width: cs.getPropertyValue("width"),
height: cs.getPropertyValue("height"),
transform: cs.getPropertyValue("transform"),
}
: undefined;
// Core reads position only from computedStyles; inlineStyles supplies the
// authored left/top/width/height that override the computed layout.
const inlineStyles: Record<string, string> = {
left: liveEl.style.getPropertyValue("left"),
top: liveEl.style.getPropertyValue("top"),
width: liveEl.style.getPropertyValue("width"),
height: liveEl.style.getPropertyValue("height"),
};
return resolveEditingAffordances({
hasStableTarget: true,
tag: liveEl.tagName.toLowerCase(),
inlineStyles,
computedStyles,
isCompositionHost: ctx.isCompositionHost ?? false,
isCompositionRoot: ctx.isCompositionRoot ?? false,
isInsideLockedComposition: ctx.isInsideLockedComposition ?? false,
isMasterView: ctx.isMasterView ?? false,
existsInSource: modelEl != null,
hasEditableText: modelEl?.text != null,
hasTimingStart: modelEl ? modelEl.start != null : liveEl.hasAttribute("data-start"),
animationCount: modelEl?.animationIds.length ?? 0,
});
}
+340
View File
@@ -0,0 +1,340 @@
/**
* 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, OverrideSet } from "../types.js";
import type { ParsedDocument } from "./model.js";
import {
findById,
findRoot,
declarationElement,
setElementStyles,
setOwnText,
setGsapScript,
setStyleSheet,
} from "./model.js";
import { keyToPath, stylePath } from "./patches.js";
import {
writeVariableDefault,
clearVariableDefault,
writeVariableDeclaration,
removeVariableDeclarationEntry,
} from "./variableModel.js";
function isRawDeclarationEntry(value: unknown): value is { id: string } & Record<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
typeof (value as { id?: unknown }).id === "string"
);
}
// ─── Path parser ────────────────────────────────────────────────────────────
interface ParsedPath {
type:
| "style"
| "text"
| "attribute"
| "timing"
| "hold"
| "element"
| "variable"
| "variableDeclaration"
| "metadata"
| "script"
| "stylesheet";
id?: string;
prop?: string;
field?: string;
}
// fallow-ignore-next-line complexity
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 varDeclM = /^\/variableDeclarations\/(.+)$/.exec(path);
if (varDeclM) return { type: "variableDeclaration", id: varDeclM[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] };
if (path === "/script/gsap") return { type: "script" };
if (path === "/style/css") return { type: "stylesheet" };
return null;
}
// ─── Variable JSON model helper ───────────────────────────────────────────────
/**
* Apply a variable patch to `data-composition-variables`. A remove op (null)
* deletes the declaration's `default` key, restoring its "no authored default"
* state — the exact inverse of a first-set that added a default to a
* default-less variable, so undo of such a set round-trips. A value op upserts
* the matching declaration's `default`. No-ops when the attr/decl is absent.
* Shares the model logic with mutate.ts via ./variableModel.ts.
*/
function applyVariableDefault(declEl: Element | null, id: string, newDefault: unknown): void {
if (newDefault === null) {
clearVariableDefault(declEl, id);
} else {
writeVariableDefault(declEl, id, newDefault);
}
}
// ─── Patch application ───────────────────────────────────────────────────────
/**
* Replay a stored override-set onto a freshly-parsed base document (T3 init).
* A null value means the property was explicitly deleted — emit a remove patch
* so the base document matches the session state. (Removing a non-existent
* property is a no-op in applyOne, so this is safe against fresh-base misses.)
*/
export function applyOverrideSet(parsed: ParsedDocument, overrides: OverrideSet): void {
const patches: JsonPatchOp[] = [];
const rootId = findRoot(parsed.document)?.getAttribute("data-hf-id") ?? null;
// Whole-declaration snapshots (varDecl.{id}) must replay BEFORE value keys
// (var.{id}): a declaration snapshot embeds the default at fold time, while
// var.{id} always carries the latest value — insertion order alone would let
// an older snapshot clobber a newer value.
const entries = Object.entries(overrides).sort(([a], [b]) => {
const aVar = a.startsWith("var.");
const bVar = b.startsWith("var.");
const aDecl = a.startsWith("varDecl.");
const bDecl = b.startsWith("varDecl.");
if (aVar && bDecl) return 1;
if (aDecl && bVar) return -1;
return 0; // stable — every other key keeps its insertion order
});
for (const [key, value] of entries) {
const path = keyToPath(key);
if (!path) continue;
if (value === null) {
patches.push({ op: "remove", path });
} else {
patches.push({ op: "replace", path, value });
}
// A scalar `var.{id}` override must also restore the `--{id}` CSS custom
// prop on the root. Current sessions persist a paired style override, but
// sets written before the model/CSS split only carry `var.{id}`; derive the
// CSS here so `var(--{id})` bindings rehydrate. Object (font/image) values
// are never CSS, so they are skipped.
if (rootId && key.startsWith("var.") && value !== null && typeof value !== "object") {
const cssPath = stylePath(rootId, `--${key.slice("var.".length)}`);
patches.push({ op: "replace", path: cssPath, value: String(value) });
} else if (rootId && key.startsWith("var.") && value === null) {
patches.push({ op: "remove", path: stylePath(rootId, `--${key.slice("var.".length)}`) });
}
}
applyPatchesToDocument(parsed, patches);
}
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 === "duration") {
// Patch value is the data-duration value — set directly.
if (patch.op === "remove") el.removeAttribute("data-duration");
else el.setAttribute("data-duration", 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 "variableDeclaration": {
if (!p.id) return;
if (patch.op === "remove") {
removeVariableDeclarationEntry(declarationElement(parsed.document, parsed.wrapped), p.id);
} else if (isRawDeclarationEntry(patch.value)) {
// Replay is faithful, not strict: inverse patches capture raw entries
// (loose hand-authored declarations included) and undo must restore
// them verbatim — gating on isCompositionVariable here would make
// undo of a remove/update on a loose entry silently no-op.
writeVariableDeclaration(declarationElement(parsed.document, parsed.wrapped), patch.value);
}
break;
}
case "variable": {
if (!p.id) return;
// B1: update the JSON model (data-composition-variables) so
// getVariables() returns the correct value in both preview and render.
// CSS compat is handled by explicit style-path patches emitted by mutate.ts,
// so we do NOT write CSS here — the style case above handles those patches.
applyVariableDefault(
declarationElement(parsed.document, parsed.wrapped),
p.id,
patch.op === "remove" ? null : patch.value,
);
break;
}
case "script": {
if (patch.op === "remove") {
setGsapScript(parsed.document, "");
} else {
setGsapScript(parsed.document, String(patch.value ?? ""));
}
break;
}
case "stylesheet": {
if (patch.op === "remove") {
setStyleSheet(parsed.document, "");
} else {
setStyleSheet(parsed.document, 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;
}
}
}
+150
View File
@@ -0,0 +1,150 @@
/**
* Hand-rolled flat-CSS rule editor.
*
* Handles only simple flat rules (`selector { declarations }`) — no nesting,
* no @-rules, no CSS comments. Sufficient for composition <style> blocks
* generated by hyperframes, which never contain those constructs.
*/
// ─── Types ────────────────────────────────────────────────────────────────────
interface CssRule {
selector: string;
body: string;
/** Byte offset of the first char of the selector. */
start: number;
/** Byte offset after the closing `}`. */
end: number;
}
// ─── Parsing ──────────────────────────────────────────────────────────────────
// fallow-ignore-next-line complexity
function parseCssRules(css: string): CssRule[] {
const rules: CssRule[] = [];
let i = 0;
const len = css.length;
while (i < len) {
const braceOpen = css.indexOf("{", i);
if (braceOpen === -1) break;
const selector = css.slice(i, braceOpen).trim();
if (!selector) {
i = braceOpen + 1;
continue;
}
// skip leading whitespace to get the true selector start
let selStart = i;
while (selStart < braceOpen && " \t\r\n".includes(css[selStart]!)) selStart++;
// find closing }, respecting quoted strings so `content: "}"` doesn't end the rule
let j = braceOpen + 1;
let quote: string | null = null;
while (j < len) {
const ch = css[j]!;
if (quote) {
if (ch === "\\" && j + 1 < len)
j++; // skip escaped char
else if (ch === quote) quote = null;
} else if (ch === '"' || ch === "'") {
quote = ch;
} else if (ch === "}") {
break;
}
j++;
}
rules.push({ selector, body: css.slice(braceOpen + 1, j), start: selStart, end: j + 1 });
i = j + 1;
}
return rules;
}
// fallow-ignore-next-line complexity
function parseDeclarations(body: string): Record<string, string> {
const decls: Record<string, string> = {};
let depth = 0;
let quote: string | null = null;
let start = 0;
for (let i = 0; i <= body.length; i++) {
const ch = i < body.length ? body[i]! : ";"; // sentinel flush
if (quote) {
if (ch === "\\") {
i++;
continue;
} // skip escaped char
if (ch === quote) quote = null;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
continue;
}
if (ch === "(") depth++;
else if (ch === ")") depth--;
else if (ch === ";" && depth === 0) {
const part = body.slice(start, i);
const colon = part.indexOf(":");
if (colon !== -1) {
const prop = part.slice(0, colon).trim();
const value = part.slice(colon + 1).trim();
if (prop && value) decls[prop] = value;
}
start = i + 1;
}
}
return decls;
}
function serializeDeclarations(decls: Record<string, string>): string {
const entries = Object.entries(decls);
if (!entries.length) return "";
return " " + entries.map(([k, v]) => `${k}: ${v}`).join("; ") + "; ";
}
function normalizeSelector(sel: string): string {
return sel.trim().replace(/\s+/g, " ");
}
// ─── Public API ───────────────────────────────────────────────────────────────
/**
* Update or insert a CSS rule.
*
* Finds the first rule whose selector matches (after whitespace normalization)
* and merges the given declarations into it. A null value removes the property.
* If no matching rule exists, appends a new one.
*
* Returns the modified CSS string. Returns `css` unchanged when nothing changed.
*/
export function upsertCssRule(
css: string,
selector: string,
styles: Record<string, string | null>,
): string {
const normalized = normalizeSelector(selector);
const rules = parseCssRules(css);
const idx = rules.findIndex((r) => normalizeSelector(r.selector) === normalized);
if (idx !== -1) {
const rule = rules[idx]!;
const decls = parseDeclarations(rule.body);
for (const [prop, value] of Object.entries(styles)) {
if (value === null) {
delete decls[prop];
} else {
decls[prop] = value;
}
}
const newRuleText = `${rule.selector} {${serializeDeclarations(decls)}}`;
return css.slice(0, rule.start) + newRuleText + css.slice(rule.end);
}
// Append new rule — skip if all values are null (nothing to write).
const newDecls: Record<string, string> = {};
for (const [prop, value] of Object.entries(styles)) {
if (value !== null) newDecls[prop] = value;
}
if (!Object.keys(newDecls).length) return css;
const newRuleText = `${selector} {${serializeDeclarations(newDecls)}}`;
const sep = css.length > 0 && !css.endsWith("\n") ? "\n" : "";
return css + sep + newRuleText + "\n";
}
@@ -0,0 +1,45 @@
/**
* Backfill defaults for add-keyframe ops.
*
* When an add-keyframe op introduces a property absent from the other keyframes,
* the writer needs a rest value to seed those keyframes with so GSAP interpolates
* instead of snapping. The SDK derives the numeric-default set here so the acorn
* writer matches the recast writer the server uses.
*
* Only props with a real numeric default get a backfill value. Defaulting an
* unknown or string-valued prop to 0 (e.g. `color: 0`, `filter: 0`) emits invalid
* GSAP, so such props are SKIPPED — the writer then leaves them out of the other
* keyframes (GSAP reads the rest value from the DOM), matching recast (which skips
* any prop whose default is null).
*/
// Numeric rest values for editable transform/style props. Props absent here have
// no safe static default and are intentionally omitted from the backfill set.
//
// KEEP IN SYNC WITH packages/studio/src/hooks/gsapShared.ts:PROPERTY_DEFAULTS —
// the studio (recast) and SDK (acorn) paths must derive the same defaults or
// SDK-written keyframes drift from server-written ones (the exact bug this fixes).
// TODO: lift the canonical table into @hyperframes/core and import from both.
const KEYFRAME_PROPERTY_DEFAULTS: Record<string, number> = {
opacity: 1,
x: 0,
y: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
rotation: 0,
width: 100,
height: 100,
};
/** Derive the backfillDefaults for an add-keyframe op (numeric-default props only). */
export function deriveKeyframeBackfillDefaults(
value: Record<string, number | string>,
): Record<string, number | string> {
const defaults: Record<string, number | string> = {};
for (const key of Object.keys(value)) {
const def = KEYFRAME_PROPERTY_DEFAULTS[key];
if (def !== undefined) defaults[key] = def;
}
return defaults;
}
+433
View File
@@ -0,0 +1,433 @@
/**
* 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,
isCompositionTemplate,
walkCompositionDescendants,
} 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 {
// Delegate to resolveScoped so patch replay (undo/redo, override-set apply)
// resolves an id the SAME way forward dispatch does: canonical-first for an
// ambiguous bare id, and scoped-path ("hf-host/hf-leaf") aware. Otherwise the
// two paths disagree on which duplicate a bare id targets and undo reverts the
// wrong element. (function declaration is hoisted.)
return resolveScoped(document, id);
}
export function escapeHfId(id: string): string {
return id.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
/**
* querySelectorAll that also descends into COMPOSITION `<template>` subtrees
* (`data-composition-id` — the pattern the studio preview unwraps) — linkedom's
* querySelectorAll does not, so template-based sub-comp content would be
* unreachable for resolution/dispatch even though buildRoots models it.
*
* Implemented as a document-order DOM walk (not qsa + append) so duplicate-id
* tiebreaks resolve in TRUE document order — appending template matches after
* all top-level matches would make resolveScoped pick a different duplicate
* than the preview's unwrapped DOM does. Plain templates (runtime clone
* sources) are skipped, matching buildChildren and ensureHfIds.
*
* Throws like querySelectorAll on an invalid selector (Element.matches).
*/
export function querySelectorAllDeep(root: Document | Element, selector: string): Element[] {
const out: Element[] = [];
const start: Element | null =
"body" in root ? ((root as Document).body ?? null) : (root as Element);
const walk = (parent: Element): void => {
for (const child of Array.from(parent.children)) {
if (child.tagName.toLowerCase() === "template") {
if (isCompositionTemplate(child)) walk(child);
continue;
}
if (child.matches(selector)) out.push(child);
walk(child);
}
};
if (start) {
// When rooted at an Element (scoped-path step), the root itself is the
// context, not a candidate — only descendants match, like querySelectorAll.
walk(start);
}
return out;
}
/**
* True when an element lives at the top-level (canonical) scope — i.e. its
* scopedId equals its bare id because no ancestor opens a sub-composition
* boundary. This mirrors document.ts's scopedId construction (childPrefix only
* changes at isNewHostBoundary elements) without rebuilding the snapshot tree.
*/
function isCanonicalScope(el: Element): boolean {
for (let cur = el.parentElement; cur; cur = cur.parentElement) {
if (isNewHostBoundary(cur)) return false;
}
return true;
}
/**
* Resolve a bare or scoped hf-id to its DOM element.
*
* Bare id ("hf-x"): top-level document search. When the bare id is ambiguous
* (duplicated across a sub-composition and the top level), prefer the canonical
* (top-level) instance — the one whose scopedId equals the bare id — falling
* back to document order when no canonical match exists. This matches
* getElement()'s resolution rule so removeElement / getElement agree on which
* instance an ambiguous bare id targets.
*
* Scoped id ("hf-HOST/hf-LEAF", any depth): each segment narrows the search
* into the subtree of the previous match. This unambiguously addresses an
* element inside a sub-composition even when bare ids collide.
*/
export function resolveScoped(document: Document, id: string): Element | null {
const parts = id.split("/");
// Bare id: prefer the canonical (top-level) match when one exists, so
// resolution agrees with getElement (scopedId === id wins over document order).
if (parts.length === 1) {
const escaped = escapeHfId(id);
const matches = querySelectorAllDeep(document, `[data-hf-id="${escaped}"]`);
if (matches.length > 0) {
return matches.find((el) => isCanonicalScope(el)) ?? matches[0] ?? null;
}
// Fall back to a sub-composition ROOT addressed by its composition id. A
// host element carries data-hf-id (its own leaf id) AND data-composition-id
// (the id studio passes when targeting the sub-comp root). data-hf-id takes
// precedence above; only when no hf-id matches do we treat the bare id as a
// composition id, making comp-ids first-class resolvable addresses.
return querySelectorAllDeep(document, `[data-composition-id="${escaped}"]`)[0] ?? null;
}
let context: Element | Document = document;
for (const part of parts) {
const escaped = escapeHfId(part);
const found: Element | null =
querySelectorAllDeep(context, `[data-hf-id="${escaped}"]`)[0] ?? null;
if (!found) return null;
context = found;
}
return context as Element;
}
/**
* Bare leaf id from a scoped hf-id ("hf-HOST/hf-LEAF" → "hf-LEAF"; a bare id
* passes through unchanged). The live DOM's `data-hf-id` attribute never
* carries the host-chain prefix, so a consumer holding a scopedId (from
* getElements()/getElement()) needs this to query the rendered DOM directly.
*/
export function bareId(scopedId: string): string {
const parts = scopedId.split("/");
// split() always returns >=1 element, so this never actually falls through at
// runtime — the fallback exists to satisfy noUncheckedIndexedAccess, not as a
// reachable safety net.
return parts[parts.length - 1] ?? scopedId;
}
/**
* Returns true when this element starts a new sub-composition scope — i.e. it
* is a host element (has data-composition-file) and is NOT the outerHTML
* innerRoot of the SAME sub-composition (same dcf value as parent).
*
* outerHTML case: both host and innerRoot carry data-composition-file="sub.html".
* The innerRoot has the SAME value as the host (its parent) → not a new boundary.
* A genuine nested host inside a sub-comp has a DIFFERENT dcf value.
*/
export function isNewHostBoundary(el: Element): boolean {
const dcf = el.getAttribute("data-composition-file");
if (!dcf) return false;
const parentDcf = el.parentElement?.getAttribute("data-composition-file") ?? null;
return dcf !== parentDcf;
}
/**
* The element that carries composition-level declarations
* (`data-composition-variables`). Full-document comps use `<html>`; a wrapped
* template/fragment comp has a synthetic `<html>` that serialize() strips, so
* its declarations must live on the composition root div (where values/metadata
* already live) to survive save.
*/
export function declarationElement(document: Document, wrapped: boolean): Element | null {
if (wrapped) return findRoot(document);
return (document as Document & { documentElement?: Element }).documentElement ?? null;
}
export function findRoot(document: Document): Element | null {
return (
document.querySelector("[data-hf-root]") ??
document.getElementById("stage") ??
// Descend into a composition <template> so a wrapped template sub-comp
// resolves to its inner [data-composition-id] root, not the <template> shell.
querySelectorAllDeep(document, "[data-composition-id]")[0] ??
document.body?.firstElementChild ??
null
);
}
// ─── Inline style helpers ─────────────────────────────────────────────────────
export 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). */
interface StyleDeclarationScan {
depth: number;
quote: "'" | '"' | null;
skip: boolean;
}
function advanceStyleDeclarationScan(scan: StyleDeclarationScan, ch: string, next: string): void {
if (scan.quote) {
if (ch === "\\" && next) {
scan.skip = true;
return;
}
if (ch === scan.quote) scan.quote = null;
return;
}
if (ch === "'" || ch === '"') {
scan.quote = ch;
return;
}
if (ch === "(") scan.depth++;
else if (ch === ")") scan.depth = Math.max(0, scan.depth - 1);
}
function splitStyleDeclarations(style: string): string[] {
const declarations: string[] = [];
const scan: StyleDeclarationScan = { depth: 0, quote: null, skip: false };
let start = 0;
for (let i = 0; i < style.length; i++) {
if (scan.skip) {
scan.skip = false;
continue;
}
const ch = style[i] ?? "";
if (ch === ";" && scan.depth === 0 && scan.quote === null) {
declarations.push(style.slice(start, i));
start = i + 1;
} else {
advanceStyleDeclarationScan(scan, ch, style[i + 1] ?? "");
}
}
declarations.push(style.slice(start));
return declarations;
}
function parseStyleAttr(styleAttr: string): Record<string, string> {
const result: Record<string, string> = {};
for (const decl of splitStyleDeclarations(styleAttr)) {
const idx = decl.indexOf(":");
if (idx === -1) continue;
const rawProp = decl.slice(0, idx).trim();
const value = decl.slice(idx + 1).trim();
if (!rawProp) 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)) {
// Stored map is keyed camelCase (parseStyleAttr); custom props (--foo) stay
// verbatim. Normalize the incoming key the same way for both set and delete.
const key = toCamel(prop);
if (value === null) {
delete current[key];
} else {
current[key] = value;
}
}
const serialized = serializeStyleAttr(current);
if (serialized) {
el.setAttribute("style", serialized);
} else {
el.removeAttribute("style");
}
}
// ─── Text helpers ─────────────────────────────────────────────────────────────
function isHTMLElementTarget(el: Element): boolean {
const HTMLElementCtor = el.ownerDocument.defaultView?.HTMLElement;
if (HTMLElementCtor) return el instanceof HTMLElementCtor;
return "style" in el;
}
function resolveSingleChildTextTarget(el: Element): Element | null {
const inner = el.children.length === 1 ? el.firstElementChild : null;
return inner && isHTMLElementTarget(inner) ? inner : null;
}
/** Read the text target used by SDK setText. */
export function getOwnText(el: Element): string {
const singleChild = resolveSingleChildTextTarget(el);
if (singleChild) return singleChild.textContent ?? "";
let text = "";
el.childNodes.forEach((n) => {
if (n.nodeType === 3) text += (n as Text).nodeValue ?? "";
});
return text;
}
/** Replace the SDK text target without destroying multi-child element structure. */
export function setOwnText(el: Element, text: string): void {
const singleChild = resolveSingleChildTextTarget(el);
if (singleChild) {
singleChild.textContent = text;
return;
}
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);
}
}
// ─── CSS style helpers ────────────────────────────────────────────────────────
function findStyleElement(document: Document): Element | null {
return document.querySelector("style") as unknown as Element | null;
}
export function getStyleSheet(document: Document): string {
return findStyleElement(document)?.textContent ?? "";
}
export function setStyleSheet(document: Document, css: string): void {
const existing = findStyleElement(document);
if (!css) {
existing?.remove();
return;
}
let el = existing;
if (!el) {
el = document.createElement("style") as unknown as Element;
const head =
(document.querySelector("head") as unknown as Element | null) ??
(document.body as unknown as Element);
(head as any).appendChild(el);
}
el.textContent = css;
}
// ─── GSAP script helpers ──────────────────────────────────────────────────────
function findScriptElementsDeep(document: Document): Element[] {
const scripts: Element[] = [];
walkCompositionDescendants(document, (child) => {
if (child.tagName.toLowerCase() === "script") scripts.push(child);
});
return scripts;
}
function isGsapScriptText(text: string): boolean {
return text.includes("gsap") || text.includes("__timelines") || text.includes("ScrollTrigger");
}
export function getGsapScripts(document: Document): string[] {
return findScriptElementsDeep(document)
.map((script) => script.textContent ?? "")
.filter(isGsapScriptText);
}
function findGsapScriptElement(document: Document): Element | null {
for (const script of findScriptElementsDeep(document)) {
const text = script.textContent ?? "";
if (isGsapScriptText(text)) return script;
}
return null;
}
export function getGsapScript(document: Document): string | null {
const el = findGsapScriptElement(document);
return el ? (el.textContent ?? "") : null;
}
export function setGsapScript(document: Document, newScript: string): void {
const existing = findGsapScriptElement(document);
if (!newScript) {
existing?.remove();
return;
}
let el = existing;
if (!el) {
el = document.createElement("script") as unknown as Element;
const head =
(document.querySelector("head") as unknown as Element | null) ??
(document.body as unknown as Element);
(head as any).appendChild(el);
}
el.textContent = newScript;
}
// ─── Sibling index ────────────────────────────────────────────────────────────
export function getSiblingIndex(el: Element): number {
const parent = el.parentElement;
if (!parent) return 0;
return Array.from(parent.children).indexOf(el);
}
@@ -0,0 +1,214 @@
/**
* setClassStyle handler tests — flat CSS rule upsert via <style> element.
*/
import { describe, it, expect } from "vitest";
import { parseMutable } from "./model.js";
import { applyOp, validateOp } from "./mutate.js";
import { applyPatchesToDocument } from "./apply-patches.js";
import { serializeDocument } from "./serialize.js";
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const CSS = `.box { opacity: 0; transform: translateX(-50px); }
.title { color: #fff; font-size: 64px; }
`;
function makeHtml(style = CSS) {
return `<!DOCTYPE html><html><head><style>${style}</style></head><body>
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px">
<div data-hf-id="hf-box" class="box"></div>
<h1 data-hf-id="hf-title" class="title">Hello</h1>
</div></body></html>`.trim();
}
function fresh(style = CSS) {
return parseMutable(makeHtml(style));
}
function getStyleText(parsed: ReturnType<typeof parseMutable>): string {
const doc = serializeDocument(parsed);
const m = /<style>([\s\S]*?)<\/style>/i.exec(doc);
return m ? m[1]! : "";
}
// ─── validateOp ───────────────────────────────────────────────────────────────
describe("validateOp setClassStyle", () => {
it("returns ok:true (always valid — creates <style> if absent)", () => {
expect(
validateOp(fresh(), { type: "setClassStyle", selector: ".box", styles: { opacity: "1" } }).ok,
).toBe(true);
});
it("returns ok:true even when no <style> element present", () => {
const noStyle = parseMutable(
`<div data-hf-id="hf-stage" data-hf-root><div data-hf-id="hf-box"></div></div>`,
);
expect(
validateOp(noStyle, { type: "setClassStyle", selector: ".box", styles: { opacity: "1" } }).ok,
).toBe(true);
});
});
// ─── setClassStyle: update existing rule ──────────────────────────────────────
describe("setClassStyle — update existing rule", () => {
function applyBoxOpacity1() {
const result = applyOp(fresh(), {
type: "setClassStyle",
selector: ".box",
styles: { opacity: "1" },
});
return String(result.forward[0]?.value ?? "");
}
it("adds a new property to an existing rule", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "setClassStyle",
selector: ".box",
styles: { color: "red" },
});
expect(result.forward).toHaveLength(1);
expect(result.forward[0]?.path).toBe("/style/css");
const newCss = String(result.forward[0]?.value ?? "");
expect(newCss).toContain("color: red");
expect(newCss).toContain("opacity: 0");
});
it("overwrites an existing property value", () => {
const newCss = applyBoxOpacity1();
expect(newCss).toContain("opacity: 1");
expect(newCss).not.toContain("opacity: 0");
expect(newCss).toContain("translateX(-50px)");
});
it("removes a property when value is null", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "setClassStyle",
selector: ".box",
styles: { opacity: null },
});
const newCss = String(result.forward[0]?.value ?? "");
expect(newCss).not.toContain("opacity");
expect(newCss).toContain("translateX(-50px)");
});
it("leaves other rules untouched", () => {
const newCss = applyBoxOpacity1();
expect(newCss).toContain(".title");
expect(newCss).toContain("color: #fff");
});
});
// ─── setClassStyle: insert new rule ──────────────────────────────────────────
describe("setClassStyle — insert new rule", () => {
it("appends a new rule when selector not found", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "setClassStyle",
selector: ".new",
styles: { display: "flex", gap: "8px" },
});
const newCss = String(result.forward[0]?.value ?? "");
expect(newCss).toContain(".new");
expect(newCss).toContain("display: flex");
expect(newCss).toContain("gap: 8px");
expect(newCss).toContain(".box");
});
it("creates <style> element when none exists", () => {
const noStyle = parseMutable(
`<div data-hf-id="hf-stage" data-hf-root><div data-hf-id="hf-box"></div></div>`,
);
const result = applyOp(noStyle, {
type: "setClassStyle",
selector: ".box",
styles: { opacity: "1" },
});
expect(result.forward).toHaveLength(1);
const newCss = String(result.forward[0]?.value ?? "");
expect(newCss).toContain(".box");
expect(newCss).toContain("opacity: 1");
});
});
// ─── no-op cases ─────────────────────────────────────────────────────────────
describe("setClassStyle — no-ops", () => {
it("returns EMPTY when all values are null and selector not found", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "setClassStyle",
selector: ".nonexistent",
styles: { opacity: null },
});
expect(result.forward).toHaveLength(0);
});
});
// ─── inverse restores original ────────────────────────────────────────────────
describe("setClassStyle — inverse patches", () => {
it("inverse restores original CSS", () => {
const parsed = fresh();
const original = getStyleText(parsed);
const result = applyOp(parsed, {
type: "setClassStyle",
selector: ".box",
styles: { opacity: "1", color: "blue" },
});
applyPatchesToDocument(parsed, result.inverse);
expect(getStyleText(parsed)).toBe(original);
});
it("undo on style-less composition does not create spurious <style> element", () => {
const noStyle = parseMutable(
`<div data-hf-id="hf-stage" data-hf-root><div data-hf-id="hf-box"></div></div>`,
);
const result = applyOp(noStyle, {
type: "setClassStyle",
selector: ".box",
styles: { opacity: "1" },
});
applyPatchesToDocument(noStyle, result.inverse);
const html = serializeDocument(noStyle);
expect(html).not.toContain("<style");
});
});
// ─── semicolon-containing CSS values ─────────────────────────────────────────
describe("setClassStyle — CSS values with semicolons (data URIs)", () => {
it("preserves data URI value when updating another property in same rule", () => {
const dataUriCss = ".hero { background: url(data:image/png;base64,abc=); color: red; }\n";
const parsed = fresh(dataUriCss);
const result = applyOp(parsed, {
type: "setClassStyle",
selector: ".hero",
styles: { color: "blue" },
});
const newCss = String(result.forward[0]?.value ?? "");
expect(newCss).toContain("url(data:image/png;base64,abc=)");
expect(newCss).toContain("color: blue");
expect(newCss).not.toContain("color: red");
});
});
// ─── DOM side-effect ─────────────────────────────────────────────────────────
describe("setClassStyle — DOM mutation", () => {
it("mutates the live <style> element in the document", () => {
const parsed = fresh();
applyOp(parsed, {
type: "setClassStyle",
selector: ".box",
styles: { opacity: "1" },
});
expect(getStyleText(parsed)).toContain("opacity: 1");
expect(getStyleText(parsed)).not.toContain("opacity: 0");
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+255
View File
@@ -0,0 +1,255 @@
/**
* 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|duration|trackIndex} ← end = computed absolute data-end
* /elements/{hfId}/hold/{start|end|fill}
* /elements/{hfId} ← whole subtree (removeElement)
* /variables/{variableId} ← declaration's default value
* /variableDeclarations/{variableId} ← whole declaration object
* /metadata/{width|height|duration}
* /script/gsap ← GSAP inline script textContent
* /style/css ← <style> element textContent
*
* 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"
* /variableDeclarations/brand-color-primary → "varDecl.brand-color-primary"
* /metadata/width → "meta.width"
* /script/gsap → "script.gsap"
* /style/css → "style.css"
*/
import type { JsonPatchOp, PatchEvent } from "../types.js";
// ─── Path builders ────────────────────────────────────────────────────────────
/**
* RFC 6902 JSON Pointer escaping for an hf-id (bare or scoped).
* Scoped ids contain "/" which must be encoded as "~1" in a path segment.
* "~" must be encoded as "~0" first (order matters per RFC 6902 §3).
*/
function escapeIdForPath(id: string): string {
return id.replace(/~/g, "~0").replace(/\//g, "~1");
}
/** Decode a path segment that may contain RFC 6902-escaped characters back to an hf-id. */
function decodePathSegment(segment: string): string {
// RFC 6902 §3: unescape ~1 → /, then ~0 → ~ (reverse order)
return segment.replace(/~1/g, "/").replace(/~0/g, "~");
}
export function stylePath(id: string, prop: string): string {
return `/elements/${escapeIdForPath(id)}/inlineStyles/${prop}`;
}
export function textPath(id: string): string {
return `/elements/${escapeIdForPath(id)}/text`;
}
export function attrPath(id: string, name: string): string {
// RFC 6902 JSON Pointer: ~ → ~0, / → ~1
const escapedName = name.replace(/~/g, "~0").replace(/\//g, "~1");
return `/elements/${escapeIdForPath(id)}/attributes/${escapedName}`;
}
export function timingPath(id: string, field: "start" | "end" | "duration" | "trackIndex"): string {
return `/elements/${escapeIdForPath(id)}/timing/${field}`;
}
export function holdPath(id: string, field: "start" | "end" | "fill"): string {
return `/elements/${escapeIdForPath(id)}/hold/${field}`;
}
export function elementPath(id: string): string {
return `/elements/${escapeIdForPath(id)}`;
}
export function variablePath(id: string): string {
return `/variables/${id}`;
}
/** Whole-declaration path — distinct from /variables/{id}, which is the default *value*. */
export function variableDeclPath(id: string): string {
return `/variableDeclarations/${id}`;
}
export function metaPath(field: "width" | "height" | "duration"): string {
return `/metadata/${field}`;
}
export function gsapScriptPath(): string {
return "/script/gsap";
}
export function styleSheetPath(): string {
return "/style/css";
}
// ─── 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}"
// id segment may contain ~1 (RFC 6902-escaped "/") for scoped ids
const styleMatch = /^\/elements\/([^/]+)\/inlineStyles\/(.+)$/.exec(path);
if (styleMatch) return `${decodePathSegment(styleMatch[1]!)}.style.${styleMatch[2]}`;
// /elements/{id}/text → "{id}.text"
const textMatch = /^\/elements\/([^/]+)\/text$/.exec(path);
if (textMatch) return `${decodePathSegment(textMatch[1]!)}.text`;
// /elements/{id}/attributes/{name} → "{id}.attr.{name}"
const attrMatch = /^\/elements\/([^/]+)\/attributes\/(.+)$/.exec(path);
if (attrMatch) return `${decodePathSegment(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 `${decodePathSegment(timingMatch[1]!)}.timing.${timingMatch[2]}`;
// /elements/{id}/hold/{field} → "{id}.hold.{field}"
const holdMatch = /^\/elements\/([^/]+)\/hold\/(.+)$/.exec(path);
if (holdMatch) return `${decodePathSegment(holdMatch[1]!)}.hold.${holdMatch[2]}`;
// /elements/{id} (whole element) → "{id}"
const elemMatch = /^\/elements\/([^/]+)$/.exec(path);
if (elemMatch) return decodePathSegment(elemMatch[1]!);
// /variableDeclarations/{id} → "varDecl.{id}" (checked before /variables/)
const varDeclMatch = /^\/variableDeclarations\/(.+)$/.exec(path);
if (varDeclMatch) return `varDecl.${varDeclMatch[1]}`;
// /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]}`;
// /script/gsap → "script.gsap"
if (path === "/script/gsap") return "script.gsap";
// /style/css → "style.css"
if (path === "/style/css") return "style.css";
return null;
}
/**
* Inverse of pathToKey — maps an override-set key back to its RFC 6902 path.
* Used to replay a stored override-set onto a fresh base document (T3 init).
*/
// Exhaustive key-family dispatcher — same shape as apply-patches.ts parsePath.
// fallow-ignore-next-line complexity
export function keyToPath(key: string): string | null {
const style = /^([^.]+)\.style\.(.+)$/.exec(key);
if (style?.[1] && style[2]) return stylePath(style[1], style[2]);
const text = /^([^.]+)\.text$/.exec(key);
if (text?.[1]) return textPath(text[1]);
const attr = /^([^.]+)\.attr\.(.+)$/.exec(key);
// The attr name segment in the key is already RFC 6902-encoded (pathToKey stored it verbatim).
// The id may be a scoped id (contains "/") so we must escape it, but must NOT re-escape
// the already-encoded attr segment. Reconstruct manually.
if (attr?.[1] && attr[2]) return `/elements/${escapeIdForPath(attr[1])}/attributes/${attr[2]}`;
const timing = /^([^.]+)\.timing\.(start|end|duration|trackIndex)$/.exec(key);
if (timing?.[1])
return timingPath(timing[1], timing[2] as "start" | "end" | "duration" | "trackIndex");
const hold = /^([^.]+)\.hold\.(start|end|fill)$/.exec(key);
if (hold?.[1]) return holdPath(hold[1], hold[2] as "start" | "end" | "fill");
const varDecl = /^varDecl\.(.+)$/.exec(key);
if (varDecl?.[1]) return variableDeclPath(varDecl[1]);
const variable = /^var\.(.+)$/.exec(key);
if (variable?.[1]) return variablePath(variable[1]);
const meta = /^meta\.(width|height|duration)$/.exec(key);
if (meta) return metaPath(meta[1] as "width" | "height" | "duration");
if (key === "script.gsap") return gsapScriptPath();
if (key === "style.css") return styleSheetPath();
// Bare element id — removal marker key.
if (!key.includes(".")) return elementPath(key);
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 (replace or add) + inverse (replace or remove) for any JSON-serializable value.
* Use instead of scalarChange when the value may be an object (e.g. font/image variable).
* The old value is captured whole — no sub-key diffing.
*/
export function valueChange(
path: string,
oldValue: unknown,
newValue: unknown,
): { forward: JsonPatchOp; inverse: JsonPatchOp } {
const forward = oldValue == null ? patchAdd(path, newValue) : patchReplace(path, newValue);
const inverse = oldValue == null ? patchRemove(path) : patchReplace(path, oldValue);
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}`;
}
+168
View File
@@ -0,0 +1,168 @@
/**
* Shared helpers for the composition variable JSON model
* (`data-composition-variables`). The declaration-carrying element is resolved
* by the caller (`declarationElement` in model.ts): `<html>` for full-document
* comps, the composition root div for wrapped template/fragment comps.
*
* Single source for the parse → find-by-id → read/write/clear logic so the
* forward-mutation path (engine/mutate.ts) and the patch-replay path
* (engine/apply-patches.ts) can never disagree on the model's shape.
*/
// Browser-safe subpath — the core/parsers root entries pull Node-only modules
// and would break browser bundles that include the SDK (e.g. Studio).
import { parseCompositionVariables } from "@hyperframes/core/variables";
import type { CompositionVariable } from "@hyperframes/core/variables";
// Exported so the SDK index can re-export it (kept from #2098's surface).
export type VariableDecl = { id: string; default?: unknown; [key: string]: unknown };
/** Parse the variable declaration array, or null when absent/invalid. */
function readDecls(declEl: Element | null): { declEl: Element; arr: VariableDecl[] } | null {
if (!declEl) return null;
const raw = declEl.getAttribute("data-composition-variables");
if (!raw) return null;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!Array.isArray(parsed)) return null;
return { declEl, arr: parsed as VariableDecl[] };
}
function indexOfId(arr: VariableDecl[], id: string): number {
return arr.findIndex((v) => typeof v === "object" && v !== null && v.id === id);
}
/**
* Read the typed variable declarations from `data-composition-variables`.
* Delegates to the canonical parser (same filter the render pipeline uses),
* so malformed entries are dropped rather than surfaced. Returns `[]` when
* the declaration element is absent or has no declarations.
*/
export function readVariableDeclarations(declEl: Element | null): CompositionVariable[] {
if (!declEl) return [];
return parseCompositionVariables(declEl);
}
/**
* Find the raw declaration entry for a variable id, verbatim (unvalidated).
* Returns undefined when the attribute is absent, the JSON is invalid, or no
* entry matches the id.
*/
export function findVariableDeclaration(
declEl: Element | null,
id: string,
): VariableDecl | undefined {
const decls = readDecls(declEl);
if (!decls) return undefined;
const idx = indexOfId(decls.arr, id);
return idx < 0 ? undefined : decls.arr[idx];
}
/**
* Upsert a whole variable declaration by its id. Creates the
* `data-composition-variables` attribute when absent; replaces an unparseable
* attribute with a fresh single-entry array (the prior content was invisible
* to every reader anyway). Returns false only when there is no declaration
* element to carry the attribute.
*
* Accepts raw (unvalidated) entries as well as typed declarations: the patch
* REPLAY path must faithfully restore whatever entry the inverse patch
* captured — including loose hand-authored declarations the strict parser
* would drop — or undo silently diverges from history.
*/
export function writeVariableDeclaration(
declEl: Element | null,
declaration: CompositionVariable | ({ id: string } & Record<string, unknown>),
): boolean {
if (!declEl) return false;
const decls = readDecls(declEl);
const arr = decls?.arr ?? [];
const idx = indexOfId(arr, declaration.id);
const entry: VariableDecl = { ...declaration };
if (idx < 0) {
arr.push(entry);
} else {
arr[idx] = entry;
}
declEl.setAttribute("data-composition-variables", JSON.stringify(arr));
return true;
}
/**
* Remove a variable declaration by id. Drops the whole attribute when the
* last declaration is removed (an empty `[]` is noise in authored HTML).
* No-ops (returns false) when the attribute or the entry is absent.
*/
export function removeVariableDeclarationEntry(declEl: Element | null, id: string): boolean {
const decls = readDecls(declEl);
if (!decls) return false;
const idx = indexOfId(decls.arr, id);
if (idx < 0) return false;
decls.arr.splice(idx, 1);
if (decls.arr.length === 0) {
decls.declEl.removeAttribute("data-composition-variables");
} else {
decls.declEl.setAttribute("data-composition-variables", JSON.stringify(decls.arr));
}
return true;
}
/**
* Read the current `default` value for a variable id. Returns undefined when
* the attribute is absent, the JSON is invalid, or no entry matches the id.
*/
export function readVariableDefault(declEl: Element | null, id: string): unknown {
const decls = readDecls(declEl);
if (!decls) return undefined;
const idx = indexOfId(decls.arr, id);
return idx < 0 ? undefined : decls.arr[idx]?.default;
}
/**
* Upsert a variable's `default`. No-ops (returns false) when the attribute is
* absent or contains no declaration for the id — we never auto-add declarations
* for undeclared variables, keeping the schema authoritative. Returns true when
* the attribute was updated.
*/
export function writeVariableDefault(
declEl: Element | null,
id: string,
newDefault: unknown,
): boolean {
const decls = readDecls(declEl);
if (!decls) return false;
const idx = indexOfId(decls.arr, id);
if (idx < 0) return false; // variable not declared — don't auto-add
decls.arr[idx] = { ...decls.arr[idx]!, default: newDefault };
decls.declEl.setAttribute("data-composition-variables", JSON.stringify(decls.arr));
return true;
}
/**
* Remove the `default` key from a variable declaration, restoring its
* "no authored default" state. This is the exact inverse of writeVariableDefault
* adding a default to a decl that had none, so undo of a first-set on a
* default-less variable round-trips. No-ops when the decl or key is absent.
* Returns true when the attribute was updated.
*/
export function clearVariableDefault(declEl: Element | null, id: string): boolean {
const decls = readDecls(declEl);
if (!decls) return false;
const idx = indexOfId(decls.arr, id);
if (idx < 0 || !(decls.arr[idx]! && "default" in decls.arr[idx]!)) return false;
const { default: _drop, ...rest } = decls.arr[idx]!;
decls.arr[idx] = rest as VariableDecl;
decls.declEl.setAttribute("data-composition-variables", JSON.stringify(decls.arr));
return true;
}
// NOTE: #2098's Document-based helpers (listVariableDecls / declareVariableDecl /
// removeVariableDecl) were removed in the #2098-reconciliation — they duplicated
// the canonical declEl-based readVariableDeclarations / writeVariableDeclaration /
// removeVariableDeclarationEntry below and predate template/fragment declaration
// scope. The session conveniences (listVariables / removeVariable) delegate to
// the canonical methods instead.
+145
View File
@@ -0,0 +1,145 @@
/**
* Optional history module (F5 layering).
*
* Wires onto a Composition session via on('patch') and implements undo/redo.
* Coalesces same-op+same-targets bursts within coalesceMs into one undo entry.
*
* Usage (standalone / T1):
* const comp = await openComposition(html, { persist });
* // openComposition attaches this automatically in non-embedded mode.
*
* Usage (manual / custom undo timeline):
* const history = createHistory(comp, { coalesceMs: 500, trackedOrigins: ['local'] });
* // host calls history.undo() / history.redo() instead of comp.undo() / comp.redo()
*/
import type { Composition, JsonPatchOp, PatchEvent } from "./types.js";
import { ORIGIN_APPLY_PATCHES } from "./types.js";
export interface HistoryEntry {
readonly patches: readonly JsonPatchOp[];
readonly inversePatches: readonly JsonPatchOp[];
readonly opTypes: readonly string[];
readonly origin: unknown;
readonly timestamp: number;
}
export interface HistoryModule {
undo(): boolean;
redo(): boolean;
canUndo(): boolean;
canRedo(): boolean;
dispose(): void;
}
export interface HistoryOptions {
/** Only ops with these origins enter the undo stack. Default: all non-ORIGIN_APPLY_PATCHES. */
trackedOrigins?: unknown[];
/** Coalesce window in ms. Same opTypes + same origin within window → one entry. Default: 300. */
coalesceMs?: number;
/** Max undo stack depth. Default: 100. */
maxEntries?: number;
}
export function createHistory(session: Composition, opts: HistoryOptions = {}): HistoryModule {
const coalesceMs = opts.coalesceMs ?? 300;
const maxEntries = opts.maxEntries ?? 100;
const { trackedOrigins } = opts;
const undoStack: HistoryEntry[] = [];
let redoStack: HistoryEntry[] = [];
function isTracked(origin: unknown): boolean {
if (origin === ORIGIN_APPLY_PATCHES) return false;
if (!trackedOrigins) return true;
return trackedOrigins.includes(origin);
}
function pathsKey(patches: readonly JsonPatchOp[]): string {
return patches
.map((p) => p.path)
.sort()
.join("\n");
}
function opTypesKey(opTypes: readonly string[]): string {
// Sorted: the same op-type SET coalesces regardless of dispatch order.
return [...opTypes].sort().join(",");
}
function shouldCoalesce(entry: HistoryEntry, incoming: PatchEvent): boolean {
if (coalesceMs <= 0) return false;
if (opTypesKey(entry.opTypes) !== opTypesKey(incoming.opTypes)) return false;
if (entry.origin !== incoming.origin) return false;
// Coalesce only when the SAME paths are touched (e.g. slider drag on one
// property). Without this, rapid edits to different elements would merge
// into one entry holding the second forward + first inverse — undo would
// then revert the wrong element.
if (pathsKey(entry.patches) !== pathsKey(incoming.patches)) return false;
const now = Date.now();
return now - entry.timestamp <= coalesceMs;
}
// fallow-ignore-next-line complexity
const unsubscribe = session.on("patch", (event: PatchEvent) => {
if (!isTracked(event.origin)) return;
const last = undoStack[undoStack.length - 1];
if (last && shouldCoalesce(last, event)) {
// Coalesce: keep first inverse (original prev), replace forward with latest value.
// Slide timestamp forward so rapid-fire edits keep coalescing.
const coalesced: HistoryEntry = {
patches: event.patches,
inversePatches: last.inversePatches,
opTypes: last.opTypes,
origin: last.origin,
timestamp: Date.now(),
};
undoStack[undoStack.length - 1] = coalesced;
} else {
undoStack.push({
patches: event.patches,
inversePatches: event.inversePatches,
opTypes: event.opTypes,
origin: event.origin,
timestamp: Date.now(),
});
if (undoStack.length > maxEntries) undoStack.shift();
}
// Any new op clears the redo stack.
redoStack = [];
});
return {
undo(): boolean {
const entry = undoStack.pop();
if (!entry) return false;
session.applyPatches(entry.inversePatches, { origin: ORIGIN_APPLY_PATCHES });
redoStack.push(entry);
return true;
},
redo(): boolean {
const entry = redoStack.pop();
if (!entry) return false;
session.applyPatches(entry.patches, { origin: ORIGIN_APPLY_PATCHES });
undoStack.push(entry);
return true;
},
canUndo(): boolean {
return undoStack.length > 0;
},
canRedo(): boolean {
return redoStack.length > 0;
},
dispose(): void {
unsubscribe();
undoStack.length = 0;
redoStack.length = 0;
},
};
}
+65
View File
@@ -0,0 +1,65 @@
export type {
HyperFramesElement,
SdkDocument,
OverrideSet,
EditOp,
ElasticHold,
FontValue,
ImageValue,
GsapTweenSpec,
HfId,
JsonPatchOp,
PatchEvent,
PersistErrorEvent,
ElementSnapshot,
ElementTimingSnapshot,
FindQuery,
SelectionProxy,
ElementHandle,
Composition,
CanResult,
} from "./types.js";
export { ORIGIN_APPLY_PATCHES, ORIGIN_LOCAL } from "./types.js";
// Variable schema types — re-exported so SDK consumers (Studio, embedders)
// can type declarations without a direct @hyperframes/core dependency.
export type {
CompositionVariable,
CompositionVariableType,
CompositionVariableBase,
StringVariable,
NumberVariable,
ColorVariable,
BooleanVariable,
EnumVariable,
FontVariable,
ImageVariable,
VariableValidationIssue,
VariableUsageScan,
} from "@hyperframes/core/variables";
export type { VariableUsageReport } from "./types.js";
export { UnsupportedOpError } from "./engine/mutate.js";
export { buildDocument, buildRoots, flatElements } from "./document.js";
export { isNewHostBoundary, bareId, resolveScoped, findById, escapeHfId } from "./engine/model.js";
export { readVariableDefault } from "./engine/variableModel.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";
export type { PersistAdapter, PreviewAdapter, PersistVersionEntry } from "./adapters/types.js";
// Concrete adapter factories (browser-safe — Node-only fs adapter: @hyperframes/sdk/adapters/fs).
export { createMemoryAdapter } from "./adapters/memory.js";
export { createHeadlessAdapter } from "./adapters/headless.js";
export { createIframePreviewAdapter, resolveNearestHfElement } from "./adapters/iframe.js";
+83
View File
@@ -0,0 +1,83 @@
/**
* Optional persist queue module (F5 layering).
*
* Subscribes to 'change' events and schedules async writes via a PersistAdapter.
* One in-flight write at a time; latest state always wins (last-write-wins coalescing).
*
* Wired automatically by openComposition() in standalone (T1/T2) mode.
* T3 (embedded) hosts own persistence — do not use this module.
*/
import type { Composition, PersistErrorEvent } from "./types.js";
import type { PersistAdapter } from "./adapters/types.js";
export interface PersistQueueModule {
/** Force an immediate write (e.g. before app close). */
flush(): Promise<void>;
dispose(): void;
}
export interface PersistQueueOptions {
/** Adapter path to write to. Default: "composition.html" */
path?: string;
/** Called when adapter.write() rejects. */
onError?: (e: PersistErrorEvent) => void;
}
export function createPersistQueue(
session: Composition,
adapter: PersistAdapter,
opts: PersistQueueOptions = {},
): PersistQueueModule {
const path = opts.path ?? "composition.html";
let pendingWrite: ReturnType<typeof setTimeout> | null = null;
// Promise-chain mutex: each write chains onto the prior, preventing concurrent writes.
let writeChain: Promise<void> = Promise.resolve();
let disposed = false;
function scheduleWrite(): void {
if (pendingWrite !== null) clearTimeout(pendingWrite);
pendingWrite = setTimeout(() => {
pendingWrite = null;
void doWrite();
}, 0);
}
function doWrite(): Promise<void> {
if (disposed) return Promise.resolve();
const content = session.serialize();
writeChain = writeChain.then(async () => {
if (disposed) return;
try {
await adapter.write(path, content);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
opts.onError?.({ error: { message, cause: err } });
}
});
return writeChain;
}
const unsubscribe = session.on("change", () => {
scheduleWrite();
});
return {
async flush(): Promise<void> {
if (pendingWrite !== null) {
clearTimeout(pendingWrite);
pendingWrite = null;
}
await doWrite();
},
dispose(): void {
disposed = true;
if (pendingWrite !== null) {
clearTimeout(pendingWrite);
pendingWrite = null;
}
unsubscribe();
},
};
}
+251
View File
@@ -0,0 +1,251 @@
/**
* T4 — dispatch-boundary tests.
*
* Tests the full pipeline: session.dispatch() → patch event → override-set.
* Complements mutate.test.ts (which tests applyOp directly) by verifying
* the session wiring layer.
*/
import { describe, it, expect } from "vitest";
import { openComposition } from "./session.js";
import type { Composition, PatchEvent } from "./types.js";
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>
<p data-hf-id="hf-sub" style="opacity: 0.5">subtitle</p>
</div>
`.trim();
const GSAP_HTML = `
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px">
<div data-hf-id="hf-box" style="opacity: 0"></div>
<script>var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0);
window.__timelines = { t: tl };</script>
</div>
`.trim();
async function withPatch(html: string): Promise<{ comp: Composition; events: PatchEvent[] }> {
const comp = await openComposition(html);
const events: PatchEvent[] = [];
comp.on("patch", (e) => events.push(e));
return { comp, events };
}
function expectGsapScriptPatch(id: string, events: PatchEvent[]): void {
expect(typeof id).toBe("string");
expect(id.length).toBeGreaterThan(0);
expect(events).toHaveLength(1);
expect(events[0]!.patches.find((p) => p.path.includes("/script/gsap"))).toBeDefined();
}
// ─── patch event emission ─────────────────────────────────────────────────────
describe("dispatch emits patch event", () => {
it("setStyle emits forward replace + inverse replace", async () => {
const { comp, events } = await withPatch(BASE_HTML);
comp.setStyle("hf-title", { color: "#e63946" });
expect(events).toHaveLength(1);
expect(events[0]!.patches[0]).toMatchObject({
op: "replace",
path: "/elements/hf-title/inlineStyles/color",
value: "#e63946",
});
expect(events[0]!.inversePatches[0]).toMatchObject({
op: "replace",
path: "/elements/hf-title/inlineStyles/color",
value: "#fff",
});
});
it("no-op dispatch (same value) fires change; patch may be empty", async () => {
const comp = await openComposition(BASE_HTML);
const changes: number[] = [];
comp.on("change", () => changes.push(1));
comp.setStyle("hf-title", { color: "#fff" }); // same value already set
expect(changes).toHaveLength(1);
});
it("patch event opTypes reflects dispatched op type", async () => {
const { comp, events } = await withPatch(BASE_HTML);
comp.setText("hf-sub", "new text");
expect(events[0]?.opTypes).toContain("setText");
});
});
// ─── override-set accumulation ────────────────────────────────────────────────
describe("override-set accumulation", () => {
it("setStyle dispatch adds key to override-set", async () => {
const comp = await openComposition(BASE_HTML);
comp.setStyle("hf-title", { color: "#e63946" });
expect(comp.getOverrides()["hf-title.style.color"]).toBe("#e63946");
});
it("setText dispatch adds text key to override-set", async () => {
const comp = await openComposition(BASE_HTML);
comp.setText("hf-sub", "changed");
expect(comp.getOverrides()["hf-sub.text"]).toBe("changed");
});
it("setAttribute dispatch adds attr key", async () => {
const comp = await openComposition(BASE_HTML);
comp.dispatch({ type: "setAttribute", target: "hf-title", name: "data-name", value: "hero" });
expect(comp.getOverrides()["hf-title.attr.data-name"]).toBe("hero");
});
it("removeElement dispatch sets null removal marker in override-set", async () => {
const comp = await openComposition(BASE_HTML);
comp.removeElement("hf-sub");
// element path key should map to null marker
const overrides = comp.getOverrides();
const removedKey = Object.keys(overrides).find((k) => k.startsWith("hf-sub"));
expect(removedKey).toBeDefined();
});
});
// ─── can() structured result ──────────────────────────────────────────────────
describe("can() CanResult", () => {
it("ok:true for valid setStyle target", async () => {
const comp = await openComposition(BASE_HTML);
const r = comp.can({ type: "setStyle", target: "hf-title", styles: {} });
expect(r.ok).toBe(true);
});
it("ok:false / E_TARGET_NOT_FOUND for unknown id", async () => {
const comp = await openComposition(BASE_HTML);
const r = comp.can({ type: "setStyle", target: "hf-missing", styles: {} });
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.code).toBe("E_TARGET_NOT_FOUND");
expect(r.message).toContain("hf-missing");
expect(r.hint).toBeDefined();
}
});
it("ok:false / E_NO_GSAP_SCRIPT for GSAP op on non-GSAP composition", async () => {
const comp = await openComposition(BASE_HTML);
const r = comp.can({ type: "removeGsapTween", animationId: "tw-1" });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.code).toBe("E_NO_GSAP_SCRIPT");
});
it("ok:true for addGsapTween on GSAP composition", async () => {
const comp = await openComposition(GSAP_HTML);
const r = comp.can({
type: "addGsapTween",
target: "hf-box",
tween: { method: "to", properties: { x: 100 } },
});
expect(r.ok).toBe(true);
});
it("ok:false / E_NO_GSAP_TIMELINE when script has no timeline var (addLabel path)", async () => {
const noTimelineHtml = `
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
<div data-hf-id="hf-box"></div>
<script>gsap.defaults({ ease: "power1.out" });
window.__timelines = {};</script>
</div>`.trim();
const comp = await openComposition(noTimelineHtml);
const r = comp.can({ type: "addLabel", name: "intro", position: 0 });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.code).toBe("E_NO_GSAP_TIMELINE");
});
it("ok:true for setCompositionMetadata always", async () => {
const comp = await openComposition(BASE_HTML);
expect(comp.can({ type: "setCompositionMetadata", width: 1920 }).ok).toBe(true);
});
});
// ─── batch() emits single patch event ────────────────────────────────────────
describe("batch() patch event", () => {
it("collapses N dispatches into one patch event with all op types", async () => {
const { comp, events } = await withPatch(BASE_HTML);
comp.batch(() => {
comp.setStyle("hf-title", { color: "#111" });
comp.setText("hf-sub", "batched");
});
expect(events).toHaveLength(1);
expect(events[0]!.patches.length).toBeGreaterThanOrEqual(2);
expect(events[0]!.opTypes).toContain("setStyle");
expect(events[0]!.opTypes).toContain("setText");
});
});
// ─── addGsapTween via session API ─────────────────────────────────────────────
describe("addGsapTween via session", () => {
it("returns animationId and emits GSAP script patch", async () => {
const { comp, events } = await withPatch(GSAP_HTML);
const id = comp.addGsapTween("hf-box", { method: "to", duration: 0.3, properties: { x: 200 } });
expectGsapScriptPatch(id, events);
});
it("undo removes the added tween", async () => {
const comp = await openComposition(GSAP_HTML);
const scriptBefore = comp.serialize();
comp.addGsapTween("hf-box", { method: "to", duration: 0.3, properties: { x: 200 } });
comp.undo();
expect(comp.serialize()).toBe(scriptBefore);
});
});
// ─── addWithKeyframes / replaceWithKeyframes via session API ──────────────────
describe("keyframe ops via session", () => {
it("addWithKeyframes returns an animationId and emits a GSAP script patch", async () => {
const { comp, events } = await withPatch(GSAP_HTML);
const id = comp.addWithKeyframes('[data-hf-id="hf-box"]', 0, 0.5, [
{ percentage: 0, properties: { opacity: 0 } },
{ percentage: 100, properties: { opacity: 1 } },
]);
expectGsapScriptPatch(id, events);
});
it("replaceWithKeyframes returns the replacement id; undo restores the prior script", async () => {
const comp = await openComposition(GSAP_HTML);
const before = comp.serialize();
const addId = comp.addWithKeyframes('[data-hf-id="hf-box"]', 0, 0.5, [
{ percentage: 0, properties: { opacity: 0 } },
{ percentage: 100, properties: { opacity: 1 } },
]);
const newId = comp.replaceWithKeyframes(addId, '[data-hf-id="hf-box"]', 0, 0.8, [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 100, properties: { x: 100 } },
]);
expect(typeof newId).toBe("string");
expect(newId.length).toBeGreaterThan(0);
comp.undo(); // undo replace
comp.undo(); // undo add
expect(comp.serialize()).toBe(before);
});
});
// ─── dispatch with explicit origin ───────────────────────────────────────────
describe("dispatch origin", () => {
it("custom origin is propagated to patch event", async () => {
const comp = await openComposition(BASE_HTML);
const events: PatchEvent[] = [];
comp.on("patch", (e) => events.push(e));
const MY_ORIGIN = Symbol("ai-agent");
comp.dispatch({ type: "setText", target: "hf-title", value: "AI edit" }, { origin: MY_ORIGIN });
expect(events[0]?.origin).toBe(MY_ORIGIN);
});
});
+658
View File
@@ -0,0 +1,658 @@
/**
* T-contract: sub-composition scoped id suite (Stage 6 / F9).
*
* All tests use pre-inlined HTML (flat DOM with data-composition-file boundaries)
* because the SDK only opens pre-inlined HTML — sub-comp loading is not the SDK's job.
*
* Boundary detection rule: an element is a host (starts a new scope) when it has
* data-composition-file AND its value differs from its parent's data-composition-file.
* This correctly handles the outerHTML innerRoot case (same dcf as parent → not a new host)
* and nested hosts (different dcf from parent → new host).
*/
import { describe, it, expect } from "vitest";
import { parseHTML } from "linkedom";
import { ensureHfIds } from "@hyperframes/core/hf-ids";
import { RUNTIME_BOOTSTRAP_ATTR } from "@hyperframes/core";
import { resolveScoped, findById, isNewHostBoundary, bareId } from "./engine/model.js";
import { parseMutable } from "./engine/model.js";
import { buildRoots, flatElements } from "./document.js";
import { openComposition } from "./session.js";
// ─── Fixture helpers ──────────────────────────────────────────────────────────
/** Build a flat inlined HTML string simulating what inlineSubCompositions produces. */
function inlinedHtml(inner: string): string {
return `<!DOCTYPE html><html><body>${inner}</body></html>`;
}
/** Stamp hf-ids and return a linkedom document (same as parseMutable's path). */
function makeDoc(html: string) {
const { document } = parseHTML(ensureHfIds(html));
return document;
}
// ─── 1. resolveScoped ─────────────────────────────────────────────────────────
describe("resolveScoped — flat id", () => {
it("resolves a bare id at top level (same as findById)", () => {
const doc = makeDoc(
`<!DOCTYPE html><html><body><div data-hf-id="hf-aaaa">hi</div></body></html>`,
);
const el = resolveScoped(doc as unknown as Document, "hf-aaaa");
expect(el).not.toBeNull();
expect(el?.getAttribute("data-hf-id")).toBe("hf-aaaa");
});
it("returns null for a missing bare id", () => {
const doc = makeDoc(
`<!DOCTYPE html><html><body><div data-hf-id="hf-aaaa"></div></body></html>`,
);
expect(resolveScoped(doc as unknown as Document, "hf-xxxx")).toBeNull();
});
// A sub-composition ROOT is addressed by its composition id. When no element
// carries that as a data-hf-id, fall back to [data-composition-id]: comp-ids
// become first-class resolvable addresses (fixes validate / getElement).
it("resolves a bare id to a sub-comp root via data-composition-id fallback", () => {
const doc = makeDoc(
`<!DOCTYPE html><html><body><div data-hf-id="hf-host" data-composition-id="sub-1"></div></body></html>`,
) as unknown as Document;
const viaComp = resolveScoped(doc, "sub-1");
const viaHf = resolveScoped(doc, "hf-host");
expect(viaComp).not.toBeNull();
expect(viaComp?.getAttribute("data-hf-id")).toBe("hf-host");
// Both addresses resolve to the SAME host element.
expect(viaComp).toBe(viaHf);
});
// data-hf-id MUST take precedence: a bare id that matches a real data-hf-id
// never falls back to data-composition-id, even if some other element carries
// that string as its composition id.
it("data-hf-id takes precedence over data-composition-id for a bare id", () => {
const doc = makeDoc(
`<!DOCTYPE html><html><body>
<div data-hf-id="dup" class="byHfId"></div>
<div data-hf-id="hf-host" data-composition-id="dup" class="byCompId"></div>
</body></html>`,
) as unknown as Document;
const el = resolveScoped(doc, "dup");
expect(el?.getAttribute("class")).toBe("byHfId");
});
// Regression: findById is the patch-replay/undo resolver. It must agree with
// resolveScoped (forward dispatch) on an ambiguous bare id — both pick the
// canonical (top-level) instance — or undo reverts the wrong duplicate.
it("findById resolves an ambiguous bare id to the canonical instance (== resolveScoped)", () => {
const doc = makeDoc(
inlinedHtml(`
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-dup" class="inside">inside</p>
</div>
<p data-hf-id="hf-dup" class="outside">outside</p>
`),
) as unknown as Document;
const viaFind = findById(doc, "hf-dup");
const viaResolve = resolveScoped(doc, "hf-dup");
expect(viaFind).toBe(viaResolve);
expect(viaFind?.getAttribute("class")).toBe("outside");
});
});
describe("resolveScoped — scoped id", () => {
it("resolves hf-HOST/hf-LEAF inside the host's subtree", () => {
// Simulated post-inline structure: host has data-composition-file
const doc = makeDoc(
inlinedHtml(`
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-leaf">text</p>
</div>
`),
);
const el = resolveScoped(doc as unknown as Document, "hf-host/hf-leaf");
expect(el?.getAttribute("data-hf-id")).toBe("hf-leaf");
expect(el?.textContent?.trim()).toBe("text");
});
it("does NOT match a leaf outside the host when ids collide", () => {
// Two elements with the same hf-id — one inside host, one outside.
// resolveScoped must return the one INSIDE the host.
const doc = makeDoc(
inlinedHtml(`
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-dup" class="inside">inside</p>
</div>
<p data-hf-id="hf-dup" class="outside">outside</p>
`),
);
const el = resolveScoped(doc as unknown as Document, "hf-host/hf-dup");
expect(el?.getAttribute("class")).toBe("inside");
});
it("resolves 3-level nesting hf-H1/hf-H2/hf-leaf", () => {
const doc = makeDoc(
inlinedHtml(`
<div data-hf-id="hf-h1" data-composition-file="sub1.html">
<div data-hf-id="hf-h2" data-composition-file="sub2.html">
<span data-hf-id="hf-leaf">deep</span>
</div>
</div>
`),
);
const el = resolveScoped(doc as unknown as Document, "hf-h1/hf-h2/hf-leaf");
expect(el?.getAttribute("data-hf-id")).toBe("hf-leaf");
expect(el?.textContent?.trim()).toBe("deep");
});
it("returns null when the first segment is not found", () => {
const doc = makeDoc(
inlinedHtml(`<div data-hf-id="hf-other"><p data-hf-id="hf-leaf"></p></div>`),
);
expect(resolveScoped(doc as unknown as Document, "hf-host/hf-leaf")).toBeNull();
});
it("returns null when the leaf is not found inside the host", () => {
const doc = makeDoc(
inlinedHtml(`
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-other">text</p>
</div>
`),
);
expect(resolveScoped(doc as unknown as Document, "hf-host/hf-leaf")).toBeNull();
});
});
// ─── 2. ElementSnapshot.scopedId via buildRoots ───────────────────────────────
describe("ElementSnapshot.scopedId", () => {
it("top-level element has scopedId equal to its bare id", () => {
const parsed = parseMutable(
`<div data-hf-id="hf-root" data-hf-root><p data-hf-id="hf-p">hi</p></div>`,
);
const elements = flatElements(buildRoots(parsed.document));
const p = elements.find((e) => e.id === "hf-p");
expect(p?.scopedId).toBe("hf-p");
});
it("element inside sub-comp gets hf-HOST/hf-LEAF scopedId", () => {
const parsed = parseMutable(
inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-leaf">text</p>
</div>
</div>
`),
);
const elements = flatElements(buildRoots(parsed.document));
const leaf = elements.find((e) => e.id === "hf-leaf");
expect(leaf?.scopedId).toBe("hf-host/hf-leaf");
});
it("host element itself has bare scopedId (it lives in parent scope)", () => {
const parsed = parseMutable(
inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-leaf">text</p>
</div>
</div>
`),
);
const elements = flatElements(buildRoots(parsed.document));
const host = elements.find((e) => e.id === "hf-host");
expect(host?.scopedId).toBe("hf-host");
});
it("3-level nesting produces hf-H1/hf-H2/hf-LEAF", () => {
const parsed = parseMutable(
inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-h1" data-composition-file="sub1.html">
<div data-hf-id="hf-h2" data-composition-file="sub2.html">
<span data-hf-id="hf-leaf">deep</span>
</div>
</div>
</div>
`),
);
const elements = flatElements(buildRoots(parsed.document));
const leaf = elements.find((e) => e.id === "hf-leaf");
expect(leaf?.scopedId).toBe("hf-h1/hf-h2/hf-leaf");
});
it("same sub-comp mounted twice gets different scopedIds", () => {
// hf-x exists in both mounts — different host ids disambiguate
const parsed = parseMutable(
inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-mount-a" data-composition-file="sub.html">
<p data-hf-id="hf-x" class="in-a">A</p>
</div>
<div data-hf-id="hf-mount-b" data-composition-file="sub.html">
<p data-hf-id="hf-x" class="in-b">B</p>
</div>
</div>
`),
);
const elements = flatElements(buildRoots(parsed.document));
const xs = elements.filter((e) => e.id === "hf-x");
const scopedIds = xs.map((e) => e.scopedId);
expect(scopedIds).toContain("hf-mount-a/hf-x");
expect(scopedIds).toContain("hf-mount-b/hf-x");
expect(new Set(scopedIds).size).toBe(2);
});
it("outerHTML innerRoot (same dcf as parent) is NOT itself a new host boundary", () => {
// outerHTML case: host and innerRoot both get data-composition-file="sub.html"
const parsed = parseMutable(
inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-host" data-composition-file="sub.html">
<div data-hf-id="hf-inner" data-composition-id="my-sub" data-composition-file="sub.html">
<p data-hf-id="hf-leaf">text</p>
</div>
</div>
</div>
`),
);
const elements = flatElements(buildRoots(parsed.document));
const leaf = elements.find((e) => e.id === "hf-leaf");
// Leaf should be scoped under hf-host, not hf-host/hf-inner
expect(leaf?.scopedId).toBe("hf-host/hf-leaf");
});
});
// ─── 3. Dispatch to scoped target ─────────────────────────────────────────────
describe("dispatch — scoped target", () => {
it("setStyle with scoped id mutates the correct element when id collides", async () => {
// Both host subtree and sibling have an element hf-x — scoped target must hit the right one
const html = inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-x">inside</p>
</div>
<p data-hf-id="hf-x">outside</p>
</div>
`);
const comp = await openComposition(html);
comp.setStyle("hf-host/hf-x", { color: "red" });
const inside = comp.getElement("hf-host/hf-x");
const outside = comp.getElement("hf-x");
expect(inside?.inlineStyles.color).toBe("red");
// Outside element should be unchanged
expect(outside?.inlineStyles.color).toBeUndefined();
});
it("dispatch emits scoped id in patch path", async () => {
const html = inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-leaf">text</p>
</div>
</div>
`);
const comp = await openComposition(html);
const patches: string[] = [];
comp.on("patch", (e) => {
patches.push(...e.patches.map((p) => p.path));
});
comp.setStyle("hf-host/hf-leaf", { color: "blue" });
// Patch path should encode the scoped id with RFC 6902 escaping (/ → ~1)
expect(patches.some((p) => p.includes("hf-host~1hf-leaf"))).toBe(true);
});
it("getElement by scopedId returns the correct snapshot", async () => {
const html = inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-leaf">inside text</p>
</div>
</div>
`);
const comp = await openComposition(html);
const el = comp.getElement("hf-host/hf-leaf");
expect(el).not.toBeNull();
expect(el?.text).toBe("inside text");
});
it("find() returns scopedIds for sub-comp elements", async () => {
const html = inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-leaf" class="target">inside</p>
</div>
<p data-hf-id="hf-outer" class="target">outside</p>
</div>
`);
const comp = await openComposition(html);
const ids = comp.find({ tag: "p" });
expect(ids).toContain("hf-host/hf-leaf");
expect(ids).toContain("hf-outer");
});
});
// ─── 3b. Comp-root GSAP tween attribution ─────────────────────────────────────
describe("sub-comp root GSAP tween — canonical hf-id attribution", () => {
it("getElement(host).animationIds includes a tween added by comp-id target", async () => {
const html = inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-host" data-composition-id="sub-1" data-composition-file="sub.html">
<p data-hf-id="hf-leaf">text</p>
</div>
<script>var tl = gsap.timeline({ paused: true });
window.__timelines = { t: tl };</script>
</div>
`);
const comp = await openComposition(html);
// Target the sub-comp ROOT by its composition id.
const animId = comp.addGsapTween("sub-1", {
method: "to",
duration: 0.3,
properties: { x: 200 },
});
// The tween is filed under the host's own data-hf-id (canonical form), so
// it surfaces on the host element snapshot.
const host = comp.getElement("hf-host");
expect(host?.animationIds).toContain(animId);
});
});
// ─── 4. Override-set keys for scoped ids ──────────────────────────────────────
describe("override-set — scoped id keys", () => {
it("setStyle on scoped id produces scoped key in getOverrides()", async () => {
const html = inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-leaf">text</p>
</div>
</div>
`);
const comp = await openComposition(html);
comp.setStyle("hf-host/hf-leaf", { color: "green" });
const overrides = comp.getOverrides();
expect(overrides["hf-host/hf-leaf.style.color"]).toBe("green");
});
it("removeElement on host purges all sub-comp keys from override-set", async () => {
const html = inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-leaf">text</p>
</div>
</div>
`);
const comp = await openComposition(html);
comp.setStyle("hf-host/hf-leaf", { color: "green" });
comp.removeElement("hf-host");
const overrides = comp.getOverrides();
// Removal marker for host is preserved (null); scoped property sub-keys are purged
expect(overrides["hf-host"]).toBeNull();
expect(
Object.keys(overrides).some((k) => k.startsWith("hf-host/") || k.startsWith("hf-host.")),
).toBe(false);
});
});
// ─── 5. find({ composition }) filter ─────────────────────────────────────────
describe("find({ composition })", () => {
it("returns elements inside the named host sub-composition", async () => {
const html = inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-leaf">inside</p>
</div>
<p data-hf-id="hf-outer">outside</p>
</div>
`);
const comp = await openComposition(html);
const ids = comp.find({ composition: "hf-host" });
expect(ids).toContain("hf-host/hf-leaf");
expect(ids).not.toContain("hf-outer");
expect(ids).not.toContain("hf-host"); // host itself is in parent scope
});
it("returns empty array for unknown host id", async () => {
const html = inlinedHtml(
`<div data-hf-id="hf-root" data-hf-root><p data-hf-id="hf-p">x</p></div>`,
);
const comp = await openComposition(html);
expect(comp.find({ composition: "hf-no-such" })).toEqual([]);
});
it("can combine composition filter with other query fields", async () => {
const html = inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-a">match</p>
<span data-hf-id="hf-b">no</span>
</div>
</div>
`);
const comp = await openComposition(html);
const ids = comp.find({ composition: "hf-host", tag: "p" });
expect(ids).toEqual(["hf-host/hf-a"]);
});
});
// ─── 5b. Ambiguous bare id: removeElement / getElement agreement ──────────────
describe("ambiguous bare id — removeElement and getElement agree", () => {
// Inner sub-comp dup appears FIRST in document order; the canonical top-level
// dup appears AFTER it. querySelector document-order would return the inner one,
// but getElement prefers the canonical (top-level) match. The two APIs must agree.
const ambiguousHtml = () =>
inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-dup" class="inner">inner</p>
</div>
<p data-hf-id="hf-dup" class="outer">outer</p>
</div>
`);
it("bare id resolves to the canonical (top-level) instance, matching getElement", async () => {
const comp = await openComposition(ambiguousHtml());
// getElement prefers the canonical match (scopedId === id) → top-level "outer".
const got = comp.getElement("hf-dup");
expect(got?.scopedId).toBe("hf-dup");
expect(got?.classNames).toContain("outer");
// removeElement(bareId) must remove the SAME instance getElement returned.
comp.removeElement("hf-dup");
// The canonical top-level instance is gone — getElement(bareId) no longer
// finds it (and does NOT silently fall through to the inner sub-comp dup).
expect(comp.getElement("hf-dup")).toBeNull();
// The inner instance survives, addressable only via its fully-scoped path.
const inner = comp.getElement("hf-host/hf-dup");
expect(inner?.classNames).toContain("inner");
});
it("fully-scoped path still targets the inner instance exactly", async () => {
const comp = await openComposition(ambiguousHtml());
comp.removeElement("hf-host/hf-dup");
// Inner gone; canonical top-level survives.
const inner = comp.getElement("hf-host/hf-dup");
expect(inner).toBeNull();
const top = comp.getElement("hf-dup");
expect(top?.scopedId).toBe("hf-dup");
expect(top?.classNames).toContain("outer");
});
it("non-duplicated bare id still resolves and removes normally", async () => {
const html = inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-leaf">inside</p>
</div>
<p data-hf-id="hf-solo">solo</p>
</div>
`);
const comp = await openComposition(html);
expect(comp.getElement("hf-solo")?.scopedId).toBe("hf-solo");
comp.removeElement("hf-solo");
expect(comp.getElement("hf-solo")).toBeNull();
});
});
// ─── 6. Scoped id stability across serialize ──────────────────────────────────
describe("scopedId stability across serialize/re-parse", () => {
it("scopedId values are identical after serialize + re-open", async () => {
const html = inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-leaf">text</p>
</div>
<p data-hf-id="hf-outer">outer</p>
</div>
`);
const comp1 = await openComposition(html);
const serialized = comp1.serialize();
const comp2 = await openComposition(serialized);
const ids1 = comp1
.getElements()
.map((e) => e.scopedId)
.sort();
const ids2 = comp2
.getElements()
.map((e) => e.scopedId)
.sort();
expect(ids1).toEqual(ids2);
});
});
// ─── 7. isNewHostBoundary ──────────────────────────────────────────────────────
describe("isNewHostBoundary", () => {
it("is true for a host with no ancestor dcf (top-level sub-comp host)", () => {
const doc = makeDoc(
inlinedHtml(`<div data-hf-id="hf-host" data-composition-file="sub.html"></div>`),
) as unknown as Document;
const host = doc.querySelector('[data-hf-id="hf-host"]') as unknown as Element;
expect(isNewHostBoundary(host)).toBe(true);
});
it("is false for an element with no data-composition-file at all", () => {
const doc = makeDoc(inlinedHtml(`<div data-hf-id="hf-plain"></div>`)) as unknown as Document;
const el = doc.querySelector('[data-hf-id="hf-plain"]') as unknown as Element;
expect(isNewHostBoundary(el)).toBe(false);
});
it("is false for the outerHTML innerRoot (same dcf value as its host parent)", () => {
const doc = makeDoc(
inlinedHtml(`
<div data-hf-id="hf-host" data-composition-file="sub.html">
<div data-hf-id="hf-inner" data-composition-file="sub.html"></div>
</div>
`),
) as unknown as Document;
const inner = doc.querySelector('[data-hf-id="hf-inner"]') as unknown as Element;
expect(isNewHostBoundary(inner)).toBe(false);
});
it("is true for a nested host with a DIFFERENT dcf from its parent", () => {
const doc = makeDoc(
inlinedHtml(`
<div data-hf-id="hf-outer" data-composition-file="outer.html">
<div data-hf-id="hf-inner-host" data-composition-file="inner.html"></div>
</div>
`),
) as unknown as Document;
const innerHost = doc.querySelector('[data-hf-id="hf-inner-host"]') as unknown as Element;
expect(isNewHostBoundary(innerHost)).toBe(true);
});
});
// ─── 8. bareId ──────────────────────────────────────────────────────────────────
describe("bareId", () => {
it("returns the leaf segment of a scoped id", () => {
expect(bareId("hf-host/hf-leaf")).toBe("hf-leaf");
});
it("returns a deeply nested id's leaf segment", () => {
expect(bareId("hf-a/hf-b/hf-c")).toBe("hf-c");
});
it("passes a bare id through unchanged", () => {
expect(bareId("hf-solo")).toBe("hf-solo");
});
});
// ─── 9. getRootElements — no descendant duplication ────────────────────────────
describe("getRootElements", () => {
it("excludes descendants that getElements() also lists as top-level entries", async () => {
const html = inlinedHtml(`
<div data-hf-id="hf-panel">
<h1 data-hf-id="hf-title">Title</h1>
</div>
<p data-hf-id="hf-solo">solo</p>
`);
const comp = await openComposition(html);
// getElements() is flat: hf-title appears once nested under hf-panel AND
// once again as its own top-level entry.
const flatIds = comp.getElements().map((e) => e.id);
expect(flatIds).toContain("hf-title");
expect(flatIds).toContain("hf-panel");
// getRootElements() only returns true roots — hf-title is not one, since
// it's hf-panel's descendant.
const rootIds = comp.getRootElements().map((e) => e.id);
expect(rootIds).toEqual(["hf-panel", "hf-solo"]);
expect(comp.getRootElements().find((e) => e.id === "hf-panel")?.children[0]?.id).toBe(
"hf-title",
);
});
it("treats a sub-composition host as a root even though it has descendants", async () => {
const html = inlinedHtml(`
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-leaf">inside</p>
</div>
`);
const comp = await openComposition(html);
expect(comp.getRootElements().map((e) => e.id)).toEqual(["hf-host"]);
});
});
// ─── 10. serialize({ stripRuntime }) ───────────────────────────────────────────
describe("serialize({ stripRuntime })", () => {
const RUNTIME_SCRIPT =
'<script data-hyperframes-preview-runtime="1" src="https://cdn.jsdelivr.net/npm/@hyperframes/core/dist/hyperframe.runtime.iife.js"></script>';
it("keeps the embedded runtime script by default", async () => {
const html = `<!DOCTYPE html><html><head>${RUNTIME_SCRIPT}</head><body><div data-hf-id="hf-a"></div></body></html>`;
const comp = await openComposition(html);
expect(comp.serialize()).toContain("hyperframe.runtime");
});
it("strips the embedded runtime script when stripRuntime is true", async () => {
const html = `<!DOCTYPE html><html><head>${RUNTIME_SCRIPT}</head><body><div data-hf-id="hf-a"></div></body></html>`;
const comp = await openComposition(html);
const out = comp.serialize({ stripRuntime: true });
expect(out).not.toContain("hyperframe.runtime");
expect(out).toContain('data-hf-id="hf-a"');
});
it("re-exports RUNTIME_BOOTSTRAP_ATTR from @hyperframes/core, matching the marker generators stamp", async () => {
expect(RUNTIME_BOOTSTRAP_ATTR).toBe("data-hyperframes-preview-runtime");
// The fixture's marker attribute above is authored by hand — confirm it's not
// drifted from the real constant a generator would actually stamp.
expect(RUNTIME_SCRIPT).toContain(RUNTIME_BOOTSTRAP_ATTR);
});
});
+142
View File
@@ -0,0 +1,142 @@
/**
* Template-based sub-comp support — SDK must model elements inside <template>.
*
* The studio preview unwraps <template data-composition-id> content into the
* served body, so the timeline hands edits hf-ids that live inside the
* template in the raw file. Before this support, buildElement excluded the
* whole template subtree: getElements() returned [] for template comps and
* every edit produced a false element_not_found resolver divergence.
*/
import { describe, it, expect } from "vitest";
import { openComposition } from "./session.js";
const TEMPLATE_HTML = `
<template data-composition-id="test-minimal">
<div data-hf-id="hf-a" class="clip" data-start="0" data-end="3">Hello</div>
<div data-hf-id="hf-b" class="clip" data-start="3" data-end="6">World</div>
</template>
`.trim();
const TEMPLATE_UNSTAMPED_HTML = `
<template data-composition-id="test-minimal">
<div class="clip" data-start="0" data-end="3">Hello</div>
</template>
`.trim();
describe("template-based sub-comp compositions", () => {
it("getElements() models the template's inner elements (template itself absent)", async () => {
const comp = await openComposition(TEMPLATE_HTML);
const els = comp.getElements();
expect(els.map((e) => e.id)).toEqual(["hf-a", "hf-b"]);
expect(els.every((e) => e.tag !== "template")).toBe(true);
});
it("mints ids for unstamped template-inner elements on open", async () => {
const comp = await openComposition(TEMPLATE_UNSTAMPED_HTML);
expect(comp.getElements()).toHaveLength(1);
expect(comp.getElements()[0]?.id).toMatch(/^hf-/);
});
it("getElement resolves a template-inner id", async () => {
const comp = await openComposition(TEMPLATE_HTML);
expect(comp.getElement("hf-a")?.text).toBe("Hello");
});
it("setTiming on a template-inner element mutates and serializes inside the template", async () => {
const comp = await openComposition(TEMPLATE_HTML);
comp.setTiming("hf-a", { start: 1.5 });
const out = comp.serialize();
expect(out).toContain("<template");
// the mutated start must be INSIDE the template wrapper
const tpl = out.slice(out.indexOf("<template"), out.indexOf("</template>"));
expect(tpl).toContain('data-start="1.5"');
expect(comp.getElement("hf-a")?.start).toBe(1.5);
});
it("timed template-inner elements carry start/duration snapshots", async () => {
const comp = await openComposition(TEMPLATE_HTML);
const a = comp.getElement("hf-a");
expect(a?.start).toBe(0);
expect(a?.duration).toBe(3);
});
it("plain <template> (runtime clone-source) stays fully excluded", async () => {
const comp = await openComposition(
`<div data-hf-id="hf-stage" data-hf-root>x</div><template><li data-hf-id="hf-clone">item</li></template>`,
);
expect(comp.getElements().map((e) => e.id)).toEqual(["hf-stage"]);
expect(comp.getElement("hf-clone")).toBeNull();
});
it("duplicate hf-id resolves in true document order (template-inner first when it comes first)", async () => {
// A comp-template-inner element EARLIER in the document and a top-level
// element LATER share an id (copy-paste drift). The preview's unwrapped
// DOM resolves the first-in-document copy; the SDK must agree.
const comp = await openComposition(
`<template data-composition-id="t"><div data-hf-id="hf-dup" data-start="1" data-end="2">tpl</div></template><div data-hf-id="hf-dup" data-start="5" data-end="6">top</div>`,
);
expect(comp.getElement("hf-dup")?.text).toBe("tpl");
});
it("models GSAP animations declared inside a composition template", async () => {
const comp = await openComposition(`
<template data-composition-id="document-card">
<div data-hf-id="hf-line" class="line">line</div>
<script>
var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-line\\"]", { x: 100, duration: 1 }, 0);
</script>
</template>
`);
const animationIds = comp.getElement("hf-line")?.animationIds ?? [];
expect(animationIds).toHaveLength(1);
expect(comp.getAllAnimationIds()).toEqual(new Set(animationIds));
});
});
// The authored sub-comp form `hyperframes add` scaffolds: the composition id is
// on the wrapped root div, and the <template> is keyed by `id="X-template"`.
const AUTHORED_TEMPLATE_HTML = `
<template id="card-template">
<div data-composition-id="card" data-width="1280" data-height="720" data-duration="5">
<h1 class="title" style="color: rgb(255, 0, 0)">Headline</h1>
</div>
</template>
`.trim();
describe("authored template sub-comps (id on the wrapped root div)", () => {
it("enumerates and resolves inner elements", async () => {
const comp = await openComposition(AUTHORED_TEMPLATE_HTML);
const title = comp.getElements().find((e) => e.classNames.includes("title"));
expect(title).toBeTruthy();
expect(comp.getElement(title!.id)?.text).toBe("Headline");
});
it("declares a variable on the root div and round-trips through serialize", async () => {
const comp = await openComposition(AUTHORED_TEMPLATE_HTML);
comp.declareVariable({
id: "title-color",
type: "color",
label: "Title color",
default: "#ff0000",
});
expect(comp.getVariableDeclarations().map((d) => d.id)).toEqual(["title-color"]);
const serialized = comp.serialize();
expect(serialized).toContain("data-composition-variables");
// Survives a re-open (declaration is on the root div, not a stripped <html>).
const reopened = await openComposition(serialized);
expect(reopened.getVariableDeclarations().map((d) => d.id)).toEqual(["title-color"]);
});
it("does not treat a plain clone-source template as a composition", async () => {
const comp = await openComposition(
`<div data-composition-id="c" data-width="100" data-height="100" data-duration="1" data-start="0">` +
`<template id="particle"><span class="dot">·</span></template></div>`,
);
// The particle template's inner <span> must NOT be enumerated (it is cloned
// N times at runtime; a persisted inner id would duplicate across clones).
expect(comp.getElements().some((e) => e.classNames.includes("dot"))).toBe(false);
});
});
+649
View File
@@ -0,0 +1,649 @@
/**
* Session-level behavior: history coalescing invariants and T3 override replay.
*/
import { describe, it, expect } from "vitest";
import { openComposition } from "./session.js";
import type { DraftProps, ElementAtPointResult, PreviewAdapter } from "./adapters/types.js";
const BASE_HTML = `
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px" data-duration="5">
<h1 data-hf-id="hf-title" data-start="0" data-end="3" style="color: #fff; font-size: 64px">Hello World</h1>
<p data-hf-id="hf-sub" style="opacity: 0.5">subtitle</p>
<img data-hf-id="hf-logo" src="/logo.png" alt="Logo" />
</div>
`.trim();
class TestPreviewAdapter implements PreviewAdapter {
private selectionHandlers: Array<(ids: string[]) => void> = [];
// fallow-ignore-next-line code-duplication
elementAtPoint(_x: number, _y: number, _opts?: { atTime?: number }): ElementAtPointResult | null {
return null;
}
applyDraft(_id: string, _props: DraftProps): void {
// Test adapter tracks selection only.
}
commitPreview(): void {
// Test adapter tracks selection only.
}
cancelPreview(): void {
// Test adapter tracks selection only.
}
select(ids: string[], _opts?: { additive?: boolean }): void {
this.emitSelection(ids);
}
on(_event: "selection", handler: (ids: string[]) => void): () => void {
this.selectionHandlers.push(handler);
return () => {
this.selectionHandlers = this.selectionHandlers.filter((h) => h !== handler);
};
}
emitSelection(ids: readonly string[]): void {
const snapshot = [...ids];
for (const handler of this.selectionHandlers) {
handler([...snapshot]);
}
}
listenerCount(): number {
return this.selectionHandlers.length;
}
}
// ─── Preview selection bridge ────────────────────────────────────────────────
describe("preview selection bridge", () => {
it("mirrors preview selection into session state and notifies subscribers", async () => {
const preview = new TestPreviewAdapter();
const comp = await openComposition(BASE_HTML, { preview });
const events: string[][] = [];
comp.on("selectionchange", (ids) => events.push([...ids]));
preview.select(["hf-title"]);
expect(comp.getSelection()).toEqual(["hf-title"]);
expect(comp.selection().ids).toEqual(["hf-title"]);
expect(events).toEqual([["hf-title"]]);
});
it("selection proxy applies edits to ids selected by the preview", async () => {
const preview = new TestPreviewAdapter();
const comp = await openComposition(BASE_HTML, { preview });
preview.select(["hf-title", "hf-sub"]);
comp.selection().setStyle({ color: "#123456" });
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#123456");
expect(comp.getElement("hf-sub")?.inlineStyles["color"]).toBe("#123456");
});
it("dispose unsubscribes from preview selection events", async () => {
const preview = new TestPreviewAdapter();
const comp = await openComposition(BASE_HTML, { preview });
expect(preview.listenerCount()).toBe(1);
comp.dispose();
expect(preview.listenerCount()).toBe(0);
preview.select(["hf-title"]);
expect(comp.getSelection()).toEqual([]);
});
});
// ─── History coalescing ───────────────────────────────────────────────────────
describe("history coalescing", () => {
it("rapid edits to the SAME property coalesce into one undo entry", async () => {
const comp = await openComposition(BASE_HTML);
comp.setStyle("hf-title", { color: "#111" });
comp.setStyle("hf-title", { color: "#222" });
comp.setStyle("hf-title", { color: "#333" });
comp.undo();
const el = comp.getElement("hf-title");
expect(el?.inlineStyles["color"]).toBe("#fff"); // back to original in ONE step
});
it("rapid edits to DIFFERENT elements do NOT coalesce — undo reverts only the last edit", async () => {
const comp = await openComposition(BASE_HTML);
comp.setStyle("hf-title", { color: "#111" });
comp.setStyle("hf-sub", { opacity: "1" });
comp.undo();
expect(comp.getElement("hf-sub")?.inlineStyles["opacity"]).toBe("0.5"); // last edit reverted
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#111"); // first edit intact
comp.undo();
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#fff");
});
it("rapid edits to different properties of the same element do not coalesce", async () => {
const comp = await openComposition(BASE_HTML);
comp.setStyle("hf-title", { color: "#111" });
comp.setStyle("hf-title", { fontSize: "96px" });
comp.undo();
expect(comp.getElement("hf-title")?.inlineStyles["fontSize"]).toBe("64px");
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#111");
});
});
// ─── T3 override replay ───────────────────────────────────────────────────────
describe("override-set replay on open", () => {
it("applies style, text, and attribute overrides to the base document", async () => {
const comp = await openComposition(BASE_HTML, {
overrides: {
"hf-title.style.color": "#e63946",
"hf-title.text": "Edited headline",
"hf-logo.attr.src": "/new-logo.png",
},
});
const title = comp.getElement("hf-title");
expect(title?.inlineStyles["color"]).toBe("#e63946");
expect(title?.text).toBe("Edited headline");
expect(comp.getElement("hf-logo")?.attributes["src"]).toBe("/new-logo.png");
const html = comp.serialize();
expect(html).toContain("Edited headline");
expect(html).toContain("/new-logo.png");
expect(html).toContain("#e63946");
});
it("applies timing overrides (computed absolute end)", async () => {
const comp = await openComposition(BASE_HTML, {
overrides: { "hf-title.timing.end": 4.5 },
});
expect(comp.serialize()).toContain('data-end="4.5"');
});
it("removes elements marked with the null removal marker", async () => {
const comp = await openComposition(BASE_HTML, {
overrides: { "hf-sub": null },
});
expect(comp.getElement("hf-sub")).toBeNull();
expect(comp.serialize()).not.toContain("subtitle");
});
it("treats property-level null as a deletion marker — removes the property from the base", async () => {
// Null in the override-set is emitted only from patchRemove (explicit deletion).
// On replay against a base that has the property set, it must be removed.
const comp = await openComposition(BASE_HTML, {
overrides: { "hf-title.style.color": null },
});
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBeUndefined();
});
it("null removal override on non-existent property is a safe no-op", async () => {
// backgroundColor doesn't exist on hf-title in the base; removing it must not throw.
const comp = await openComposition(BASE_HTML, {
overrides: { "hf-title.style.backgroundColor": null },
});
expect(comp.getElement("hf-title")).not.toBeNull();
expect(comp.getElement("hf-title")?.inlineStyles["backgroundColor"]).toBeUndefined();
});
it("getOverrides returns the set the session was opened with", async () => {
const overrides = { "hf-title.style.color": "#e63946" };
const comp = await openComposition(BASE_HTML, { overrides });
expect(comp.getOverrides()).toEqual(overrides);
});
});
// ─── batch() transactional rollback ───────────────────────────────────────────
describe("batch rollback on throw", () => {
it("reverts DOM mutations and override-set when the callback throws", async () => {
const comp = await openComposition(BASE_HTML);
const htmlBefore = comp.serialize();
expect(() =>
comp.batch(() => {
comp.setStyle("hf-title", { color: "#e63946" });
comp.setText("hf-sub", "changed");
throw new Error("user cancelled");
}),
).toThrowError("user cancelled");
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#fff");
expect(comp.getElement("hf-sub")?.text).toBe("subtitle");
expect(comp.serialize()).toBe(htmlBefore);
expect(comp.getOverrides()).toEqual({});
});
it("a throwing batch leaves no history entry — undo is a no-op", async () => {
const comp = await openComposition(BASE_HTML);
try {
comp.batch(() => {
comp.setStyle("hf-title", { color: "#e63946" });
throw new Error("boom");
});
} catch {
// expected
}
comp.undo();
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#fff");
});
});
// ─── canUndo / canRedo ────────────────────────────────────────────────────────
describe("canUndo / canRedo", () => {
it("returns false before any mutation", async () => {
const comp = await openComposition(BASE_HTML);
expect(comp.canUndo()).toBe(false);
expect(comp.canRedo()).toBe(false);
});
it("canUndo true after a mutation, false after undoing back to start", async () => {
const comp = await openComposition(BASE_HTML);
comp.setStyle("hf-title", { color: "#ff0000" });
expect(comp.canUndo()).toBe(true);
expect(comp.canRedo()).toBe(false);
comp.undo();
expect(comp.canUndo()).toBe(false);
expect(comp.canRedo()).toBe(true);
});
it("canRedo cleared after a new mutation", async () => {
const comp = await openComposition(BASE_HTML);
comp.setStyle("hf-title", { color: "#ff0000" });
comp.undo();
expect(comp.canRedo()).toBe(true);
comp.setStyle("hf-title", { color: "#00ff00" });
expect(comp.canRedo()).toBe(false);
});
it("returns false in embedded (T3) mode — no history", async () => {
const comp = await openComposition(BASE_HTML, { overrides: {} });
comp.setStyle("hf-title", { color: "#ff0000" });
expect(comp.canUndo()).toBe(false);
expect(comp.canRedo()).toBe(false);
});
});
// ─── override-set orphan cleanup ──────────────────────────────────────────────
describe("override-set orphan cleanup on removeElement", () => {
it("purges property keys for removed element from the override-set", async () => {
const comp = await openComposition(BASE_HTML);
comp.setStyle("hf-title", { color: "#ff0000", fontSize: "96px" });
expect(Object.keys(comp.getOverrides())).toContain("hf-title.style.color");
comp.removeElement("hf-title");
const overrides = comp.getOverrides();
// removal marker present
expect(overrides["hf-title"]).toBeNull();
// orphan property keys gone
expect(Object.keys(overrides)).not.toContain("hf-title.style.color");
expect(Object.keys(overrides)).not.toContain("hf-title.style.fontSize");
});
it("property keys for other elements are unaffected", async () => {
const comp = await openComposition(BASE_HTML);
comp.setStyle("hf-title", { color: "#ff0000" });
comp.setStyle("hf-sub", { opacity: "1" });
comp.removeElement("hf-title");
const overrides = comp.getOverrides();
expect(overrides["hf-sub.style.opacity"]).toBe("1");
});
});
describe("single-dispatch undo reverses the inverse patch list", () => {
// A single dispatch that emits order-dependent inverse patches (here a nested
// parent+child removeElement) must undo in reverse application order. Without
// the reverse, undo replays 'add child' before 'add parent' → the child has no
// parent to attach to and is dropped.
it("removeElement([child, parent]) undo restores both, child included", async () => {
const NESTED = `<div data-hf-id="hf-root" data-hf-root data-duration="5">
<div data-hf-id="hf-parent"><span data-hf-id="hf-child">x</span></div>
</div>`;
const comp = await openComposition(NESTED);
comp.dispatch({ type: "removeElement", target: ["hf-child", "hf-parent"] });
expect(comp.getElement("hf-parent")).toBeNull();
expect(comp.getElement("hf-child")).toBeNull();
comp.undo();
expect(comp.getElement("hf-parent")).not.toBeNull();
expect(comp.getElement("hf-child")).not.toBeNull();
});
// Defense-in-depth: an aliased multi-target (the same element twice) makes the
// 2nd id capture the value the 1st already wrote; undo must replay the inverse
// in reverse to land on the ORIGINAL, not the intermediate.
it("setStyle with a duplicate target undoes to the original, not the intermediate", async () => {
const comp = await openComposition(BASE_HTML);
comp.dispatch({
type: "setStyle",
target: ["hf-title", "hf-title"],
styles: { fontSize: "96px" },
});
expect(comp.getElement("hf-title")?.inlineStyles.fontSize).toBe("96px");
comp.undo();
expect(comp.getElement("hf-title")?.inlineStyles.fontSize).toBe("64px");
});
});
// ─── setSelection / getSelection / selectionchange ───────────────────────────
describe("setSelection", () => {
it("getSelection returns empty array before any setSelection call", async () => {
const comp = await openComposition(BASE_HTML);
expect(comp.getSelection()).toEqual([]);
});
it("setSelection updates getSelection", async () => {
const comp = await openComposition(BASE_HTML);
comp.setSelection(["hf-title"]);
expect(comp.getSelection()).toEqual(["hf-title"]);
});
it("setSelection with multiple ids", async () => {
const comp = await openComposition(BASE_HTML);
comp.setSelection(["hf-title", "hf-sub"]);
expect(comp.getSelection()).toEqual(["hf-title", "hf-sub"]);
});
it("setSelection([]) clears selection", async () => {
const comp = await openComposition(BASE_HTML);
comp.setSelection(["hf-title"]);
comp.setSelection([]);
expect(comp.getSelection()).toEqual([]);
});
it("setSelection fires selectionchange with new ids", async () => {
const comp = await openComposition(BASE_HTML);
const calls: string[][] = [];
comp.on("selectionchange", (ids) => calls.push(ids));
comp.setSelection(["hf-title"]);
expect(calls).toEqual([["hf-title"]]);
});
it("setSelection fires selectionchange with empty array when clearing", async () => {
const comp = await openComposition(BASE_HTML);
comp.setSelection(["hf-title"]);
const calls: string[][] = [];
comp.on("selectionchange", (ids) => calls.push(ids));
comp.setSelection([]);
expect(calls).toEqual([[]]);
});
it("selectionchange listener receives a fresh copy each call", async () => {
const comp = await openComposition(BASE_HTML);
const snapshots: string[][] = [];
comp.on("selectionchange", (ids) => snapshots.push(ids));
comp.setSelection(["hf-title"]);
comp.setSelection(["hf-sub"]);
expect(snapshots[0]).toEqual(["hf-title"]);
expect(snapshots[1]).toEqual(["hf-sub"]);
});
it("unsubscribed listener does not fire", async () => {
const comp = await openComposition(BASE_HTML);
const calls: string[][] = [];
const off = comp.on("selectionchange", (ids) => calls.push(ids));
off();
comp.setSelection(["hf-title"]);
expect(calls).toHaveLength(0);
});
it("selection() proxy operates on ids at call time", async () => {
const comp = await openComposition(BASE_HTML);
comp.setSelection(["hf-title"]);
const proxy = comp.selection();
expect(proxy.ids).toEqual(["hf-title"]);
});
it("setSelection does not affect undo stack", async () => {
const comp = await openComposition(BASE_HTML);
comp.setStyle("hf-title", { color: "#ff0000" });
comp.setSelection(["hf-sub"]);
expect(comp.canUndo()).toBe(true);
comp.undo();
// selection must not have been pushed to history
expect(comp.canUndo()).toBe(false);
});
it("setSelection does not emit a patch event", async () => {
const comp = await openComposition(BASE_HTML);
const patches: unknown[] = [];
comp.on("patch", (e) => patches.push(e));
comp.setSelection(["hf-title"]);
expect(patches).toHaveLength(0);
});
// fallow-ignore-next-line code-duplication
it("setSelection with same ids does not fire selectionchange again", async () => {
const comp = await openComposition(BASE_HTML);
const calls: string[][] = [];
comp.on("selectionchange", (ids) => calls.push(ids));
comp.setSelection(["hf-title"]);
comp.setSelection(["hf-title"]); // same ids — must be a no-op
expect(calls).toHaveLength(1);
});
it("setSelection with same ids in different order fires selectionchange", async () => {
const comp = await openComposition(BASE_HTML);
const calls: string[][] = [];
comp.on("selectionchange", (ids) => calls.push(ids));
comp.setSelection(["hf-title", "hf-sub"]);
comp.setSelection(["hf-sub", "hf-title"]); // order differs — must fire
expect(calls).toHaveLength(2);
});
it("setSelection de-duplicates repeated ids", async () => {
const comp = await openComposition(BASE_HTML);
comp.setSelection(["hf-title", "hf-title", "hf-sub", "hf-title"]);
expect(comp.getSelection()).toEqual(["hf-title", "hf-sub"]);
});
it("setSelection with duplicates matching stored selection does not fire selectionchange", async () => {
const comp = await openComposition(BASE_HTML);
const calls: string[][] = [];
comp.on("selectionchange", (ids) => calls.push(ids));
comp.setSelection(["hf-title"]);
comp.setSelection(["hf-title", "hf-title"]); // de-duped = ["hf-title"] — no change
expect(calls).toHaveLength(1);
});
});
describe("animationIds population", () => {
const GSAP_HTML = `
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px">
<div data-hf-id="hf-box" style="opacity: 0">box</div>
<div data-hf-id="hf-plain">plain</div>
<script>var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0.2);
window.__timelines["t"] = tl;</script>
</div>`.trim();
it("attaches the parser's stable tween id to the targeted element", async () => {
const comp = await openComposition(GSAP_HTML);
const box = comp.getElement("hf-box");
expect(box?.animationIds.length).toBe(1);
// Stable id-space shared with studio-api / GSAP ops: targetSelector-method-position.
expect(box?.animationIds[0]).toContain("hf-box");
expect(box?.animationIds[0]).toContain("-to-");
});
it("leaves untargeted elements with an empty animationIds", async () => {
const comp = await openComposition(GSAP_HTML);
expect(comp.getElement("hf-plain")?.animationIds).toEqual([]);
});
it("the populated id is dispatchable as a removeGsapTween target", async () => {
const comp = await openComposition(GSAP_HTML);
const id = comp.getElement("hf-box")?.animationIds[0];
expect(id).toBeDefined();
if (id) expect(comp.can({ type: "removeGsapTween", animationId: id }).ok).toBe(true);
});
it("attaches multiple distinct tween ids when one element has several tweens", async () => {
const html = `
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px">
<div data-hf-id="hf-box" style="opacity: 0">box</div>
<script>var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0);
tl.from("[data-hf-id=\\"hf-box\\"]", { x: -100, duration: 0.5 }, 1);
window.__timelines["t"] = tl;</script>
</div>`.trim();
const ids = (await openComposition(html)).getElement("hf-box")?.animationIds ?? [];
expect(ids.length).toBe(2);
expect(new Set(ids).size).toBe(2); // distinct
});
it("fans a shared-selector tween out to every matched element", async () => {
const html = `
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px">
<div data-hf-id="hf-a" class="fade">a</div>
<div data-hf-id="hf-b" class="fade">b</div>
<script>var tl = gsap.timeline({ paused: true });
tl.to(".fade", { opacity: 1, duration: 0.5 }, 0);
window.__timelines["t"] = tl;</script>
</div>`.trim();
const comp = await openComposition(html);
const a = comp.getElement("hf-a")?.animationIds ?? [];
const b = comp.getElement("hf-b")?.animationIds ?? [];
expect(a.length).toBe(1);
expect(b).toEqual(a); // same tween id on both matched elements
});
});
describe("getAllAnimationIds", () => {
it("includes a tween id even when its selector matches no live DOM element", async () => {
const html = /* html */ `<!DOCTYPE html>
<html><body>
<div data-hf-id="hf-box" style="color: red">Hello</div>
<script>var tl = gsap.timeline({ paused: true }); tl.to("#does-not-exist", { x: 100, duration: 1 }, 3);</script>
</body></html>`;
const comp = await openComposition(html);
const flatIds = comp.getAllAnimationIds();
expect(flatIds.size).toBeGreaterThan(0);
const [unmatchedId] = [...flatIds];
// Confirms the bug this fixes: no element's animationIds contains this id,
// because "#does-not-exist" never CSS-matches anything in the document.
expect(comp.getElements().some((el) => el.animationIds.includes(unmatchedId ?? ""))).toBe(
false,
);
});
it("returns an empty set when the composition has no GSAP script", async () => {
const html = /* html */ `<!DOCTYPE html>
<html><body><div data-hf-id="hf-box">Hello</div></body></html>`;
const comp = await openComposition(html);
expect(comp.getAllAnimationIds().size).toBe(0);
});
it("still includes ids for tweens that DO match a live DOM element", async () => {
const html = /* html */ `<!DOCTYPE html>
<html><body>
<div data-hf-id="hf-box" style="color: red">Hello</div>
<script>var tl = gsap.timeline({ paused: true }); tl.to("[data-hf-id=\\"hf-box\\"]", { x: 100, duration: 1 }, 0);</script>
</body></html>`;
const comp = await openComposition(html);
const realId = comp.getElements().flatMap((e) => [...e.animationIds])[0] ?? "";
expect(realId).not.toBe("");
expect(comp.getAllAnimationIds().has(realId)).toBe(true);
});
});
// ─── getVariableValue / listVariables / declareVariable / removeVariable ──────
const VARIABLES_HTML = `<!DOCTYPE html>
<html data-composition-id="c1" data-composition-duration="5" data-composition-variables='${JSON.stringify(
[{ id: "brand-color", type: "color", label: "Brand color", default: "#0066cc" }],
)}'>
<body>${BASE_HTML}</body>
</html>`;
describe("variable declarations (Composition API)", () => {
it("getVariableValue reads a declared variable's current default", async () => {
const comp = await openComposition(VARIABLES_HTML);
expect(comp.getVariableValue("brand-color")).toBe("#0066cc");
});
it("getVariableValue returns undefined for an undeclared id", async () => {
const comp = await openComposition(VARIABLES_HTML);
expect(comp.getVariableValue("never-declared")).toBeUndefined();
});
it("listVariables returns every declared variable's full schema", async () => {
const comp = await openComposition(VARIABLES_HTML);
expect(comp.listVariables()).toEqual([
{ id: "brand-color", type: "color", label: "Brand color", default: "#0066cc" },
]);
});
it("listVariables returns [] when the composition declares none", async () => {
const comp = await openComposition(BASE_HTML);
expect(comp.listVariables()).toEqual([]);
});
it("declareVariable creates a new declaration a variables panel can list immediately", async () => {
const comp = await openComposition(VARIABLES_HTML);
comp.declareVariable({ id: "brand-title", type: "string", label: "Title", default: "Hi" });
expect(comp.getVariableValue("brand-title")).toBe("Hi");
expect(comp.listVariables()).toHaveLength(2);
});
it("declareVariable can create where setVariableValue's model write silently no-ops", async () => {
// Full document (not a fragment): declareVariable refuses fragment sources,
// whose synthetic <html> is stripped on serialize. No declarations yet.
const comp = await openComposition(`<!DOCTYPE html><html><body>${BASE_HTML}</body></html>`);
comp.setVariableValue("never-declared", "x");
expect(comp.getVariableValue("never-declared")).toBeUndefined();
comp.declareVariable({ id: "never-declared", type: "string", label: "New", default: "x" });
expect(comp.getVariableValue("never-declared")).toBe("x");
});
it("removeVariable removes the declaration; listVariables reflects it immediately", async () => {
const comp = await openComposition(VARIABLES_HTML);
comp.removeVariable("brand-color");
expect(comp.listVariables()).toEqual([]);
expect(comp.getVariableValue("brand-color")).toBeUndefined();
});
it("declareVariable / removeVariable both support undo", async () => {
const comp = await openComposition(VARIABLES_HTML);
comp.declareVariable({ id: "brand-title", type: "string", label: "Title", default: "Hi" });
expect(comp.listVariables()).toHaveLength(2);
comp.undo();
expect(comp.listVariables()).toHaveLength(1);
comp.removeVariable("brand-color");
expect(comp.listVariables()).toEqual([]);
comp.undo();
expect(comp.listVariables()).toEqual([
{ id: "brand-color", type: "color", label: "Brand color", default: "#0066cc" },
]);
});
it("declareVariable / removeVariable both support redo after undo", async () => {
const comp = await openComposition(VARIABLES_HTML);
comp.declareVariable({ id: "brand-title", type: "string", label: "Title", default: "Hi" });
comp.undo();
comp.redo();
expect(comp.listVariables()).toEqual([
{ id: "brand-color", type: "color", label: "Brand color", default: "#0066cc" },
{ id: "brand-title", type: "string", label: "Title", default: "Hi" },
]);
comp.removeVariable("brand-color");
comp.undo();
comp.redo();
expect(comp.listVariables()).toEqual([
{ id: "brand-title", type: "string", label: "Title", default: "Hi" },
]);
});
});
+374
View File
@@ -0,0 +1,374 @@
/**
* WS-C — getElementTimings / setElementTiming / setHold tests.
*
* Tests the session-layer wiring for the new typed methods.
* happy-dom can't do GSAP seek/layout so we test DOM attribute reads and
* dispatch behavior directly.
*/
import { describe, it, expect } from "vitest";
import { openComposition } from "./session.js";
// ─── Fixtures ─────────────────────────────────────────────────────────────────
/** Duration-authored clip (data-duration preferred by handleSetTiming). */
const DURATION_AUTHORED_HTML = `
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px" data-duration="10">
<h1 data-hf-id="hf-title" data-start="0" data-duration="3">Hello</h1>
<p data-hf-id="hf-sub" data-start="2" data-duration="2">World</p>
</div>
`.trim();
/** End-authored clip (data-end only, no data-duration). */
const END_AUTHORED_HTML = `
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px" data-duration="10">
<h1 data-hf-id="hf-title" data-start="1" data-end="4">Hello</h1>
</div>
`.trim();
/** Both data-duration and data-end (data-duration wins). */
const BOTH_ATTRS_HTML = `
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px" data-duration="10">
<h1 data-hf-id="hf-title" data-start="0" data-duration="3" data-end="99">Hello</h1>
</div>
`.trim();
/** Has a GSAP script with addLabel. */
const GSAP_LABEL_HTML = `
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
<div data-hf-id="hf-box" data-start="0" data-duration="5" style="opacity:0"></div>
<script>var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 1 }, 0);
tl.addLabel("intro", 0.5);
tl.addLabel("outro", 4.0);
window.__timelines = { t: tl };</script>
</div>
`.trim();
const GSAP_TEMPLATE_LABEL_HTML = `
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
<template data-composition-id="label-sub-comp">
<div data-hf-id="hf-box" data-start="0" data-duration="5"></div>
<script>
var tl = gsap.timeline({ paused: true });
tl.addLabel("template-label", 1.5);
window.__timelines = { t: tl };
</script>
</template>
</div>
`.trim();
// ─── getElementTimings — duration-authored clips ──────────────────────────────
describe("getElementTimings — duration-authored clips", () => {
it("reads enterAt = data-start, exitAt = data-start + data-duration", async () => {
const comp = await openComposition(DURATION_AUTHORED_HTML);
const timings = comp.getElementTimings();
expect(timings["hf-title"]).toMatchObject({ enterAt: 0, exitAt: 3 });
expect(timings["hf-sub"]).toMatchObject({ enterAt: 2, exitAt: 4 });
});
it("returns empty labels array when no GSAP script", async () => {
const comp = await openComposition(DURATION_AUTHORED_HTML);
const timings = comp.getElementTimings();
expect(timings["hf-title"]?.labels).toEqual([]);
});
});
// ─── getElementTimings — end-authored clips ───────────────────────────────────
describe("getElementTimings — end-authored clips", () => {
it("falls back to data-end data-start when no data-duration", async () => {
const comp = await openComposition(END_AUTHORED_HTML);
const timings = comp.getElementTimings();
// enterAt = 1, exitAt = 4 (from data-end = 4, data-start = 1, duration = 3)
expect(timings["hf-title"]).toMatchObject({ enterAt: 1, exitAt: 4 });
});
});
// ─── getElementTimings — data-duration wins over data-end ────────────────────
describe("getElementTimings — data-duration wins over data-end", () => {
it("uses data-duration when both data-duration and data-end are present", async () => {
const comp = await openComposition(BOTH_ATTRS_HTML);
const timings = comp.getElementTimings();
// data-duration=3 wins; exitAt = 0+3=3, NOT from data-end=99
expect(timings["hf-title"]).toMatchObject({ enterAt: 0, exitAt: 3 });
});
});
// ─── getElementTimings — labels from GSAP script ─────────────────────────────
describe("getElementTimings — GSAP labels", () => {
it("returns labels whose position falls within [enterAt, exitAt]", async () => {
const comp = await openComposition(GSAP_LABEL_HTML);
const timings = comp.getElementTimings();
// hf-box: enterAt=0, exitAt=5; labels "intro"@0.5 and "outro"@4.0 are both in range
const box = timings["hf-box"];
expect(box?.labels).toContain("intro");
expect(box?.labels).toContain("outro");
});
it("parses labels fresh — no stale cache after mutation", async () => {
const comp = await openComposition(GSAP_LABEL_HTML);
const before = comp.getElementTimings()["hf-box"]?.labels ?? [];
expect(before).toContain("intro");
// Move the element so timing changes; labels should still parse fresh
comp.setTiming("hf-box", { start: 0, duration: 5 }); // no-op but triggers re-parse
const after = comp.getElementTimings()["hf-box"]?.labels ?? [];
expect(after).toContain("intro");
});
it("reads labels from GSAP scripts inside composition templates", async () => {
const comp = await openComposition(GSAP_TEMPLATE_LABEL_HTML);
const labels = comp.getElementTimings()["hf-box"]?.labels ?? [];
expect(labels).toContain("template-label");
});
});
// ─── getElementTimings — relative data-start references ──────────────────────
/** "intro" starts at data-start=1 for 3s (ends at 4). "outro" starts 2s after intro ends. */
const RELATIVE_START_HTML = `
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px" data-duration="20">
<h1 data-hf-id="hf-intro" data-start="1" data-duration="3">Intro</h1>
<p data-hf-id="hf-outro" data-start="hf-intro + 2" data-duration="4">Outro</p>
<p data-hf-id="hf-right-after" data-start="hf-intro" data-duration="1">Right after</p>
</div>
`.trim();
describe("getElementTimings — relative data-start references", () => {
it("resolves 'ref + offset' against the referenced element's resolved end", async () => {
const comp = await openComposition(RELATIVE_START_HTML);
const timings = comp.getElementTimings();
// hf-intro: enterAt=1, exitAt=4
expect(timings["hf-intro"]).toMatchObject({ enterAt: 1, exitAt: 4 });
// hf-outro: "hf-intro + 2" = intro's exitAt (4) + 2 = 6
expect(timings["hf-outro"]).toMatchObject({ enterAt: 6, exitAt: 10 });
});
it("resolves a bare reference (no offset) to the referenced element's exitAt", async () => {
const comp = await openComposition(RELATIVE_START_HTML);
const timings = comp.getElementTimings();
expect(timings["hf-right-after"]).toMatchObject({ enterAt: 4, exitAt: 5 });
});
it("resolves to 0 (not NaN) when the reference target doesn't exist", async () => {
const html = `
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
<p data-hf-id="hf-orphan" data-start="hf-nonexistent + 5" data-duration="2"></p>
</div>
`.trim();
const comp = await openComposition(html);
const timings = comp.getElementTimings();
expect(timings["hf-orphan"]).toMatchObject({ enterAt: 0, exitAt: 2 });
});
it("resolves a chained reference (A -> B -> C) through the recursive resolver", async () => {
const html = `
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
<h1 data-hf-id="hf-a" data-start="0" data-duration="2">A</h1>
<p data-hf-id="hf-b" data-start="hf-a" data-duration="3">B</p>
<p data-hf-id="hf-c" data-start="hf-b + 1" data-duration="1">C</p>
</div>
`.trim();
const comp = await openComposition(html);
const timings = comp.getElementTimings();
expect(timings["hf-a"]).toMatchObject({ enterAt: 0, exitAt: 2 });
// hf-b: "hf-a" (no offset) = a's exitAt (2)
expect(timings["hf-b"]).toMatchObject({ enterAt: 2, exitAt: 5 });
// hf-c: "hf-b + 1" = b's exitAt (5) + 1 = 6
expect(timings["hf-c"]).toMatchObject({ enterAt: 6, exitAt: 7 });
});
it("terminates (not an infinite loop) on a direct self-reference", async () => {
const html = `
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
<p data-hf-id="hf-self" data-start="hf-self" data-duration="2"></p>
</div>
`.trim();
const comp = await openComposition(html);
const timings = comp.getElementTimings();
// The cycle guard fires on the re-entrant call, contributing 0 for the
// self-reference's own start — the element's OWN duration (2) still applies on
// top of that, so enterAt=2, not 0. The guard's job is termination, not zeroing
// the whole chain.
expect(timings["hf-self"]).toMatchObject({ enterAt: 2, exitAt: 4 });
expect(Number.isFinite(timings["hf-self"]?.enterAt)).toBe(true);
});
it("terminates (not an infinite loop) on a mutual A <-> B reference cycle", async () => {
const html = `
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
<p data-hf-id="hf-a" data-start="hf-b" data-duration="2"></p>
<p data-hf-id="hf-b" data-start="hf-a" data-duration="3"></p>
</div>
`.trim();
const comp = await openComposition(html);
const timings = comp.getElementTimings();
// Document order resolves hf-a first: it recurses into hf-b, which recurses back
// into hf-a — the guard fires there (returns 0), so hf-b's start = 0 + hf-a's
// duration (2) = 2. Back in hf-a's own resolution: start = hf-b's start (2) +
// hf-b's duration (3) = 5. Neither number is "correct" for a genuine cycle —
// the point is both are finite and the recursion terminates.
expect(timings["hf-a"]).toMatchObject({ enterAt: 5, exitAt: 7 });
expect(timings["hf-b"]).toMatchObject({ enterAt: 2, exitAt: 5 });
expect(Number.isFinite(timings["hf-a"]?.enterAt)).toBe(true);
expect(Number.isFinite(timings["hf-b"]?.enterAt)).toBe(true);
});
it("resolves a colliding bare id to the TOP-LEVEL match, not a same-scope sibling", async () => {
// Bare ids have no scope syntax — resolveScoped's bare-id rule prefers the
// canonical top-level match when one exists, same as the runtime's own (also
// global, not scope-aware) resolver. Both the outer document AND the sub-comp
// author an element with the SAME bare id "hf-intro" — a genuine collision.
const html = `
<!DOCTYPE html><html><body>
<h1 data-hf-id="hf-intro" data-start="0" data-duration="10">Outer intro</h1>
<div data-hf-id="hf-host" data-composition-file="sub.html">
<h1 data-hf-id="hf-intro" data-start="0" data-duration="1">Inner intro (same bare id)</h1>
<p data-hf-id="hf-outro" data-start="hf-intro + 1" data-duration="1">Inner outro</p>
</div>
</body></html>
`.trim();
const comp = await openComposition(html);
const timings = comp.getElementTimings();
// "hf-intro + 1", authored on an element INSIDE the sub-comp, still resolves
// against the OUTER hf-intro (exitAt=10) — 10 + 1 = 11 — not the same-scope
// inner hf-intro (exitAt=1, which would give 2). This pins current behavior;
// it is a real authoring footgun, not a claim that it's the ideal semantics.
expect(timings["hf-host/hf-outro"]).toMatchObject({ enterAt: 11, exitAt: 12 });
});
});
// ─── setElementTiming — sparse map + batched dispatch ────────────────────────
describe("setElementTiming", () => {
it("applies sparse timing map to multiple elements", async () => {
const comp = await openComposition(DURATION_AUTHORED_HTML);
comp.setElementTiming({
"hf-title": { start: 1, duration: 2 },
"hf-sub": { start: 4 },
});
const timings = comp.getElementTimings();
expect(timings["hf-title"]).toMatchObject({ enterAt: 1, exitAt: 3 });
expect(timings["hf-sub"]).toMatchObject({ enterAt: 4 });
});
it("emits exactly one patch event for multiple entries (batched)", async () => {
const comp = await openComposition(DURATION_AUTHORED_HTML);
const patches: unknown[] = [];
comp.on("patch", (e) => patches.push(e));
comp.setElementTiming({
"hf-title": { start: 0.5 },
"hf-sub": { start: 3.0 },
});
// One batch → one patch event
expect(patches).toHaveLength(1);
});
it("is a no-op for empty map", async () => {
const comp = await openComposition(DURATION_AUTHORED_HTML);
const patches: unknown[] = [];
comp.on("patch", (e) => patches.push(e));
comp.setElementTiming({});
expect(patches).toHaveLength(0);
});
it("respects data-duration vs data-end preference on write", async () => {
const comp = await openComposition(DURATION_AUTHORED_HTML);
// Before: hf-title has data-duration=3, data-start=0
comp.setElementTiming({ "hf-title": { duration: 5 } });
const timings = comp.getElementTimings();
// Should read back the new duration
expect(timings["hf-title"]).toMatchObject({ exitAt: 5 });
});
it("can be undone as a single step", async () => {
const comp = await openComposition(DURATION_AUTHORED_HTML);
const before = comp.getElementTimings()["hf-title"];
comp.setElementTiming({ "hf-title": { start: 2 } });
comp.undo();
const after = comp.getElementTimings()["hf-title"];
expect(after?.enterAt).toBe(before?.enterAt);
});
it("setElementTiming inverse restores original timing", async () => {
const comp = await openComposition(DURATION_AUTHORED_HTML);
const originalTimings = comp.getElementTimings();
comp.setElementTiming({
"hf-title": { start: 10, duration: 1 },
"hf-sub": { start: 12, duration: 1 },
});
comp.undo();
const restored = comp.getElementTimings();
expect(restored["hf-title"]).toEqual(originalTimings["hf-title"]);
expect(restored["hf-sub"]).toEqual(originalTimings["hf-sub"]);
});
});
// ─── setHold — typed wrapper ──────────────────────────────────────────────────
describe("setHold — typed method", () => {
it("dispatches the setHold op (regression: existing op unchanged)", async () => {
const comp = await openComposition(DURATION_AUTHORED_HTML);
const patches: unknown[] = [];
comp.on("patch", (e) => patches.push(e));
comp.setHold("hf-title", { start: 0.5, end: 2.5, fill: "freeze" });
// Should emit a patch
expect(patches).toHaveLength(1);
});
it("setHold writes data-hold-start / data-hold-end / data-hold-fill attrs", async () => {
const comp = await openComposition(DURATION_AUTHORED_HTML);
comp.setHold("hf-title", { start: 1.0, end: 2.0, fill: "loop" });
// Verify via serialize (attrs are in the HTML output)
const html = comp.serialize();
expect(html).toContain('data-hold-start="1"');
expect(html).toContain('data-hold-end="2"');
expect(html).toContain('data-hold-fill="loop"');
});
it("setHold typed method equals dispatch({type:setHold})", async () => {
// Run typed method path
const comp1 = await openComposition(DURATION_AUTHORED_HTML);
comp1.setHold("hf-title", { start: 0.5, end: 2.5, fill: "freeze" });
const html1 = comp1.serialize();
// Run raw dispatch path
const comp2 = await openComposition(DURATION_AUTHORED_HTML);
comp2.dispatch({
type: "setHold",
target: "hf-title",
hold: { start: 0.5, end: 2.5, fill: "freeze" },
});
const html2 = comp2.serialize();
expect(html1).toBe(html2);
});
});
+910
View File
@@ -0,0 +1,910 @@
/**
* Phase 3a — real editing session.
*
* CompositionImpl: live linkedom document, real dispatch, RFC 6902 patch emission,
* override-set accumulation, batch, can(), serialize(), applyPatches().
*
* openComposition() wires history + persist queue for standalone (T1/T2) mode.
* T3 (embedded) callers supply overrides; SDK emits patches only — host owns state.
*/
import type {
CanResult,
Composition,
EditOp,
ElementSnapshot,
ElementTimingSnapshot,
FindQuery,
FontValue,
GsapTweenSpec,
ElasticHold,
KeyframeSpec,
HfId,
ImageValue,
JsonPatchOp,
OverrideSet,
PatchEvent,
PersistErrorEvent,
SelectionProxy,
ElementHandle,
VariableUsageReport,
} from "./types.js";
import { ORIGIN_APPLY_PATCHES, ORIGIN_LOCAL } from "./types.js";
import { buildRoots, flatElements, parsedAnimationIds } from "./document.js";
import type { PersistAdapter, PreviewAdapter } from "./adapters/types.js";
import { parseMutable } from "./engine/model.js";
import type { ParsedDocument } from "./engine/model.js";
import { applyOp, validateOp, type MutationResult } from "./engine/mutate.js";
import { getGsapScripts, resolveScoped, declarationElement } from "./engine/model.js";
import { extractGsapLabels } from "@hyperframes/core/gsap-parser-acorn";
import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler/html-document";
import { parseStartExpression } from "@hyperframes/core/runtime/start-expression";
import {
readDeclaredDefaults,
validateVariables,
scanVariableUsage,
} from "@hyperframes/core/variables";
import type { CompositionVariable, VariableValidationIssue } from "@hyperframes/core/variables";
import { readVariableDeclarations } from "./engine/variableModel.js";
import { serializeDocument } from "./engine/serialize.js";
import { applyPatchesToDocument, applyOverrideSet } from "./engine/apply-patches.js";
import { buildPatchEvent, pathToKey } from "./engine/patches.js";
import { createHistory } from "./history.js";
import type { HistoryModule } from "./history.js";
import { createPersistQueue } from "./persist-queue.js";
import type { PersistQueueModule } from "./persist-queue.js";
export interface OpenCompositionOptions {
persist?: PersistAdapter;
/** Adapter path the persist queue writes to. Default: "composition.html". Immutable for the session lifetime. */
persistPath?: string;
preview?: PreviewAdapter;
/** T3 embedded mode: override-set applied on top of the base template. */
overrides?: OverrideSet;
/** Origins whose mutations enter the undo stack. Default: all non-applyPatches. */
trackedOrigins?: unknown[];
/** Auto-coalesce window for history entries (ms). Default: 300. */
coalesceMs?: number;
/**
* Pass `false` to skip attaching the history module (undo/redo).
* Default: history is attached in standalone (non-embedded) mode.
* Use when the host owns the undo stack and SDK undo is dead weight.
*/
history?: false;
}
// ─── Implementation ───────────────────────────────────────────────────────────
/** Escape a string for literal use inside a RegExp. */
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
class CompositionImpl implements Composition {
private readonly parsed: ParsedDocument;
private readonly persist: PersistAdapter | undefined;
readonly preview: PreviewAdapter | undefined;
/** Accumulated override-set — T3 embedded mode fold contract. */
private overrides: OverrideSet;
/** Lazily-built element snapshot, invalidated on every mutation. */
private elementsCache: ElementSnapshot[] | null = null;
/** Lazily-built root snapshot (getRootElements), invalidated alongside elementsCache. */
private rootsCache: ElementSnapshot[] | null = null;
private currentSelection: string[] = [];
private changeHandlers: Array<() => void> = [];
private selectionHandlers: Array<(ids: string[]) => void> = [];
private patchHandlers: Array<(e: PatchEvent) => void> = [];
private errorHandlers: Array<(e: PersistErrorEvent) => void> = [];
private previewSelectionUnsubscribe: (() => void) | null = null;
/** Attached by openComposition() for standalone mode. */
private historyModule: HistoryModule | null = null;
private persistQueueModule: PersistQueueModule | null = null;
/** Batching state: accumulates patches from multiple dispatches. */
private batchDepth = 0;
private batchForward: JsonPatchOp[] = [];
private batchInverse: JsonPatchOp[] = [];
private batchOpTypes: string[] = [];
private batchOrigin: unknown = ORIGIN_LOCAL;
/** Override-set state at outermost batch entry — restored if the batch throws. */
private batchOverridesSnapshot: OverrideSet = {};
constructor(parsed: ParsedDocument, opts: OpenCompositionOptions) {
this.parsed = parsed;
this.persist = opts.persist;
this.preview = opts.preview;
this.overrides = { ...(opts.overrides ?? {}) };
this.previewSelectionUnsubscribe =
this.preview?.on("selection", (ids) => this.updateSelection(ids)) ?? null;
}
attachHistory(module: HistoryModule): void {
this.historyModule = module;
}
attachPersistQueue(module: PersistQueueModule): void {
this.persistQueueModule = module;
}
_fireError(e: PersistErrorEvent): void {
this.errorHandlers.forEach((h) => h(e));
}
// ── Typed methods (F10 layer 1) ─────────────────────────────────────────────
setStyle(id: HfId, styles: Record<string, string | null>): void {
this.dispatch({ type: "setStyle", target: id, styles });
}
setText(id: HfId, value: string): void {
this.dispatch({ type: "setText", target: id, value });
}
setAttribute(id: HfId, name: string, value: string | null): void {
this.dispatch({ type: "setAttribute", target: id, name, value });
}
setTiming(id: HfId, timing: { start?: number; duration?: number; trackIndex?: number }): void {
this.dispatch({ type: "setTiming", target: id, ...timing });
}
removeElement(id: HfId): void {
this.dispatch({ type: "removeElement", target: id });
}
addElement(parent: HfId | null, index: number, html: string): HfId {
const result = this._dispatch({ type: "addElement", parent, index, html }, ORIGIN_LOCAL);
return result.meta?.newId ?? "";
}
setVariableValue(id: string, value: string | number | boolean | FontValue | ImageValue): void {
this.dispatch({ type: "setVariableValue", id, value });
}
// ── #2098 CRUD conveniences — thin aliases over the canonical surface below.
// They delegate so the per-file declaration-element scope (template/fragment
// sub-comps included) is resolved in exactly one place.
getVariableValue(id: string): string | number | boolean | FontValue | ImageValue | undefined {
return this.getVariableValues()[id] as
| string
| number
| boolean
| FontValue
| ImageValue
| undefined;
}
listVariables(): CompositionVariable[] {
return this.getVariableDeclarations();
}
removeVariable(id: string): void {
this.dispatch({ type: "removeVariable", id });
}
// ── Canonical declaration edit ops (this stack) ──
// declareVariable unified onto the {declaration} op payload (#2098 used
// {decl}); the method signature is identical, so #2098 callers are unaffected.
declareVariable(declaration: CompositionVariable): void {
this.dispatch({ type: "declareVariable", declaration });
}
updateVariableDeclaration(id: string, declaration: CompositionVariable): void {
this.dispatch({ type: "updateVariableDeclaration", id, declaration });
}
removeVariableDeclaration(id: string): void {
this.dispatch({ type: "removeVariableDeclaration", id });
}
getVariableDeclarations(): CompositionVariable[] {
return readVariableDeclarations(declarationElement(this.parsed.document, this.parsed.wrapped));
}
getVariableValues(overrides?: Record<string, unknown>): Record<string, unknown> {
// THIS composition's own declared defaults (loose extraction: any entry with
// a string id + a `default` key, even ones the strict declaration parser
// drops) spread under the overrides. Scope note: this reads the composition's
// single declaration element only — NOT a union of every `[data-composition-
// variables]` in the document. The runtime's getVariables()
// (core/runtime/getVariables.ts) additionally walks inlined sub-composition
// declarers because it operates on the bundled multi-composition document;
// the SDK models one composition file, so per-file scope is intended.
const defaults = readDeclaredDefaults(
declarationElement(this.parsed.document, this.parsed.wrapped),
);
return { ...defaults, ...(overrides ?? {}) };
}
validateVariableValues(values: Record<string, unknown>): VariableValidationIssue[] {
return validateVariables(values, this.getVariableDeclarations());
}
/**
* Script scans are content-keyed (same rationale as _gsapLabelCache): the
* panel recomputes usage on every preview reload, and unchanged script text
* is the common case — never pay a second acorn parse for identical input.
*/
private _variableUsageScanCache = new Map<string, ReturnType<typeof scanVariableUsage>>();
// Scan/merge dispatcher — same complexity class as the suppressed
// variableUsage.ts classifiers it drives.
// fallow-ignore-next-line complexity
getVariableUsage(): VariableUsageReport {
const usedIds: string[] = [];
const seen = new Set<string>();
let scanIncomplete = false;
const freshCache = new Map<string, ReturnType<typeof scanVariableUsage>>();
// Inline scripts only — external src scripts aren't part of the document model.
for (const script of Array.from(this.parsed.document.querySelectorAll("script"))) {
if (script.getAttribute("src")) continue;
const text = script.textContent ?? "";
// Direct global reads (window.__hfVariables / __hfVariablesByComp) are
// invisible to the getVariables() scanner — the report must degrade to
// a lower bound instead of confidently claiming declarations unused.
if (text.includes("__hfVariables")) scanIncomplete = true;
if (!text.includes("getVariables")) continue; // cheap pre-filter before an acorn parse
const scan = this._variableUsageScanCache.get(text) ?? scanVariableUsage(text);
freshCache.set(text, scan);
scanIncomplete = scanIncomplete || scan.scanIncomplete;
for (const id of scan.usedIds) {
if (!seen.has(id)) {
seen.add(id);
usedIds.push(id);
}
}
}
// Declarative bindings (data-var-src / data-var-text) are direct reads.
for (const el of Array.from(
this.parsed.document.querySelectorAll("[data-var-src], [data-var-text]"),
)) {
for (const attr of ["data-var-src", "data-var-text"]) {
const id = el.getAttribute(attr)?.trim();
if (id && !seen.has(id)) {
seen.add(id);
usedIds.push(id);
}
}
}
this._variableUsageScanCache = freshCache;
const declaredIds = this.getVariableDeclarations().map((d) => d.id);
// The CSS compat channel counts as usage: a variable consumed only via
// var(--id) in stylesheets or inline styles must not be badged unused
// (removing it also removes the --{id} root prop and breaks the binding).
const cssText = this._collectCssText();
// Match var(--id) only at a custom-property-name boundary: the id must be
// followed by whitespace, a comma (fallback), or the closing paren — so id
// "foo" is NOT counted as used by an unrelated var(--foo-header). Ids are
// regex-escaped because a value read from disk may predate can()'s
// /^[A-Za-z_][A-Za-z0-9_-]*$/ enforcement and carry metacharacters.
const cssUsed = (id: string) =>
new RegExp(`var\\(\\s*--${escapeRegExp(id)}[\\s,)]`).test(cssText);
const declaredSet = new Set(declaredIds);
return {
usedIds,
unusedDeclarations: declaredIds.filter((id) => !seen.has(id) && !cssUsed(id)),
undeclaredReads: usedIds.filter((id) => !declaredSet.has(id)),
scanIncomplete,
};
}
/** All stylesheet + inline-style text, for var(--id) consumption checks. */
private _collectCssText(): string {
const cssParts: string[] = [];
for (const styleEl of Array.from(this.parsed.document.querySelectorAll("style"))) {
cssParts.push(styleEl.textContent ?? "");
}
for (const el of Array.from(this.parsed.document.querySelectorAll("[style]"))) {
cssParts.push(el.getAttribute("style") ?? "");
}
return cssParts.join("\n");
}
setPreviewVariables(values: Record<string, unknown> | null): boolean {
if (!this.preview?.setPreviewVariables) return false;
this.preview.setPreviewVariables(values);
return true;
}
// ── WS-C: timing accessors + typed setHold ───────────────────────────────────
/**
* Cache of parsed GSAP labels keyed by the ordered list of EXACT script texts.
* extractGsapLabels does a full acorn parse; caching avoids re-parsing on repeated
* getElementTimings reads when the scripts are unchanged.
*/
private _gsapLabelCache: {
scripts: string[];
labels: ReturnType<typeof extractGsapLabels>;
} | null = null;
// fallow-ignore-next-line complexity
getElementTimings(): Record<HfId, ElementTimingSnapshot> {
const scripts = getGsapScripts(this.parsed.document);
// Extract all addLabel("name", position) calls from every GSAP script.
let allLabels: ReturnType<typeof extractGsapLabels>;
const cachedScripts = this._gsapLabelCache?.scripts;
const cacheMatches =
cachedScripts?.length === scripts.length &&
cachedScripts.every((script, index) => script === scripts[index]);
if (cacheMatches) {
allLabels = this._gsapLabelCache?.labels ?? [];
} else {
allLabels = scripts.flatMap((script) => extractGsapLabels(script));
this._gsapLabelCache = scripts.length ? { scripts: [...scripts], labels: allLabels } : null;
}
// Resolve a `data-start` that's a relative-timing REFERENCE ("intro", "intro + 2" —
// parseStartExpression's grammar) into an absolute second, recursively against the
// referenced element's own resolved start + duration. A plain numeric data-start keeps
// the old parseFloat path unchanged — this only touches the case that used to silently
// resolve to 0 (parseFloat("intro + 2") is NaN). Node-safe static counterpart of the
// runtime's own resolver (runtime/startResolver.ts): no live GSAP timeline to fall back
// on, so an unauthored sub-composition duration still resolves to 0, same as before.
//
// refId is always a BARE id (the reference grammar has no scope syntax), resolved via
// resolveScoped's bare-id rule: prefer the canonical top-level match, else document
// order. An element inside a sub-composition referencing a bare id that also exists at
// the top level resolves to the TOP-LEVEL one, not a same-scope sibling — this matches
// the runtime's own resolver (also a global, not scope-aware, lookup), so the two stay
// consistent, but it means a bare-id collision across scopes is a real footgun for
// authored content.
const startCache = new Map<Element, number>();
const visiting = new Set<Element>();
// Split out of resolveStart so its own branching stays low — this is the ONE
// path that recurses + calls resolveDuration, kept here so that's visible at a
// glance rather than buried inside resolveStart's try block.
const resolveReferenceStart = (refId: string, offset: number): number => {
const target = resolveScoped(this.parsed.document, refId);
if (!target) return 0;
return Math.max(0, resolveStart(target) + (resolveDuration(target) ?? 0) + offset);
};
const resolveStart = (el: Element): number => {
const cached = startCache.get(el);
if (cached !== undefined) return cached;
if (visiting.has(el)) return 0; // reference cycle — fail safe, don't loop
visiting.add(el);
let resolved: number;
try {
const startStr = el.getAttribute("data-start");
const expr = parseStartExpression(startStr);
if (expr?.kind === "reference") {
resolved = resolveReferenceStart(expr.refId, expr.offset);
} else if (expr?.kind === "absolute") {
resolved = expr.value;
} else {
resolved = startStr !== null ? parseFloat(startStr) : 0;
}
} finally {
visiting.delete(el);
}
const finite = Number.isFinite(resolved) ? resolved : 0;
startCache.set(el, finite);
return finite;
};
// Same preference as handleSetTiming: prefer data-duration, fall back to end - start.
const resolveDuration = (el: Element): number | null => {
const durationStr = el.getAttribute("data-duration");
const durationAttr = durationStr !== null ? parseFloat(durationStr) : null;
if (durationAttr !== null && Number.isFinite(durationAttr)) return durationAttr;
const endStr = el.getAttribute("data-end");
const endAttr = endStr !== null ? parseFloat(endStr) : null;
if (endAttr !== null && Number.isFinite(endAttr)) return endAttr - resolveStart(el);
return null;
};
const result: Record<HfId, ElementTimingSnapshot> = {};
const elements = this.getElements();
for (const el of elements) {
const domEl = resolveScoped(this.parsed.document, el.scopedId);
if (!domEl) continue;
const enterAt = resolveStart(domEl);
const duration = resolveDuration(domEl);
if (duration === null) continue; // no timing info — skip non-timed elements
const exitAt = enterAt + duration;
// Labels whose position falls within [enterAt, exitAt] (end-inclusive: a
// label exactly at exitAt is treated as within the element's window).
const labels = allLabels
.filter(({ position }) => position >= enterAt && position <= exitAt)
.map(({ name }) => name);
result[el.scopedId] = { enterAt, exitAt, labels };
}
return result;
}
setElementTiming(
map: Record<HfId, { start?: number; duration?: number; trackIndex?: number }>,
): void {
const entries = Object.entries(map);
if (entries.length === 0) return;
this.batch(() => {
for (const [id, timing] of entries) {
this.dispatch({ type: "setTiming", target: id, ...timing });
}
});
}
setHold(id: HfId, hold: ElasticHold): void {
this.dispatch({ type: "setHold", target: id, hold });
}
addGsapTween(target: HfId, tween: GsapTweenSpec): string {
const result = this._dispatch({ type: "addGsapTween", target, tween }, ORIGIN_LOCAL);
return result.meta?.animationId ?? "";
}
setGsapTween(animationId: string, properties: Partial<GsapTweenSpec>): void {
this.dispatch({ type: "setGsapTween", animationId, properties });
}
removeGsapTween(animationId: string): void {
this.dispatch({ type: "removeGsapTween", animationId });
}
addWithKeyframes(
targetSelector: string,
position: number,
duration: number,
keyframes: KeyframeSpec[],
ease?: string,
): string {
const result = this._dispatch(
{ type: "addWithKeyframes", targetSelector, position, duration, keyframes, ease },
ORIGIN_LOCAL,
);
return result.meta?.animationId ?? "";
}
replaceWithKeyframes(
animationId: string,
targetSelector: string,
position: number,
duration: number,
keyframes: KeyframeSpec[],
ease?: string,
): string {
const result = this._dispatch(
{
type: "replaceWithKeyframes",
animationId,
targetSelector,
position,
duration,
keyframes,
ease,
},
ORIGIN_LOCAL,
);
// Position-derived IDs renumber after the remove — this is the NEW id, which
// may differ from the input animationId.
return result.meta?.animationId ?? "";
}
undo(): void {
this.historyModule?.undo();
}
redo(): void {
this.historyModule?.redo();
}
canUndo(): boolean {
return this.historyModule?.canUndo() ?? false;
}
canRedo(): boolean {
return this.historyModule?.canRedo() ?? false;
}
// ── Query API (F1) ───────────────────────────────────────────────────────────
getElements(): ElementSnapshot[] {
// Walk the live linkedom DOM directly — no serialize/re-parse round trip.
this.elementsCache ??= flatElements(buildRoots(this.parsed.document));
return [...this.elementsCache];
}
/**
* Top-level elements only (each still carrying its full descendant subtree via
* `.children`) — unlike `getElements()`, no element appears twice. Consumers building a
* tree view (a layer panel) want this, not `getElements()`: that method's flat list
* includes every descendant a second time as its own top-level entry, since each
* snapshot in it still carries its children. `buildRoots` already computes true roots
* internally for `getElements()` to flatten — this just returns them unflattened.
* Cached like elementsCache — a layer panel calling this every render tick shouldn't
* repay the DOM walk each time.
*/
getRootElements(): ElementSnapshot[] {
this.rootsCache ??= buildRoots(this.parsed.document);
return [...this.rootsCache];
}
getElement(id: HfId): ElementSnapshot | null {
// Accept both bare ids (top-level) and scoped ids (sub-composition elements).
// Match by scopedId first (canonical); bare-id fallback keeps top-level compat
// for callers that don't yet use scoped ids.
return (
this.getElements().find((el) => el.scopedId === id) ??
this.getElements().find((el) => el.id === id && el.scopedId === el.id) ??
null
);
}
find(query: FindQuery): string[] {
return (
this.getElements()
// fallow-ignore-next-line complexity
.filter((el) => {
if (query.tag && el.tag !== query.tag) return false;
if (query.text && !el.text?.includes(query.text)) return false;
if (query.name && el.attributes["data-name"] !== query.name) return false;
if (query.track !== undefined && el.trackIndex !== query.track) return false;
if (query.composition && !el.scopedId.startsWith(`${query.composition}/`)) return false;
return true;
})
.map((el) => el.scopedId)
);
}
getAllAnimationIds(): Set<string> {
const ids = new Set<string>();
for (const script of getGsapScripts(this.parsed.document)) {
for (const id of parsedAnimationIds(script)) ids.add(id);
}
return ids;
}
// ── Selection API ────────────────────────────────────────────────────────────
selection(): SelectionProxy {
const ids = [...this.currentSelection];
return {
ids,
setStyle: (styles) => this.dispatch({ type: "setStyle", target: ids, styles }),
setText: (value) => this.dispatch({ type: "setText", target: ids, value }),
setAttribute: (name, value) =>
this.dispatch({ type: "setAttribute", target: ids, name, value }),
setTiming: (timing) => this.dispatch({ type: "setTiming", target: ids, ...timing }),
removeElement: () => this.dispatch({ type: "removeElement", target: ids }),
};
}
element(id: HfId): ElementHandle {
return {
id,
setStyle: (styles) => this.dispatch({ type: "setStyle", target: id, styles }),
setText: (value) => this.dispatch({ type: "setText", target: id, value }),
setAttribute: (name, value) =>
this.dispatch({ type: "setAttribute", target: id, name, value }),
setTiming: (timing) => this.dispatch({ type: "setTiming", target: id, ...timing }),
removeElement: () => this.dispatch({ type: "removeElement", target: id }),
};
}
getSelection(): string[] {
return [...this.currentSelection];
}
setSelection(ids: string[]): void {
const deduped = Array.from(new Set(ids));
if (
deduped.length === this.currentSelection.length &&
deduped.every((id, i) => id === this.currentSelection[i])
) {
return;
}
this.updateSelection(deduped);
}
private updateSelection(ids: readonly string[]): void {
this.currentSelection = [...ids];
for (const handler of this.selectionHandlers) {
handler([...this.currentSelection]);
}
}
// ── Dispatch / batch ─────────────────────────────────────────────────────────
// fallow-ignore-next-line complexity
private _dispatch(op: EditOp, origin: unknown): MutationResult {
const result = applyOp(this.parsed, op);
const { forward, inverse } = result;
if (forward.length === 0 && inverse.length === 0) {
if (this.batchDepth === 0) this.changeHandlers.forEach((h) => h());
return result;
}
this.elementsCache = null;
this.rootsCache = null;
// Update override-set from forward patches
for (const p of forward) {
const key = pathToKey(p.path);
if (key !== null) {
this.overrides[key] =
p.op === "remove"
? null
: (p.value as string | number | boolean | Record<string, unknown> | null);
}
}
// Purge orphan property keys for removed elements so the override-set stays
// compact and a future T3 session doesn't replay stale properties onto a
// non-existent element. Override-set keys use decoded scoped ids ("hf-host/hf-leaf")
// while path segments use RFC 6902 encoding ("hf-host~1hf-leaf") — decode before compare.
for (const p of forward) {
const elemMatch = /^\/elements\/([^/]+)$/.exec(p.path);
if (p.op === "remove" && elemMatch) {
// Decode RFC 6902 escaping: ~1 → /, ~0 → ~
const id = elemMatch[1]!.replace(/~1/g, "/").replace(/~0/g, "~");
for (const key of Object.keys(this.overrides)) {
// Purge property sub-keys (e.g. "hf-x.style.color") but preserve
// the removal marker itself (key === id, set to null in the loop above).
if (key.startsWith(`${id}.`) || key.startsWith(`${id}/`)) {
delete this.overrides[key];
}
}
}
}
if (this.batchDepth > 0) {
this.batchForward.push(...forward);
this.batchInverse.push(...inverse);
if (!this.batchOpTypes.includes(op.type)) this.batchOpTypes.push(op.type);
} else {
// Reverse the inverse list (parity with batch() below): an op that emits
// multiple patches whose undo order matters — same path (reorderElements
// with a duplicate target), an aliased multi-target, or a nested
// parent+child removeElement — must undo in reverse application order, or
// undo lands on an intermediate value / drops a subtree. Harmless for the
// common single-patch / independent-path case.
const event = buildPatchEvent(forward, [...inverse].reverse(), origin, [op.type]);
this.patchHandlers.forEach((h) => h(event));
this.changeHandlers.forEach((h) => h());
}
return result;
}
dispatch(op: EditOp, opts?: { origin?: unknown }): void {
this._dispatch(op, opts?.origin ?? ORIGIN_LOCAL);
}
/**
* Coalesce multiple dispatches into one undo entry / one patch event.
*
* Transactional: if the callback throws, all DOM mutations applied so far
* are reverted (accumulated inverse patches replayed in reverse) and the
* override-set is restored — the model is exactly as it was at batch entry.
*
* Note: a batch that produces no effective mutations still fires 'change'
* handlers (parity with no-op dispatch) — subscribers must not assume
* silence when wrapping speculative operations.
*/
// fallow-ignore-next-line complexity
batch(fn: () => void, opts?: { origin?: unknown }): void {
const origin = opts?.origin ?? ORIGIN_LOCAL;
this.batchDepth++;
if (this.batchDepth === 1) {
this.batchOrigin = origin; // only set on outermost entry
this.batchOverridesSnapshot = { ...this.overrides };
}
let threw = false;
try {
fn();
} catch (err) {
threw = true;
throw err;
} finally {
this.batchDepth--;
if (this.batchDepth === 0) {
if (!threw && this.batchForward.length > 0) {
const event = buildPatchEvent(
this.batchForward,
[...this.batchInverse].reverse(),
this.batchOrigin,
this.batchOpTypes,
);
// Fire handlers before resetting batch state so that if a handler
// throws the patch data (batchForward/batchInverse) is still intact
// for callers that inspect it on error. The event was already built
// from a snapshot so handler re-entrancy does not corrupt the event.
this.patchHandlers.forEach((h) => h(event));
this.changeHandlers.forEach((h) => h());
this.resetBatchState();
} else {
if (threw && this.batchInverse.length > 0) {
// Roll back: the dispatches inside the batch already mutated the
// DOM. Without this, a throwing batch would leave the model in a
// partial state with no patch trail to undo it.
applyPatchesToDocument(this.parsed, [...this.batchInverse].reverse());
this.overrides = { ...this.batchOverridesSnapshot };
this.elementsCache = null;
this.rootsCache = null;
}
this.resetBatchState();
// Empty no-op batch: fire changeHandlers (parity with dispatch)
if (!threw) this.changeHandlers.forEach((h) => h());
}
}
}
}
private resetBatchState(): void {
this.batchForward = [];
this.batchInverse = [];
this.batchOpTypes = [];
this.batchOrigin = ORIGIN_LOCAL;
this.batchOverridesSnapshot = {};
}
can(op: EditOp): CanResult {
return validateOp(this.parsed, op);
}
// ── Events ───────────────────────────────────────────────────────────────────
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;
// fallow-ignore-next-line complexity
on(event: string, handler: unknown): () => void {
const h = handler as (...args: unknown[]) => void;
if (event === "change") {
this.changeHandlers.push(h as () => void);
return () => {
this.changeHandlers = this.changeHandlers.filter((x) => x !== h);
};
}
if (event === "selectionchange") {
this.selectionHandlers.push(h as (ids: string[]) => void);
return () => {
this.selectionHandlers = this.selectionHandlers.filter((x) => x !== h);
};
}
if (event === "patch") {
this.patchHandlers.push(h as (e: PatchEvent) => void);
return () => {
this.patchHandlers = this.patchHandlers.filter((x) => x !== h);
};
}
if (event === "persist:error") {
const typedH = h as (e: PersistErrorEvent) => void;
this.errorHandlers.push(typedH);
const offPersist = this.persist?.on("persist:error", typedH);
return () => {
this.errorHandlers = this.errorHandlers.filter((x) => x !== typedH);
offPersist?.();
};
}
return () => {};
}
// ── Serialization ────────────────────────────────────────────────────────────
serialize(opts?: { stripRuntime?: boolean }): string {
const html = serializeDocument(this.parsed);
// Newer agent-generated compositions embed hyperframe.runtime.iife.js in their own
// HTML. Any host driving its own clock (not just an editing iframe — anything that
// owns seeking/playback itself) must not let that runtime self-init: it races the
// host's first seek and resets the timeline to t=0. Opt-in (default false) since a
// host playing the composition normally wants the runtime.
return opts?.stripRuntime ? stripEmbeddedRuntimeScripts(html) : html;
}
// ── T3 embedded-mode extras ──────────────────────────────────────────────────
getOverrides(): OverrideSet {
return { ...this.overrides };
}
// fallow-ignore-next-line complexity
applyPatches(patches: readonly JsonPatchOp[], opts?: { origin?: unknown }): void {
const origin = opts?.origin ?? ORIGIN_APPLY_PATCHES;
// The emitted PatchEvent carries an EMPTY inversePatches array — hosts
// maintaining an external inverse log must compute inverses from their own
// state; applyPatches events never enter history (origin-guarded).
// Emit a patch event so subscribers stay in sync.
applyPatchesToDocument(this.parsed, patches);
this.elementsCache = null;
this.rootsCache = null;
// Update override-set
for (const p of patches) {
const key = pathToKey(p.path);
if (key !== null) {
this.overrides[key] =
p.op === "remove"
? null
: (p.value as string | number | boolean | Record<string, unknown> | null);
}
}
const opTypes = ["applyPatches"];
const event = buildPatchEvent(patches, [], origin, opTypes);
this.patchHandlers.forEach((h) => h(event));
this.changeHandlers.forEach((h) => h());
}
// ── Lifecycle ────────────────────────────────────────────────────────────────
async flush(): Promise<void> {
await this.persistQueueModule?.flush();
}
dispose(): void {
this.previewSelectionUnsubscribe?.();
this.previewSelectionUnsubscribe = null;
this.persistQueueModule?.dispose();
this.historyModule?.dispose();
this.changeHandlers = [];
this.selectionHandlers = [];
this.patchHandlers = [];
this.errorHandlers = [];
}
}
// ─── Public factory ───────────────────────────────────────────────────────────
/**
* Open a composition for editing.
*
* Standalone (T1/T2): supply persist adapter — SDK owns history + auto-save.
* Embedded (T3): supply overrides — SDK emits patches; host owns history + persistence.
* Headless (agents): omit both — SDK is a stateless transform + serializer.
*/
// fallow-ignore-next-line complexity
export async function openComposition(
html: string,
opts?: OpenCompositionOptions,
): Promise<Composition> {
// Single parse: parseMutable stamps hf-ids + builds the live linkedom DOM;
// the query API derives element snapshots from it lazily.
const parsed = parseMutable(html);
// T3 embedded: replay the stored override-set onto the base in one pass,
// so the session exposes the user's exact edited state — not the template.
if (opts?.overrides) applyOverrideSet(parsed, opts.overrides);
const session = new CompositionImpl(parsed, opts ?? {});
const isEmbedded = opts?.overrides !== undefined;
if (!isEmbedded) {
// history:false opts out of the SDK undo stack ONLY. Persist (auto-save) is
// independent — gating it on the history flag too would silently drop every
// disk write for a caller that just wanted to disable undo (data loss).
if (opts?.history !== false) {
const history = createHistory(session, {
coalesceMs: opts?.coalesceMs ?? 300,
trackedOrigins: opts?.trackedOrigins,
});
session.attachHistory(history);
}
if (opts?.persist) {
const pq = createPersistQueue(session, opts.persist, {
path: opts.persistPath,
onError: (e) => session._fireError(e),
});
session.attachPersistQueue(pq);
}
}
return session;
}
@@ -0,0 +1,245 @@
/**
* Variable declaration edit ops: declareVariable / updateVariableDeclaration /
* removeVariableDeclaration. Covers dispatch semantics, can() validation,
* CSS compat sync, patch grammar, and undo round-trips.
*/
import { describe, it, expect } from "vitest";
import { openComposition } from "./session.js";
import { variableDeclPath, pathToKey, keyToPath } from "./engine/patches.js";
import type { CompositionVariable } from "@hyperframes/core/variables";
const TITLE_DECL: CompositionVariable = {
id: "title",
type: "string",
label: "Title",
default: "Hello",
};
const COUNT_DECL: CompositionVariable = {
id: "count",
type: "number",
label: "Count",
default: 3,
min: 0,
max: 10,
};
const BARE_HTML = `
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px" data-duration="5">
<h1 data-hf-id="hf-title" data-start="0" data-end="3">Hello</h1>
</div>
`.trim();
const DECLARED_HTML = `<!DOCTYPE html>
<html data-composition-variables='${JSON.stringify([TITLE_DECL, COUNT_DECL])}'>
<body>${BARE_HTML}</body>
</html>`;
const UNDECLARED_HTML = `<!DOCTYPE html>
<html>
<body>${BARE_HTML}</body>
</html>`;
describe("declareVariable", () => {
it("creates the attribute on a composition with no declarations", async () => {
const comp = await openComposition(UNDECLARED_HTML);
expect(comp.getVariableDeclarations()).toEqual([]);
comp.declareVariable(TITLE_DECL);
expect(comp.getVariableDeclarations()).toEqual([TITLE_DECL]);
expect(comp.serialize()).toContain("data-composition-variables");
});
it("appends to existing declarations and survives serialize round-trip", async () => {
const comp = await openComposition(DECLARED_HTML);
comp.declareVariable({ id: "dark", type: "boolean", label: "Dark", default: false });
expect(comp.getVariableDeclarations().map((d) => d.id)).toEqual(["title", "count", "dark"]);
const reopened = await openComposition(comp.serialize());
expect(reopened.getVariableDeclarations().map((d) => d.id)).toEqual(["title", "count", "dark"]);
});
it("no-ops on duplicate ids and can() reports E_DUPLICATE_VARIABLE", async () => {
const comp = await openComposition(DECLARED_HTML);
const dup = comp.can({ type: "declareVariable", declaration: TITLE_DECL });
expect(dup).toMatchObject({ ok: false, code: "E_DUPLICATE_VARIABLE" });
comp.declareVariable({ ...TITLE_DECL, default: "clobbered" });
expect(comp.getVariableDeclarations().find((d) => d.id === "title")?.default).toBe("Hello");
});
it("rejects structurally invalid declarations", async () => {
const comp = await openComposition(UNDECLARED_HTML);
const invalid = {
id: "broken",
type: "number",
label: "Broken",
default: "not-a-number",
} as unknown as CompositionVariable;
expect(comp.can({ type: "declareVariable", declaration: invalid })).toMatchObject({
ok: false,
code: "E_INVALID_ARGS",
});
comp.declareVariable(invalid);
expect(comp.getVariableDeclarations()).toEqual([]);
});
it("rejects ids that are not valid CSS/attribute identifiers", async () => {
const comp = await openComposition(UNDECLARED_HTML);
for (const id of ["", " ", "has space", "1leading", "dot.id", "sla/sh", 'quo"te']) {
expect(
comp.can({ type: "declareVariable", declaration: { ...TITLE_DECL, id } }),
).toMatchObject({ ok: false, code: "E_INVALID_VARIABLE_ID" });
comp.declareVariable({ ...TITLE_DECL, id });
}
expect(comp.getVariableDeclarations()).toEqual([]);
// Sanity: a valid id with the allowed charset still declares.
comp.declareVariable({ ...TITLE_DECL, id: "brand_color-2" });
expect(comp.getVariableDeclarations().map((d) => d.id)).toEqual(["brand_color-2"]);
});
it("undo removes the declaration again", async () => {
const comp = await openComposition(UNDECLARED_HTML);
comp.declareVariable(TITLE_DECL);
comp.undo();
expect(comp.getVariableDeclarations()).toEqual([]);
expect(comp.serialize()).not.toContain("data-composition-variables");
comp.redo();
expect(comp.getVariableDeclarations()).toEqual([TITLE_DECL]);
});
// fallow-ignore-next-line code-duplication
it("supports fragment compositions with a root element (schema on the root div)", async () => {
// A fragment (no <html>) still has a composition root; declarations live on
// that root div, which survives serialize — so template/sub-comp files are
// first-class editable, not refused.
const comp = await openComposition(BARE_HTML);
expect(comp.can({ type: "declareVariable", declaration: TITLE_DECL })).toMatchObject({
ok: true,
});
comp.declareVariable(TITLE_DECL);
expect(comp.getVariableDeclarations()).toEqual([TITLE_DECL]);
expect(comp.serialize()).toContain("data-composition-variables");
});
it("refuses a fragment with no root element (nowhere durable to write)", async () => {
const comp = await openComposition("just text, no element");
expect(comp.can({ type: "declareVariable", declaration: TITLE_DECL })).toMatchObject({
ok: false,
code: "E_FRAGMENT_COMPOSITION",
});
comp.declareVariable(TITLE_DECL);
expect(comp.getVariableDeclarations()).toEqual([]);
expect(comp.serialize()).not.toContain("data-composition-variables");
});
});
describe("updateVariableDeclaration", () => {
it("replaces the declaration wholesale", async () => {
const comp = await openComposition(DECLARED_HTML);
comp.updateVariableDeclaration("count", { ...COUNT_DECL, label: "Item count", max: 20 });
const decl = comp.getVariableDeclarations().find((d) => d.id === "count");
expect(decl).toMatchObject({ label: "Item count", max: 20, default: 3 });
});
it("syncs the CSS compat prop when a scalar default changes", async () => {
const comp = await openComposition(DECLARED_HTML);
comp.updateVariableDeclaration("count", { ...COUNT_DECL, default: 7 });
const root = comp.getElements().find((e) => e.id === "hf-stage");
expect(root?.inlineStyles["--count"]).toBe("7");
});
it("keeps CSS untouched when the default is unchanged", async () => {
const comp = await openComposition(DECLARED_HTML);
comp.updateVariableDeclaration("count", { ...COUNT_DECL, label: "Renamed only" });
const root = comp.getElements().find((e) => e.id === "hf-stage");
expect(root?.inlineStyles["--count"]).toBeUndefined();
});
it("validates id immutability and existence via can()", async () => {
const comp = await openComposition(DECLARED_HTML);
expect(
comp.can({ type: "updateVariableDeclaration", id: "count", declaration: TITLE_DECL }),
).toMatchObject({ ok: false, code: "E_INVALID_ARGS" });
expect(
comp.can({
type: "updateVariableDeclaration",
id: "ghost",
declaration: { ...TITLE_DECL, id: "ghost" },
}),
).toMatchObject({ ok: false, code: "E_VARIABLE_NOT_FOUND" });
});
it("undo restores the previous declaration", async () => {
const comp = await openComposition(DECLARED_HTML);
comp.updateVariableDeclaration("title", { ...TITLE_DECL, label: "Headline" });
comp.undo();
expect(comp.getVariableDeclarations().find((d) => d.id === "title")?.label).toBe("Title");
});
});
describe("removeVariableDeclaration", () => {
it("removes the entry and drops the attribute with the last one", async () => {
const comp = await openComposition(DECLARED_HTML);
comp.removeVariableDeclaration("count");
expect(comp.getVariableDeclarations().map((d) => d.id)).toEqual(["title"]);
comp.removeVariableDeclaration("title");
expect(comp.getVariableDeclarations()).toEqual([]);
expect(comp.serialize()).not.toContain("data-composition-variables");
});
it("clears the CSS compat prop and undo restores declaration + CSS", async () => {
const comp = await openComposition(DECLARED_HTML);
comp.setVariableValue("count", 5);
const rootBefore = comp.getElements().find((e) => e.id === "hf-stage");
expect(rootBefore?.inlineStyles["--count"]).toBe("5");
comp.removeVariableDeclaration("count");
const rootAfter = comp.getElements().find((e) => e.id === "hf-stage");
expect(rootAfter?.inlineStyles["--count"]).toBeUndefined();
expect(comp.getVariableDeclarations().map((d) => d.id)).toEqual(["title"]);
comp.undo();
const rootRestored = comp.getElements().find((e) => e.id === "hf-stage");
expect(rootRestored?.inlineStyles["--count"]).toBe("5");
expect(comp.getVariableDeclarations().find((d) => d.id === "count")?.default).toBe(5);
});
it("no-ops on unknown ids and can() reports E_VARIABLE_NOT_FOUND", async () => {
const comp = await openComposition(DECLARED_HTML);
expect(comp.can({ type: "removeVariableDeclaration", id: "ghost" })).toMatchObject({
ok: false,
code: "E_VARIABLE_NOT_FOUND",
});
comp.removeVariableDeclaration("ghost");
expect(comp.getVariableDeclarations().map((d) => d.id)).toEqual(["title", "count"]);
});
});
describe("patch grammar", () => {
it("maps /variableDeclarations/{id} ↔ varDecl.{id} without colliding with /variables/", () => {
const path = variableDeclPath("brand-color");
expect(path).toBe("/variableDeclarations/brand-color");
expect(pathToKey(path)).toBe("varDecl.brand-color");
expect(keyToPath("varDecl.brand-color")).toBe(path);
// The value path family must stay untouched.
expect(pathToKey("/variables/brand-color")).toBe("var.brand-color");
expect(keyToPath("var.brand-color")).toBe("/variables/brand-color");
});
it("emits declaration patches on dispatch", async () => {
const comp = await openComposition(UNDECLARED_HTML);
const events: string[] = [];
comp.on("patch", (e) => {
for (const p of e.patches) events.push(`${p.op} ${p.path}`);
});
comp.declareVariable(TITLE_DECL);
comp.removeVariableDeclaration("title");
// Declaration ops also maintain the --{id} CSS compat prop (scalar defaults).
expect(events).toEqual([
"add /variableDeclarations/title",
"add /elements/hf-stage/inlineStyles/--title",
"remove /variableDeclarations/title",
"remove /elements/hf-stage/inlineStyles/--title",
]);
});
});
+140
View File
@@ -0,0 +1,140 @@
/**
* Variable read APIs: getVariableDeclarations / getVariableValues /
* validateVariableValues. Semantics contract: declarations use the strict
* canonical parser; values mirror the runtime's loose defaults + overrides
* merge; validation matches --strict-variables.
*/
import { describe, it, expect } from "vitest";
import { openComposition } from "./session.js";
const DECLS = [
{ id: "title", type: "string", label: "Title", default: "Hello" },
{ id: "accent", type: "color", label: "Accent", default: "#00C3FF" },
{ id: "count", type: "number", label: "Count", default: 3, min: 0, max: 10 },
{ id: "dark", type: "boolean", label: "Dark mode", default: false },
{
id: "layout",
type: "enum",
label: "Layout",
default: "wide",
options: [
{ value: "wide", label: "Wide" },
{ value: "tall", label: "Tall" },
],
},
];
function htmlWithVariables(attr: string): string {
return `<!DOCTYPE html>
<html data-composition-variables='${attr}'>
<body>
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px" data-duration="5">
<h1 data-hf-id="hf-title" data-start="0" data-end="3">Hello</h1>
</div>
</body>
</html>`;
}
const BASE_HTML = htmlWithVariables(JSON.stringify(DECLS));
describe("getVariableDeclarations", () => {
it("returns the declared schema with per-type metadata intact", async () => {
const comp = await openComposition(BASE_HTML);
const decls = comp.getVariableDeclarations();
expect(decls.map((d) => d.id)).toEqual(["title", "accent", "count", "dark", "layout"]);
const count = decls.find((d) => d.id === "count");
expect(count).toMatchObject({ type: "number", min: 0, max: 10, default: 3 });
const layout = decls.find((d) => d.id === "layout");
expect(layout).toMatchObject({ type: "enum", default: "wide" });
});
it("returns [] when the attribute is absent", async () => {
const comp = await openComposition(
`<div data-hf-id="hf-stage" data-hf-root data-duration="5"><p data-hf-id="hf-p">x</p></div>`,
);
expect(comp.getVariableDeclarations()).toEqual([]);
});
it("returns [] for invalid JSON and drops malformed entries", async () => {
const invalid = await openComposition(htmlWithVariables("{not json"));
expect(invalid.getVariableDeclarations()).toEqual([]);
const mixed = JSON.stringify([
{ id: "ok", type: "string", label: "Ok", default: "yes" },
{ id: "bad-type", type: "gradient", label: "Nope", default: "x" },
{ id: "bad-default", type: "number", label: "Nope", default: "not-a-number" },
"not-an-object",
]);
const mixedComp = await openComposition(htmlWithVariables(mixed));
const decls = mixedComp.getVariableDeclarations();
expect(decls.map((d) => d.id)).toEqual(["ok"]);
});
});
describe("getVariableValues", () => {
it("returns declared defaults when no overrides given", async () => {
const comp = await openComposition(BASE_HTML);
expect(comp.getVariableValues()).toEqual({
title: "Hello",
accent: "#00C3FF",
count: 3,
dark: false,
layout: "wide",
});
});
it("overrides win and undeclared override keys pass through (runtime parity)", async () => {
const comp = await openComposition(BASE_HTML);
const values = comp.getVariableValues({ title: "Custom", extra: 42 });
expect(values.title).toBe("Custom");
expect(values.accent).toBe("#00C3FF");
expect(values.extra).toBe(42);
});
it("uses the loose runtime defaults filter, not the strict declaration parser", async () => {
// A number variable with a string default is dropped by the strict parser
// but its default still flows through the runtime's readDeclaredDefaults —
// getVariableValues must match what a composition script actually reads.
const attr = JSON.stringify([
{ id: "loose", type: "number", label: "Loose", default: "not-a-number" },
]);
const comp = await openComposition(htmlWithVariables(attr));
expect(comp.getVariableDeclarations()).toEqual([]);
expect(comp.getVariableValues()).toEqual({ loose: "not-a-number" });
});
it("returns {} for a composition with no declarations", async () => {
const comp = await openComposition(
`<div data-hf-id="hf-stage" data-hf-root data-duration="5"><p data-hf-id="hf-p">x</p></div>`,
);
expect(comp.getVariableValues()).toEqual({});
expect(comp.getVariableValues({ a: 1 })).toEqual({ a: 1 });
});
});
describe("validateVariableValues", () => {
it("returns [] for values conforming to the schema", async () => {
const comp = await openComposition(BASE_HTML);
expect(comp.validateVariableValues({ title: "x", count: 5, dark: true })).toEqual([]);
});
it("flags undeclared keys, type mismatches, and enum violations", async () => {
const comp = await openComposition(BASE_HTML);
const issues = comp.validateVariableValues({
ghost: "boo",
count: "five",
layout: "diagonal",
});
const kinds = issues.map((i) => `${i.kind}:${i.variableId}`).sort();
expect(kinds).toEqual(["enum-out-of-range:layout", "type-mismatch:count", "undeclared:ghost"]);
});
it("stays consistent after setVariableValue edits the default", async () => {
const comp = await openComposition(BASE_HTML);
comp.setVariableValue("title", "Edited");
expect(comp.getVariableValues().title).toBe("Edited");
expect(comp.getVariableDeclarations().find((d) => d.id === "title")?.default).toBe("Edited");
expect(comp.validateVariableValues({ title: "still-a-string" })).toEqual([]);
});
});
@@ -0,0 +1,144 @@
/**
* getVariableUsage (declaration ↔ script-scan cross-reference) and
* setPreviewVariables (preview adapter delegation).
*/
import { describe, it, expect } from "vitest";
import { openComposition } from "./session.js";
import type { PreviewAdapter } from "./adapters/types.js";
const DECLS = JSON.stringify([
{ id: "title", type: "string", label: "Title", default: "Hello" },
{ id: "accent", type: "color", label: "Accent", default: "#00C3FF" },
{ id: "orphan", type: "string", label: "Never read", default: "x" },
]);
function doc(script: string, decls: string | null = DECLS): string {
const attr = decls ? ` data-composition-variables='${decls}'` : "";
return `<!DOCTYPE html>
<html${attr}>
<body>
<div data-hf-id="hf-stage" data-hf-root data-duration="5">
<h1 data-hf-id="hf-title">Hello</h1>
</div>
<script>${script}</script>
</body>
</html>`;
}
describe("getVariableUsage", () => {
it("cross-references used, unused, and undeclared ids", async () => {
const comp = await openComposition(
doc(`
const { title, ghost } = __hyperframes.getVariables();
document.querySelector("h1").textContent = title;
const vars = __hyperframes.getVariables();
el.style.color = vars.accent;
`),
);
const usage = comp.getVariableUsage();
expect(usage.usedIds).toEqual(["title", "ghost", "accent"]);
expect(usage.unusedDeclarations).toEqual(["orphan"]);
expect(usage.undeclaredReads).toEqual(["ghost"]);
expect(usage.scanIncomplete).toBe(false);
});
it("reports all declarations unused when no script reads variables", async () => {
const comp = await openComposition(doc(`gsap.timeline({ paused: true });`));
const usage = comp.getVariableUsage();
expect(usage.usedIds).toEqual([]);
expect(usage.unusedDeclarations).toEqual(["title", "accent", "orphan"]);
expect(usage.scanIncomplete).toBe(false);
});
it("propagates scanIncomplete from opaque access", async () => {
const comp = await openComposition(
doc(`const vars = getVariables(); const v = vars[pickKey()];`),
);
expect(comp.getVariableUsage().scanIncomplete).toBe(true);
});
it("counts a variable used only via var(--id) in a <style> block as used", async () => {
const comp = await openComposition(
doc(`gsap.timeline({ paused: true });`).replace(
"<body>",
`<body><style>.stage { background: var(--accent); }</style>`,
),
);
const usage = comp.getVariableUsage();
// accent is CSS-consumed, so it must NOT be badged unused.
expect(usage.unusedDeclarations).toEqual(["title", "orphan"]);
});
it("does not count var(--id) as usage of a prefix-extended custom property", async () => {
// `accent` must not be marked used by an unrelated `var(--accent-shadow)`.
const comp = await openComposition(
doc(`gsap.timeline({ paused: true });`).replace(
"<body>",
`<body><style>.stage { box-shadow: 0 0 4px var(--accent-shadow); }</style>`,
),
);
const usage = comp.getVariableUsage();
expect(usage.unusedDeclarations).toContain("accent");
});
it("counts declarative data-var-src / data-var-text bindings as usage", async () => {
const comp = await openComposition(`<!DOCTYPE html>
<html data-composition-variables='${DECLS}'>
<body>
<div data-hf-id="hf-stage" data-hf-root data-duration="5">
<img data-hf-id="hf-img" data-var-src="accent" src="x.jpg" />
<h1 data-hf-id="hf-h" data-var-text="title">t</h1>
</div>
</body>
</html>`);
const usage = comp.getVariableUsage();
expect(usage.usedIds).toEqual(expect.arrayContaining(["accent", "title"]));
expect(usage.unusedDeclarations).toEqual(["orphan"]);
expect(usage.scanIncomplete).toBe(false);
});
it("handles compositions with no declarations and no scripts", async () => {
const comp = await openComposition(
`<!DOCTYPE html><html><body><div data-hf-id="hf-stage" data-hf-root data-duration="5"><p data-hf-id="hf-p">x</p></div></body></html>`,
);
expect(comp.getVariableUsage()).toEqual({
usedIds: [],
unusedDeclarations: [],
undeclaredReads: [],
scanIncomplete: false,
});
});
});
describe("setPreviewVariables", () => {
function makeAdapter(calls: Array<Record<string, unknown> | null>): PreviewAdapter {
return {
elementAtPoint: () => null,
applyDraft: () => {},
commitPreview: () => {},
cancelPreview: () => {},
select: () => {},
on: () => () => {},
setPreviewVariables: (values) => calls.push(values),
};
}
it("delegates to the preview adapter and reports handling", async () => {
const calls: Array<Record<string, unknown> | null> = [];
const comp = await openComposition(doc(""), { preview: makeAdapter(calls) });
expect(comp.setPreviewVariables({ title: "Custom" })).toBe(true);
expect(comp.setPreviewVariables(null)).toBe(true);
expect(calls).toEqual([{ title: "Custom" }, null]);
});
it("returns false without an adapter or without adapter support", async () => {
const noAdapter = await openComposition(doc(""));
expect(noAdapter.setPreviewVariables({ a: 1 })).toBe(false);
const bare = makeAdapter([]);
delete bare.setPreviewVariables;
const unsupported = await openComposition(doc(""), { preview: bare });
expect(unsupported.setPreviewVariables({ a: 1 })).toBe(false);
});
});
+336
View File
@@ -0,0 +1,336 @@
/**
* SDK smoke test — end-to-end pipeline.
*
* Exercises the full public surface: openComposition → mutate → applyPatches →
* serialize round-trip, plus batch transactionality, history undo, persist
* adapter, and patch subscription.
*
* This file is the "golden example" pinned as a regression smoke test.
* If it breaks, the SDK's public contract has changed.
*/
import { describe, it, expect, vi } from "vitest";
import {
openComposition,
ORIGIN_APPLY_PATCHES,
resolveScoped,
findById,
escapeHfId,
readVariableDefault,
} from "./index.js";
import { createMemoryAdapter } from "./adapters/memory.js";
// ─── Fixture ─────────────────────────────────────────────────────────────────
const BASE_HTML = `
<!DOCTYPE html>
<html>
<head></head>
<body>
<div id="stage" data-hf-root data-width="1920" data-height="1080" data-duration="5">
<h1 id="title" data-hf-id="hf-title" data-start="0" data-end="3"
style="color: #fff; font-size: 64px">Hello World</h1>
<img id="logo" data-hf-id="hf-logo" src="/logo.png" alt="Logo"
data-x="100" data-y="200" data-start="0" data-end="5" />
<p id="body-copy" data-hf-id="hf-body" data-start="1" data-end="4"
style="font-size: 24px">Body copy</p>
</div>
</body>
</html>
`.trim();
// ─── init → mutate → serialize ────────────────────────────────────────────────
describe("openComposition + basic mutations", () => {
it("opens without error and exposes element snapshots", async () => {
const comp = await openComposition(BASE_HTML);
const els = comp.getElements();
expect(els.length).toBeGreaterThanOrEqual(3);
const title = comp.getElement("hf-title");
expect(title).not.toBeNull();
expect(title?.inlineStyles.color).toBe("#fff");
});
it("setStyle mutates inline styles and serializes back to HTML", async () => {
const comp = await openComposition(BASE_HTML);
comp.setStyle("hf-title", { color: "#f00", fontSize: "96px" });
const html = comp.serialize();
expect(html).toContain("color: #f00");
expect(html).toContain("font-size: 96px");
});
it("element handle sugar — same result as direct setStyle", async () => {
const comp = await openComposition(BASE_HTML);
comp.element("hf-title").setStyle({ color: "#0f0" });
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#0f0");
});
it("setText updates text content", async () => {
const comp = await openComposition(BASE_HTML);
comp.setText("hf-title", "Goodbye World");
expect(comp.getElement("hf-title")?.text).toContain("Goodbye World");
expect(comp.serialize()).toContain("Goodbye World");
});
it("dispatch moveElement writes data-x/data-y attributes", async () => {
const comp = await openComposition(BASE_HTML);
comp.dispatch({ type: "moveElement", target: "hf-logo", x: 300, y: 400 });
const el = comp.getElement("hf-logo");
expect(el?.attributes["data-x"]).toBe("300");
expect(el?.attributes["data-y"]).toBe("400");
});
it("serialize round-trip: mutate → serialize → reopen → same state", async () => {
const comp = await openComposition(BASE_HTML);
comp.setStyle("hf-title", { color: "#0f0" });
comp.setText("hf-body", "Round-tripped");
const html = comp.serialize();
const comp2 = await openComposition(html);
expect(comp2.getElement("hf-title")?.inlineStyles.color).toBe("#0f0");
expect(comp2.getElement("hf-body")?.text).toContain("Round-tripped");
});
});
// ─── patch subscription ───────────────────────────────────────────────────────
describe("patch events", () => {
it("emits a patch event per dispatch with correct path and value", async () => {
const comp = await openComposition(BASE_HTML);
const events: unknown[] = [];
comp.on("patch", (e) => events.push(e));
comp.setStyle("hf-title", { fontSize: "48px" });
expect(events).toHaveLength(1);
const event = events[0] as { patches: { path: string; value: unknown }[] };
const patch = event.patches.find((p) => p.path.endsWith("/fontSize"));
expect(patch?.value).toBe("48px");
});
it("applyPatches origin is tagged ORIGIN_APPLY_PATCHES", async () => {
const comp = await openComposition(BASE_HTML);
const origins: unknown[] = [];
comp.on("patch", (e) => origins.push((e as { origin: unknown }).origin));
comp.applyPatches([
{ op: "replace", path: "/elements/hf-title/inlineStyles/color", value: "#00f" },
]);
expect(origins[0]).toBe(ORIGIN_APPLY_PATCHES);
});
});
// ─── applyPatches ─────────────────────────────────────────────────────────────
describe("applyPatches", () => {
it("applies external RFC 6902 patches to the live document", async () => {
const comp = await openComposition(BASE_HTML);
comp.applyPatches([
{ op: "replace", path: "/elements/hf-title/inlineStyles/color", value: "#00f" },
{ op: "replace", path: "/elements/hf-title/text", value: "Patched" },
]);
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#00f");
expect(comp.getElement("hf-title")?.text).toContain("Patched");
});
it("applyPatches does NOT enter undo history — undo() is a no-op", async () => {
const comp = await openComposition(BASE_HTML);
comp.applyPatches([
{ op: "replace", path: "/elements/hf-title/inlineStyles/color", value: "#00f" },
]);
comp.undo(); // no-op: applyPatches bypasses history
// color must still be the patched value (undo had nothing to revert)
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#00f");
});
});
// ─── batch transactionality ───────────────────────────────────────────────────
describe("batch()", () => {
it("coalesces multiple dispatches into one patch event", async () => {
const comp = await openComposition(BASE_HTML);
const events: unknown[] = [];
comp.on("patch", (e) => events.push(e));
comp.batch(() => {
comp.setStyle("hf-title", { color: "#f00" });
comp.setText("hf-body", "Batched");
});
expect(events).toHaveLength(1);
});
it("rolls back DOM on throw — model unchanged after throwing batch", async () => {
const comp = await openComposition(BASE_HTML);
const beforeColor = comp.getElement("hf-title")?.inlineStyles.color;
try {
comp.batch(() => {
comp.setStyle("hf-title", { color: "#f00" });
throw new Error("user cancelled");
});
} catch {
// expected
}
// DOM must be exactly as before
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe(beforeColor);
});
it("throwing batch does NOT add a history entry — undo is a no-op", async () => {
const comp = await openComposition(BASE_HTML);
try {
comp.batch(() => {
comp.setStyle("hf-title", { color: "#f00" });
throw new Error("rollback");
});
} catch {
// expected
}
// undo should be a no-op since no history entry was added
comp.undo();
// color should still be the original (batch was rolled back + undo had nothing to do)
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#fff");
});
});
// ─── history ─────────────────────────────────────────────────────────────────
describe("undo / redo", () => {
it("undo reverts last mutation, redo re-applies it", async () => {
const comp = await openComposition(BASE_HTML);
comp.setStyle("hf-title", { color: "#f00" });
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#f00");
comp.undo();
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#fff");
comp.redo();
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#f00");
});
it("undo with no history is a no-op", async () => {
const comp = await openComposition(BASE_HTML);
const before = comp.getElement("hf-title")?.inlineStyles.color;
comp.undo(); // no-op
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe(before);
});
});
// ─── persist adapter ─────────────────────────────────────────────────────────
describe("persist adapter", () => {
it("writes serialized HTML to the adapter on mutation", async () => {
const adapter = createMemoryAdapter();
const writeSpy = vi.spyOn(adapter, "write");
const comp = await openComposition(BASE_HTML, { persist: adapter });
comp.setStyle("hf-title", { color: "#f00" });
await comp.flush();
expect(writeSpy).toHaveBeenCalled();
const [, content] = writeSpy.mock.calls[0] as [string, string];
expect(content).toContain("color: #f00");
});
it("still persists when history:false (undo opt-out must not disable auto-save)", async () => {
const adapter = createMemoryAdapter();
const writeSpy = vi.spyOn(adapter, "write");
const comp = await openComposition(BASE_HTML, { persist: adapter, history: false });
expect(comp.canUndo()).toBe(false); // undo is off…
comp.setStyle("hf-title", { color: "#f00" });
await comp.flush();
expect(writeSpy).toHaveBeenCalled(); // …but the write still happened
const [, content] = writeSpy.mock.calls[0] as [string, string];
expect(content).toContain("color: #f00");
});
it("surfaces persist errors via on('persist:error')", async () => {
const adapter = createMemoryAdapter();
const errors: unknown[] = [];
const comp = await openComposition(BASE_HTML, { persist: adapter });
comp.on("persist:error", (e) => errors.push(e));
adapter.injectFault("disk full");
comp.setStyle("hf-title", { color: "#f00" });
await new Promise((r) => setTimeout(r, 20));
expect(errors).toHaveLength(1);
});
it("defaults the write path to composition.html when persistPath is omitted", async () => {
const adapter = createMemoryAdapter();
const writeSpy = vi.spyOn(adapter, "write");
const comp = await openComposition(BASE_HTML, { persist: adapter });
comp.setStyle("hf-title", { color: "#f00" });
await comp.flush();
const [path] = writeSpy.mock.calls[0] as [string, string];
expect(path).toBe("composition.html");
});
it("writes to persistPath when supplied", async () => {
const adapter = createMemoryAdapter();
const writeSpy = vi.spyOn(adapter, "write");
const comp = await openComposition(BASE_HTML, {
persist: adapter,
persistPath: "scenes/intro.html",
});
comp.setStyle("hf-title", { color: "#f00" });
await comp.flush();
const [path] = writeSpy.mock.calls[0] as [string, string];
expect(path).toBe("scenes/intro.html");
});
});
// ─── T3 embedded mode (override-set) ─────────────────────────────────────────
describe("T3 embedded mode", () => {
it("applies override-set on open, mutations layer on top", async () => {
const comp = await openComposition(BASE_HTML, {
overrides: { "hf-title.style.color": "#0f0" },
});
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#0f0");
});
it("getOverrides() returns accumulated override-set", async () => {
const comp = await openComposition(BASE_HTML);
comp.setStyle("hf-title", { color: "#f00" });
const overrides = comp.getOverrides();
expect(overrides["hf-title.style.color"]).toBe("#f00");
});
it("serialize → reopen with overrides → same state as direct mutation", async () => {
// Simulate host storing overrides + base template separately (T3 pattern)
const comp = await openComposition(BASE_HTML);
comp.setStyle("hf-title", { color: "#0f0" });
comp.setText("hf-body", "Override text");
const overrides = comp.getOverrides();
// Host reopens the original template with the stored overrides
const comp2 = await openComposition(BASE_HTML, { overrides });
expect(comp2.getElement("hf-title")?.inlineStyles.color).toBe("#0f0");
expect(comp2.getElement("hf-body")?.text).toContain("Override text");
});
});
describe("engine helper exports (resolveScoped, findById, escapeHfId, readVariableDefault)", () => {
it("are importable from the public index and work against a live document", async () => {
// These operate on a Document directly, not the Composition — exercise them
// against a document parsed the same way the SDK parses internally.
const { document } = await import("linkedom").then((m) => m.parseHTML(BASE_HTML));
expect(findById(document as unknown as Document, "hf-title")).not.toBeNull();
expect(resolveScoped(document as unknown as Document, "hf-title")).not.toBeNull();
expect(escapeHfId('hf-"quoted"')).toBe('hf-\\"quoted\\"');
// readVariableDefault takes the declaration element (the <html>/root), not the Document.
const declEl = (document as unknown as Document).documentElement;
expect(readVariableDefault(declEl, "never-declared")).toBeUndefined();
});
});
+611
View File
@@ -0,0 +1,611 @@
import type { CompositionVariable, VariableValidationIssue } from "@hyperframes/core/variables";
/**
* Cross-referenced variable usage for a whole composition: the per-script
* static scans merged and compared against the declared schema.
*/
export interface VariableUsageReport {
/** Variable ids read by composition scripts (static analysis, first-seen order). */
usedIds: string[];
/** Declared ids never read by any script. */
unusedDeclarations: string[];
/** Ids read by scripts but missing from data-composition-variables. */
undeclaredReads: string[];
/**
* True when any script accesses variables opaquely (computed keys, escaping
* values object…) — usedIds is then a lower bound and unusedDeclarations
* may be false positives.
*/
scanIncomplete: boolean;
}
// ─── Document model ───────────────────────────────────────────────────────────
/** Full DOM-level view of one editable element. Built by the SDK adaptation layer. */
export interface HyperFramesElement {
readonly id: string;
/**
* Fully-qualified scoped id — host-chain prefix + leaf, separated by "/".
* For top-level elements: scopedId === id.
* For elements inside inlined sub-compositions: "hf-HOST/hf-LEAF" (any depth).
* This is the canonical identifier to use in dispatch targets, getElement(),
* find(), and override-set keys when addressing sub-composition elements.
*/
readonly scopedId: 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>>;
/** Display text for the SDK setText target, not a full descendant-text snapshot. */
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 }
*
* Font and image variable overrides store their object values under the var.{id} key:
* { "var.brand-font": { name: "Roboto", source: "https://fonts.googleapis.com/…" } }
*/
/**
* A set of variable overrides. The `Record<string, unknown>` member admits
* object-valued variables (font/image). NOTE for SDK consumers: this widening
* means code reading an OverrideSet value must narrow before assuming a scalar —
* an object value will type-check anywhere `unknown` is accepted.
*/
export type OverrideSet = Record<
string,
string | number | boolean | Record<string, unknown> | null
>;
// ─── can() result ─────────────────────────────────────────────────────────────
/**
* Structured result from can(op).
*
* `ok: true` — dispatch(op) will succeed.
* `ok: false` — dispatch would be a no-op or error; `code` is stable for switch.
* Codes: E_TARGET_NOT_FOUND | E_NO_ROOT | E_NO_GSAP_TIMELINE | E_NO_GSAP_SCRIPT
*/
export type CanResult = { ok: true } | { ok: false; code: string; message: string; hint?: string };
// ─── 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: "addElement";
/** Id of the parent element, or null to insert at the document body root. */
parent: HfId | null;
/** Zero-based sibling index at which to insert (append if >= childCount). */
index: number;
/** Single-root HTML fragment. Must not contain <script>. */
html: string;
}
| {
type: "reorderElements";
/** Each entry sets inline zIndex on one element. Positioning is unchanged — z-index only takes effect on non-static elements, so the caller must ensure the target is positioned. */
entries: Array<{ target: HfId; zIndex: number }>;
}
| { type: "setClassStyle"; selector: string; styles: Record<string, string | null> }
| { type: "setCompositionMetadata"; width?: number; height?: number; duration?: number }
| { type: "declareVariable"; declaration: CompositionVariable }
| { type: "updateVariableDeclaration"; id: string; declaration: CompositionVariable }
| { type: "removeVariableDeclaration"; id: string }
| {
type: "setVariableValue";
id: string;
value: string | number | boolean | FontValue | ImageValue;
}
// #2098 alias op — remove-by-id, kept for its shipped session.removeVariable().
| { type: "removeVariable"; id: string }
| { type: "addGsapTween"; target: HfId; 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; percentage: number }
| { type: "removeGsapProperty"; animationId: string; property: string; from?: boolean }
| { type: "removeGsapTween"; animationId: string }
| { type: "removeAllKeyframes"; animationId: string }
| {
type: "convertToKeyframes";
animationId: string;
resolvedFromValues?: Record<string, number | string>;
}
| { type: "deleteAllForSelector"; selector: string }
| {
type: "materializeKeyframes";
animationId: string;
keyframes: Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}>;
easeEach?: string;
resolvedSelector?: string;
}
| { type: "splitIntoPropertyGroups"; animationId: string }
| {
type: "splitAnimations";
originalId: string;
newId: string;
splitTime: number;
elementStart: number;
elementDuration: number;
}
| { type: "addLabel"; name: string; position: number }
| { type: "removeLabel"; name: string }
| {
type: "setArcPath";
animationId: string;
config: {
enabled: boolean;
autoRotate: boolean | number;
segments: Array<{
curviness?: number;
cp1?: { x: number; y: number };
cp2?: { x: number; y: number };
}>;
};
}
| {
type: "updateArcSegment";
animationId: string;
segmentIndex: number;
update: {
curviness?: number;
cp1?: { x: number; y: number };
cp2?: { x: number; y: number };
};
}
| { type: "removeArcPath"; animationId: string }
| {
type: "unrollDynamicAnimations";
animationId: string;
elements: Array<{
selector: string;
keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
easeEach?: string;
}>;
}
| {
/** Insert a new keyframed tween for targetSelector at the given position/duration. */
type: "addWithKeyframes";
targetSelector: string;
/** Timeline position in seconds. Number-only (unlike GsapTweenSpec.position, which also accepts label-relative strings). */
position: number;
duration: number;
keyframes: KeyframeSpec[];
ease?: string;
}
| {
/**
* Replace an existing tween (by animationId) with a new keyframed tween.
* Equivalent to removeGsapTween + addWithKeyframes in one atomic op.
* Position-derived tween IDs renumber after the remove; callers must
* re-parse to discover the new ID.
*/
type: "replaceWithKeyframes";
animationId: string;
targetSelector: string;
/** Timeline position in seconds. Number-only (unlike GsapTweenSpec.position, which also accepts label-relative strings). */
position: number;
duration: number;
keyframes: KeyframeSpec[];
ease?: string;
};
/**
* A single keyframe entry for `addWithKeyframes` / `replaceWithKeyframes`.
* Single source of truth — Studio-side mirrors (KeyframeEntry/KeyframeSpec) should
* import this rather than redeclare the shape.
*/
export interface KeyframeSpec {
percentage: number;
properties: Record<string, number | string>;
ease?: string;
/** GSAP endpoint flag — emitted as numeric `_auto: 1`, not boolean. */
auto?: boolean;
}
export interface ElasticHold {
start: number;
end: number;
fill: "freeze" | "loop";
}
/**
* Object value for a `font` variable (LOCKED §7 — object-valued, never a CSS string).
* `name` is the CSS font-family value; `source` is the stylesheet URL to load.
*/
export interface FontValue {
name: string;
source: string;
}
/**
* Object value for an `image` variable (LOCKED §7 — object-valued, never a CSS string).
* `url` is the image src. Add explicit optional fields here as consumers need them —
* an open `[key: string]: unknown` index signature was dropped because it let any
* `{url}`-shaped object through and swallowed key typos.
*/
export interface ImageValue {
url: string;
alt?: string;
fit?: "cover" | "contain" | "fill" | "none" | "scale-down";
}
export interface GsapTweenSpec {
method: "from" | "to" | "fromTo" | "set";
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;
stagger?: number | Record<string, unknown>;
}
// ─── 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;
/** Filter to elements inside a specific sub-composition host (by host hf-id). */
composition?: string;
}
// ─── 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;
}
// ─── Timing accessor types (WS-C) ─────────────────────────────────────────────
/**
* Resolved timing snapshot for one element.
* Labels are GSAP timeline label names whose numeric position falls within
* [enterAt, exitAt] for this element. Parsed fresh on every call — never cached.
*/
export interface ElementTimingSnapshot {
enterAt: number;
exitAt: number;
/** GSAP addLabel names active during this element's window. */
labels: string[];
}
// ─── 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;
/**
* Insert an HTML fragment as a child of `parent` at `index` (WS-D).
* Mints a stable hf-id against the live document's existing id set.
* Returns the minted id of the inserted root element.
* Inverse = removeElement of the returned id.
*/
addElement(parent: HfId | null, index: number, html: string): HfId;
setVariableValue(id: string, value: string | number | boolean | FontValue | ImageValue): void;
/**
* Current `default` value for a declared variable, or undefined if
* undeclared/unset. Convenience over getVariableValues() (kept from #2098).
*/
getVariableValue(id: string): string | number | boolean | FontValue | ImageValue | undefined;
/**
* Every declared variable's full schema (id/type/label/default/…), or [] when
* none. Alias of getVariableDeclarations() (kept from #2098's surface).
*/
listVariables(): CompositionVariable[];
/** Remove a variable's declaration — alias of removeVariableDeclaration(). */
removeVariable(id: string): void;
/**
* Declare a new variable in `data-composition-variables`. No-ops when the
* id is already declared (see can() for the E_DUPLICATE_VARIABLE check);
* creates the attribute when the composition has none yet.
*/
declareVariable(declaration: CompositionVariable): void;
/**
* Replace an existing declaration wholesale (label, type, constraints,
* default — the id itself is immutable; rename = remove + declare). When
* the default changes to/from a scalar, the `--{id}` CSS compat custom
* property on the root is kept in sync, mirroring setVariableValue.
*/
updateVariableDeclaration(id: string, declaration: CompositionVariable): void;
/**
* Remove a declaration (and the last one removes the whole attribute).
* Also clears the `--{id}` CSS compat custom property if present.
*/
removeVariableDeclaration(id: string): void;
/**
* Read the typed variable declarations from `data-composition-variables`
* (the canonical schema — same filter the render pipeline uses; malformed
* entries are dropped). Read-only — does not dispatch.
*/
getVariableDeclarations(): CompositionVariable[];
/**
* Resolve this composition's variable values: its declared defaults merged
* with `overrides` (overrides win, undeclared override keys pass through).
* Read-only — does not dispatch.
*
* Scope: reads THIS composition file's own declaration element, not a union of
* every `[data-composition-variables]` in a bundled document. The runtime's
* `getVariables()` additionally walks inlined sub-composition declarers because
* it runs on the fully-bundled document; the SDK models one composition file,
* so per-file scope is intentional (and is what Studio needs to predict a
* single file's `--variables` payload). For the common single-`<html>`
* composition the two agree; they diverge only when sub-comp declarers are
* inlined into one document.
*/
getVariableValues(overrides?: Record<string, unknown>): Record<string, unknown>;
/**
* Validate a values map against the declared schema. Returns undeclared /
* type-mismatch / enum-out-of-range issues (same checks as the CLI's
* `--strict-variables`). Read-only — does not dispatch.
*/
validateVariableValues(values: Record<string, unknown>): VariableValidationIssue[];
/**
* Cross-reference the declared schema against a static scan of every inline
* composition script (getVariables() reads). Read-only — does not dispatch.
*/
getVariableUsage(): VariableUsageReport;
/**
* Apply variable values to the preview surface (ephemeral — never written
* to the document; use setVariableValue to persist a default). Pass null to
* restore declared defaults. No-op when the preview adapter doesn't
* implement setPreviewVariables; returns whether the adapter handled it.
*/
setPreviewVariables(values: Record<string, unknown> | null): boolean;
/**
* Read enter/exit times and GSAP labels for every timed element (WS-C).
* Derives enterAt/exitAt using the same data-duration vs data-end preference
* as handleSetTiming (data-duration wins; data-end data-start as fallback).
* Labels are parsed fresh from the GSAP script each call.
* Read-only — does not dispatch.
*/
getElementTimings(): Record<HfId, ElementTimingSnapshot>;
/**
* Apply a sparse timing map in a single batch (WS-C).
* Dispatches one setTiming op per entry inside a batch so the history sees
* one undo step. Skips entries for unknown ids silently.
*/
setElementTiming(
map: Record<HfId, { start?: number; duration?: number; trackIndex?: number }>,
): void;
/**
* Set an elastic hold window on an element (WS-C).
* Thin typed wrapper over the existing setHold op — mirrors setVariableValue pattern.
*/
setHold(id: HfId, hold: ElasticHold): void;
/** Returns the newly-assigned tween ID */
addGsapTween(target: HfId, tween: GsapTweenSpec): string;
setGsapTween(animationId: string, properties: Partial<GsapTweenSpec>): void;
removeGsapTween(animationId: string): void;
/**
* Add a keyframed tween. Typed wrapper over the addWithKeyframes op (mirrors
* addGsapTween). Returns the newly-minted animationId, or "" if rejected.
*/
addWithKeyframes(
targetSelector: string,
position: number,
duration: number,
keyframes: KeyframeSpec[],
ease?: string,
): string;
/**
* Replace an existing keyframed tween. Typed wrapper over replaceWithKeyframes.
* Returns the replacement's animationId (treat as NEW — position-derived IDs
* renumber after the remove), or "" if rejected.
*/
replaceWithKeyframes(
animationId: string,
targetSelector: string,
position: number,
duration: number,
keyframes: KeyframeSpec[],
ease?: string,
): string;
undo(): void;
redo(): void;
canUndo(): boolean;
canRedo(): boolean;
// ── Query API (F1) ─────────────────────────────────────────────────────────
getElements(): ElementSnapshot[];
/** Top-level elements only, each carrying its full subtree — no id appears twice. */
getRootElements(): ElementSnapshot[];
getElement(id: HfId): ElementSnapshot | null;
find(query: FindQuery): string[];
/**
* Every GSAP tween id parsed from the composition's script, regardless of
* whether its target selector currently matches a live DOM element. See
* parsedAnimationIds in document.ts for why this differs from the
* per-element animationIds on ElementSnapshot.
*/
getAllAnimationIds(): Set<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[];
/** Replace the current selection; fires selectionchange. Pass [] to clear. */
setSelection(ids: string[]): void;
// ── 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?
* Returns {ok:true} when dispatch would mutate the document, {ok:false,code,message} otherwise.
* Use as a feature-detection gate: `const r = comp.can(op); if (!r.ok) return;`
* Phase 3b ops return {ok:false,code:'E_NO_GSAP_TIMELINE'} until parser engine ships.
*/
can(op: EditOp): CanResult;
// ── 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 ──────────────────────────────────────────────────────────
/** stripRuntime removes an embedded preview-runtime script — for a host driving its own clock. */
serialize(opts?: { stripRuntime?: boolean }): 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 ──────────────────────────────────────────────────────────────
/** Drain the persist queue — resolves when any queued write is committed. No-op if no adapter. */
flush(): Promise<void>;
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",
},
});