feat(sdk): session API, optional history + persist-queue, adapters — Phase 3a complete (#1325)

* feat(sdk): session API, optional history + persist-queue, adapters — Phase 3a complete

* fix(sdk): address review — live-DOM query cache, single parse, style parse dedup

- getElements/getElement/find now walk the live linkedom DOM via buildRoots
  with a lazily-built cache invalidated on dispatch/applyPatches — no
  serialize→ensureHfIds→parseHTML round trip per query
- openComposition parses once (parseMutable); dropped discarded _doc
  constructor param and the redundant buildDocument call
- document.ts buildElement reuses model.ts getElementStyles — removes
  duplicated parseInlineStyles (also fixes custom-prop camelCase mangling)
- JSDoc note: empty batch() still fires change handlers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): restore full public exports now session/document modules exist

index.ts re-exports document/session/history/persist-queue (trimmed in the
engine-layer PR to keep it self-contained); drops the temporary fallow
suppressions whose consumers now exist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): coalesce history by patch paths; replay override-set on open

Adversarial-review findings F1 + F2:

- history: coalescing now requires identical patch paths in addition to
  op types + origin + window. Previously two rapid setStyle calls on
  DIFFERENT elements merged into one entry carrying the second forward +
  first inverse — undo then reverted the wrong element and stranded the
  latest edit. Slider drags on one property still coalesce.
- T3 init: openComposition({ overrides }) now replays the stored
  override-set onto the freshly-parsed base before exposing the session
  (new keyToPath inverse mapping + applyOverrideSet). Previously the
  overrides were copied into the map but never applied — reopening an
  embedded composition showed and serialized the base template.
- examples: GSAP calls now feature-detect with can() (Phase 3b ops throw
  UnsupportedOpError as of the engine-layer fix); UnsupportedOpError
  re-exported from the package entry.
- 8 new session tests: coalesce same-path / cross-element / cross-prop,
  override round-trip (style/text/attr/timing/removal/restore-base).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): transactional batch rollback, sorted coalesce key, root-priority unify

Round-2 review (Rames/Miguel) on the session layer:

- batch() is now transactional: on throw, accumulated inverse patches are
  replayed in reverse and the override-set snapshot restored — the model is
  exactly as it was at batch entry. Previously a throwing batch left the DOM
  partially mutated with no patch trail, no history entry, no recovery path.
  2 new tests (model unchanged + undo is no-op after throwing batch).
- history coalesce key sorts opTypes — same op-type set coalesces regardless
  of dispatch order within a batch.
- applyPatches comment documents that emitted PatchEvents carry an empty
  inversePatches array (hosts keep their own inverse log).
- document.ts extractDimensions/extractDuration now use the engine's
  findRoot — dimension extraction and mutations agree on the root element
  ([data-hf-root] > #stage > first child). Dimensions prefer the runtime's
  data-width/data-height forced-override attrs, falling back to inline style.
- ownText documented: snapshot .text is trimmed display text; setText writes
  verbatim.

Deferred to follow-up (acknowledged, not ship-blocking): persist-queue flush
error surfacing, debounce window, path default, history ring-buffer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-06-11 12:23:09 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 22bb6737c5
commit 7010edac85
17 changed files with 1875 additions and 7 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);
}
+46
View File
@@ -0,0 +1,46 @@
import type { PersistAdapter, PersistVersionEntry } from "./types.js";
import type { PersistErrorEvent } from "../types.js";
export interface FsAdapterOptions {
/** Root directory for composition files */
root: string;
}
// Phase 4 — fs adapter stub. Full implementation in SDK Phase 4 (adapters stage).
// Uses Node.js fs/promises; not browser-safe (must be conditionally imported by consumers).
class FsAdapter implements PersistAdapter {
private readonly root: string;
constructor(opts: FsAdapterOptions) {
this.root = opts.root;
}
async read(_path: string): Promise<string | undefined> {
throw new Error("FsAdapter: Phase 4 — not yet implemented");
}
async write(_path: string, _content: string): Promise<void> {
throw new Error("FsAdapter: Phase 4 — not yet implemented");
}
async flush(): Promise<void> {
throw new Error("FsAdapter: Phase 4 — not yet implemented");
}
async listVersions(_path: string): Promise<PersistVersionEntry[]> {
throw new Error("FsAdapter: Phase 4 — not yet implemented");
}
async loadFrom(_path: string, _versionKey: string): Promise<string | undefined> {
throw new Error("FsAdapter: Phase 4 — not yet implemented");
}
on(_event: "persist:error", _handler: (e: PersistErrorEvent) => void): () => void {
return () => {};
}
}
export function createFsAdapter(opts: FsAdapterOptions): PersistAdapter {
return new FsAdapter(opts);
}
+24
View File
@@ -0,0 +1,24 @@
import type { PreviewAdapter, ElementAtPointResult, DraftProps } from "./types.js";
/** Null PreviewAdapter for headless use (agents, CI, server-side rendering). */
class HeadlessPreviewAdapter implements PreviewAdapter {
elementAtPoint(_x: number, _y: number, _opts?: { atTime?: number }): ElementAtPointResult | null {
return null;
}
applyDraft(_id: string, _props: DraftProps): void {}
commitPreview(): void {}
cancelPreview(): void {}
select(_ids: string[], _opts?: { additive?: boolean }): void {}
on(_event: "selection", _handler: (ids: string[]) => void): () => void {
return () => {};
}
}
export function createHeadlessAdapter(): PreviewAdapter {
return new HeadlessPreviewAdapter();
}
+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,128 @@
/**
* T13 — PersistAdapter contract suite
*
* Parameterized over adapter implementations. Every adapter (memory, fs, S3, HTTP)
* runs the same suite automatically — write once, protect all.
*
* Run against the memory adapter immediately; future implementations:
* runPersistAdapterContract("fs", () => createFsAdapter({ root: tmpDir }))
* runPersistAdapterContract("s3", () => createS3Adapter({ bucket, prefix }))
*/
import { describe, it, expect, vi } from "vitest";
import { createMemoryAdapter } from "./memory.js";
import type { PersistAdapter } from "./types.js";
export function runPersistAdapterContract(
label: string,
createAdapter: () => PersistAdapter,
): void {
describe(`PersistAdapter contract — ${label}`, () => {
it("read returns undefined for a path never written", async () => {
const adapter = createAdapter();
expect(await adapter.read("missing.html")).toBeUndefined();
});
it("write then read returns the written content", async () => {
const adapter = createAdapter();
await adapter.write("comp.html", "<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);
-2
View File
@@ -1,5 +1,3 @@
// Consumed by session.ts + adapter implementations in the next stacked PR (#1325).
// fallow-ignore-file unused-file
import type { PersistErrorEvent } from "../types.js"; import type { PersistErrorEvent } from "../types.js";
// ─── PersistAdapter ─────────────────────────────────────────────────────────── // ─── PersistAdapter ───────────────────────────────────────────────────────────
+188
View File
@@ -0,0 +1,188 @@
/**
* SDK document model — adaptation layer on top of @hyperframes/core.
*
* F6 decision: SDK builds ON core, no parser duplication.
* - ensureHfIds (from core) is the parse entry point: all construction starts here.
* - DOMParser is NOT used (browser-only). linkedom is the node-safe primitive.
* - ParsedHtml (core) is the Studio timeline view (timed elements only).
* HyperFramesElement is the editing view (ALL editable elements, with raw attrs).
*/
import { parseHTML } from "linkedom";
import { ensureHfIds } from "@hyperframes/core/hf-ids";
import { findRoot, getElementStyles } 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). setText writes verbatim — engine getOwnText/setOwnText
// operate on raw text. el.text is a display value, not a round-trip identity.
function ownText(el: Element): string | null {
let text = "";
el.childNodes.forEach((n) => {
if (n.nodeType === 3) text += (n as Text).nodeValue ?? "";
});
const trimmed = text.trim();
return trimmed.length > 0 ? trimmed : null;
}
// fallow-ignore-next-line complexity
function buildElement(el: Element): HyperFramesElement | null {
const tag = el.tagName.toLowerCase();
if (EXCLUDED_TAGS.has(tag)) return null;
const id = el.getAttribute("data-hf-id") ?? "";
if (!id) return null; // should never happen after ensureHfIds, but guard defensively
const 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: HyperFramesElement[] = [];
for (const child of Array.from(el.children)) {
const built = buildElement(child);
if (built) children.push(built);
}
return {
id,
tag,
children,
inlineStyles,
classNames,
attributes,
text: ownText(el),
start,
duration,
trackIndex,
animationIds: [],
};
}
// fallow-ignore-next-line complexity
function extractGsapScript(doc: Document): string | null {
// GSAP script is the first <script> tag whose text references gsap
for (const script of Array.from(doc.querySelectorAll("script"))) {
const text = script.textContent ?? "";
if (text.includes("gsap") || text.includes("ScrollTrigger")) {
return text;
}
}
return 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;
const roots: HyperFramesElement[] = [];
if (body) {
for (const child of Array.from(body.children)) {
const built = buildElement(child);
if (built) roots.push(built);
}
}
return roots;
}
/**
* 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;
}
+21 -1
View File
@@ -8,9 +8,10 @@
* unknown paths are silently ignored, matching the JsonPatchOp contract. * unknown paths are silently ignored, matching the JsonPatchOp contract.
*/ */
import type { JsonPatchOp } from "../types.js"; import type { JsonPatchOp, OverrideSet } from "../types.js";
import type { ParsedDocument } from "./model.js"; import type { ParsedDocument } from "./model.js";
import { findById, findRoot, setElementStyles, setOwnText } from "./model.js"; import { findById, findRoot, setElementStyles, setOwnText } from "./model.js";
import { keyToPath } from "./patches.js";
// ─── Path parser ──────────────────────────────────────────────────────────── // ─── Path parser ────────────────────────────────────────────────────────────
@@ -56,6 +57,25 @@ function parsePath(path: string): ParsedPath | null {
// ─── Patch application ─────────────────────────────────────────────────────── // ─── Patch application ───────────────────────────────────────────────────────
/**
* Replay a stored override-set onto a freshly-parsed base document (T3 init).
* Property keys with null mean "restore base value" — a no-op on a fresh base.
* Bare element keys with null are removal markers — the element is removed.
*/
export function applyOverrideSet(parsed: ParsedDocument, overrides: OverrideSet): void {
const patches: JsonPatchOp[] = [];
for (const [key, value] of Object.entries(overrides)) {
const path = keyToPath(key);
if (!path) continue;
if (value === null) {
if (!key.includes(".")) patches.push({ op: "remove", path });
continue;
}
patches.push({ op: "replace", path, value });
}
applyPatchesToDocument(parsed, patches);
}
export function applyPatchesToDocument( export function applyPatchesToDocument(
parsed: ParsedDocument, parsed: ParsedDocument,
patches: readonly JsonPatchOp[], patches: readonly JsonPatchOp[],
+3 -2
View File
@@ -53,9 +53,10 @@ const PHASE3B_OPS = new Set([
"removeLabel", "removeLabel",
]); ]);
// Re-exported from the package entry in the next stacked PR (#1325).
// fallow-ignore-next-line unused-export
export class UnsupportedOpError extends Error { export class UnsupportedOpError extends Error {
// Stable error code — part of the public API contract (F7); hosts switch on
// err.code rather than the message.
// fallow-ignore-next-line unused-class-member
readonly code = "E_UNSUPPORTED_OP"; readonly code = "E_UNSUPPORTED_OP";
constructor(opType: string) { constructor(opType: string) {
super( super(
+32 -2
View File
@@ -103,10 +103,40 @@ export function pathToKey(path: string): string | null {
return null; 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).
*/
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);
if (attr?.[1] && attr[2]) return attrPath(attr[1], attr[2]);
const timing = /^([^.]+)\.timing\.(start|end|trackIndex)$/.exec(key);
if (timing?.[1]) return timingPath(timing[1], timing[2] as "start" | "end" | "trackIndex");
const hold = /^([^.]+)\.hold\.(start|end|fill)$/.exec(key);
if (hold?.[1]) return holdPath(hold[1], hold[2] as "start" | "end" | "fill");
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");
// Bare element id — removal marker key.
if (!key.includes(".")) return elementPath(key);
return null;
}
// ─── Patch event builder ────────────────────────────────────────────────────── // ─── Patch event builder ──────────────────────────────────────────────────────
// Consumed by session.ts dispatch/batch in the next stacked PR (#1325).
// fallow-ignore-next-line unused-export
export function buildPatchEvent( export function buildPatchEvent(
forward: readonly JsonPatchOp[], forward: readonly JsonPatchOp[],
inverse: readonly JsonPatchOp[], inverse: readonly JsonPatchOp[],
+144
View File
@@ -0,0 +1,144 @@
/**
* 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 (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;
},
};
}
+13
View File
@@ -17,3 +17,16 @@ export type {
} from "./types.js"; } from "./types.js";
export { ORIGIN_APPLY_PATCHES, ORIGIN_LOCAL } from "./types.js"; export { ORIGIN_APPLY_PATCHES, ORIGIN_LOCAL } from "./types.js";
export { UnsupportedOpError } from "./engine/mutate.js";
export { buildDocument, buildRoots, flatElements } from "./document.js";
export { openComposition } from "./session.js";
export type { OpenCompositionOptions } from "./session.js";
export { createHistory } from "./history.js";
export type { HistoryModule, HistoryOptions, HistoryEntry } from "./history.js";
export { createPersistQueue } from "./persist-queue.js";
export type { PersistQueueModule, PersistQueueOptions } from "./persist-queue.js";
+80
View File
@@ -0,0 +1,80 @@
/**
* 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 } 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;
}
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 {
// error already surfaced via persist:error on the adapter
}
});
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();
},
};
}
+140
View File
@@ -0,0 +1,140 @@
/**
* Session-level behavior: history coalescing invariants and T3 override replay.
*/
import { describe, it, expect } from "vitest";
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" 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();
// ─── 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 restore-base (no-op on fresh base)", async () => {
const comp = await openComposition(BASE_HTML, {
overrides: { "hf-title.style.color": null },
});
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#fff");
});
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");
});
});
+436
View File
@@ -0,0 +1,436 @@
/**
* 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 {
Composition,
EditOp,
ElementSnapshot,
FindQuery,
GsapTweenSpec,
HfId,
JsonPatchOp,
OverrideSet,
PatchEvent,
PersistErrorEvent,
SelectionProxy,
ElementHandle,
} from "./types.js";
import { ORIGIN_APPLY_PATCHES, ORIGIN_LOCAL } from "./types.js";
import { buildRoots, flatElements } 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 } from "./engine/mutate.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;
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;
}
// ─── Implementation ───────────────────────────────────────────────────────────
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;
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> = [];
/** 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 ?? {}) };
}
attachHistory(module: HistoryModule): void {
this.historyModule = module;
}
attachPersistQueue(module: PersistQueueModule): void {
this.persistQueueModule = module;
}
// ── 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 });
}
setVariableValue(id: string, value: string | number | boolean): void {
this.dispatch({ type: "setVariableValue", id, value });
}
addGsapTween(target: HfId, tween: GsapTweenSpec): string {
// Phase 3b: AST splice. For now, mint id and pass through.
const tweenId = `tw-${crypto.randomUUID().slice(0, 8)}`;
this.dispatch({ type: "addGsapTween", target, id: tweenId, tween });
return tweenId;
}
setGsapTween(animationId: string, properties: Partial<GsapTweenSpec>): void {
this.dispatch({ type: "setGsapTween", animationId, properties });
}
removeGsapTween(animationId: string): void {
this.dispatch({ type: "removeGsapTween", animationId });
}
undo(): void {
this.historyModule?.undo();
}
redo(): void {
this.historyModule?.redo();
}
// ── 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];
}
getElement(id: HfId): ElementSnapshot | null {
return this.getElements().find((el) => el.id === 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;
return true;
})
.map((el) => el.id)
);
}
// ── 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];
}
// ── Dispatch / batch ─────────────────────────────────────────────────────────
// fallow-ignore-next-line complexity
dispatch(op: EditOp, opts?: { origin?: unknown }): void {
const origin = opts?.origin ?? ORIGIN_LOCAL;
const { forward, inverse } = applyOp(this.parsed, op);
if (forward.length === 0 && inverse.length === 0) {
// No-op (e.g. Phase 3b op with no implementation yet): still fire change
if (this.batchDepth === 0) this.changeHandlers.forEach((h) => h());
return;
}
this.elementsCache = 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 | null);
}
}
if (this.batchDepth > 0) {
this.batchForward.push(...forward);
this.batchInverse.push(...inverse);
if (!this.batchOpTypes.includes(op.type)) this.batchOpTypes.push(op.type);
} else {
const event = buildPatchEvent(forward, inverse, origin, [op.type]);
this.patchHandlers.forEach((h) => h(event));
this.changeHandlers.forEach((h) => h());
}
}
/**
* 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,
);
this.resetBatchState();
this.patchHandlers.forEach((h) => h(event));
this.changeHandlers.forEach((h) => h());
} 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.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): boolean {
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(): string {
return serializeDocument(this.parsed);
}
// ── 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;
// 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 | null);
}
}
const opTypes = ["applyPatches"];
const event = buildPatchEvent(patches, [], origin, opTypes);
this.patchHandlers.forEach((h) => h(event));
this.changeHandlers.forEach((h) => h());
}
// ── Lifecycle ────────────────────────────────────────────────────────────────
dispose(): void {
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) {
const history = createHistory(session, {
coalesceMs: opts?.coalesceMs ?? 300,
trackedOrigins: opts?.trackedOrigins,
});
session.attachHistory(history);
if (opts?.persist) {
const pq = createPersistQueue(session, opts.persist);
session.attachPersistQueue(pq);
}
}
return session;
}