Skip to content

Commit 7010eda

Browse files
vanceingallsclaude
andauthored
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>
1 parent 22bb673 commit 7010eda

17 files changed

Lines changed: 1875 additions & 7 deletions
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
/**
2+
* Archetype (c) — Headless agent script
3+
*
4+
* Shows: no browser, no persist adapter, no preview — pure editing engine.
5+
* Agents: batch restyling, localization, A/B variants, programmatic animation.
6+
* Explicit-id ops via query API — no selection, no mouse events.
7+
*
8+
* F1 payoff: headless is possible BECAUSE ops have explicit targets.
9+
* Selection-implicit ops (old R0) would break here — no UI → no selection.
10+
*/
11+
12+
import { openComposition } from "../src/index.js";
13+
import type { ElementSnapshot } from "../src/index.js";
14+
15+
// ── Localization agent ────────────────────────────────────────────────────────
16+
// Rewrites all text elements to a new locale. No browser, no preview.
17+
18+
export async function localize(html: string, translations: Map<string, string>): Promise<string> {
19+
const comp = await openComposition(html);
20+
21+
const textElements = comp.find({ tag: "div" });
22+
23+
comp.batch(() => {
24+
for (const id of textElements) {
25+
const el = comp.getElement(id);
26+
if (!el?.text) continue;
27+
const translated = translations.get(el.text);
28+
if (translated) comp.setText(id, translated);
29+
}
30+
});
31+
32+
return comp.serialize();
33+
}
34+
35+
// ── Brand restyle agent ───────────────────────────────────────────────────────
36+
// Apply brand colors to all elements with a matching class name.
37+
38+
export async function applyBrandColors(
39+
html: string,
40+
brandPrimary: string,
41+
brandSecondary: string,
42+
): Promise<string> {
43+
const comp = await openComposition(html);
44+
45+
// Query: find elements by attribute pattern
46+
const brandColorEls = comp
47+
.getElements()
48+
.filter((el) => el.attributes["data-brand-role"] === "primary");
49+
const brandSecondaryEls = comp
50+
.getElements()
51+
.filter((el) => el.attributes["data-brand-role"] === "secondary");
52+
53+
comp.batch(() => {
54+
for (const el of brandColorEls) {
55+
comp.setStyle(el.id, { color: brandPrimary });
56+
}
57+
for (const el of brandSecondaryEls) {
58+
comp.setStyle(el.id, { color: brandSecondary });
59+
}
60+
});
61+
62+
return comp.serialize();
63+
}
64+
65+
// ── A/B variant agent ─────────────────────────────────────────────────────────
66+
// Produce two HTML variants from one template.
67+
68+
export async function createABVariants(
69+
html: string,
70+
variantB: { headlineId: string; text: string; color: string },
71+
): Promise<{ variantA: string; variantB: string }> {
72+
const compA = await openComposition(html);
73+
const variantAHtml = compA.serialize();
74+
compA.dispose();
75+
76+
const compB = await openComposition(html);
77+
compB.setText(variantB.headlineId, variantB.text);
78+
compB.setStyle(variantB.headlineId, { color: variantB.color });
79+
const variantBHtml = compB.serialize();
80+
compB.dispose();
81+
82+
return { variantA: variantAHtml, variantB: variantBHtml };
83+
}
84+
85+
// ── Asset swap agent ──────────────────────────────────────────────────────────
86+
// F3: setAttribute handles img src, href, alt — the full attribute space.
87+
88+
export async function swapAssets(
89+
html: string,
90+
swaps: Array<{ id: string; src: string; alt?: string }>,
91+
): Promise<string> {
92+
const comp = await openComposition(html);
93+
94+
comp.batch(() => {
95+
for (const swap of swaps) {
96+
comp.setAttribute(swap.id, "src", swap.src);
97+
if (swap.alt !== undefined) {
98+
comp.setAttribute(swap.id, "alt", swap.alt);
99+
}
100+
}
101+
});
102+
103+
return comp.serialize();
104+
}
105+
106+
// ── Batch GSAP animation agent ────────────────────────────────────────────────
107+
// Add staggered entrance animations to all text elements.
108+
109+
export async function addStaggeredEntrance(html: string, staggerDelay = 0.15): Promise<string> {
110+
const comp = await openComposition(html);
111+
112+
const textEls = comp.find({ tag: "div" });
113+
114+
// Phase 3b feature-detect: addGsapTween throws UnsupportedOpError until the
115+
// parser-backed engine lands — skip animation rather than crash the job.
116+
const probeTween = {
117+
method: "from",
118+
position: 0,
119+
duration: 0.5,
120+
ease: "power3.out",
121+
fromProperties: { opacity: 0, y: 30 },
122+
} as const;
123+
const first = textEls[0];
124+
if (
125+
!first ||
126+
!comp.can({ type: "addGsapTween", target: first, id: "preflight", tween: probeTween })
127+
) {
128+
return comp.serialize();
129+
}
130+
131+
comp.batch(() => {
132+
textEls.forEach((id, i) => {
133+
comp.addGsapTween(id, { ...probeTween, position: i * staggerDelay });
134+
});
135+
});
136+
137+
return comp.serialize();
138+
}
139+
140+
// ── Composition metadata normalization ────────────────────────────────────────
141+
142+
export async function normalizeToPortrait(html: string): Promise<string> {
143+
const comp = await openComposition(html);
144+
comp.dispatch({ type: "setCompositionMetadata", width: 1080, height: 1920 });
145+
return comp.serialize();
146+
}
147+
148+
// ── Variable override agent ───────────────────────────────────────────────────
149+
// Apply a brand kit as composition variable overrides.
150+
151+
export async function applyVariableKit(
152+
html: string,
153+
kit: Record<string, string | number | boolean>,
154+
): Promise<string> {
155+
const comp = await openComposition(html);
156+
157+
comp.batch(() => {
158+
for (const [id, value] of Object.entries(kit)) {
159+
comp.setVariableValue(id, value);
160+
}
161+
});
162+
163+
return comp.serialize();
164+
}
165+
166+
// ── Inspection utility ────────────────────────────────────────────────────────
167+
// Agents need to discover what's in a composition before editing.
168+
169+
export async function inspectComposition(html: string): Promise<{
170+
elementCount: number;
171+
textElements: ElementSnapshot[];
172+
imageElements: ElementSnapshot[];
173+
ids: string[];
174+
}> {
175+
const comp = await openComposition(html);
176+
177+
const all = comp.getElements();
178+
const textElements = all.filter((el) => ["div", "p", "h1", "h2", "h3", "span"].includes(el.tag));
179+
const imageElements = all.filter((el) => el.tag === "img");
180+
181+
comp.dispose();
182+
183+
return {
184+
elementCount: all.length,
185+
textElements,
186+
imageElements,
187+
ids: all.map((el) => el.id),
188+
};
189+
}
190+
191+
// ── Timing normalization agent ────────────────────────────────────────────────
192+
193+
export async function normalizeTiming(html: string, totalDuration: number): Promise<string> {
194+
const comp = await openComposition(html);
195+
196+
const timedEls = comp.getElements().filter((el) => el.start !== null && el.duration !== null);
197+
198+
const lastEnd = timedEls.reduce(
199+
(max, el) => Math.max(max, (el.start ?? 0) + (el.duration ?? 0)),
200+
0,
201+
);
202+
if (lastEnd === 0) return comp.serialize();
203+
204+
const scale = totalDuration / lastEnd;
205+
206+
comp.batch(() => {
207+
for (const el of timedEls) {
208+
comp.setTiming(el.id, {
209+
start: Math.round((el.start ?? 0) * scale * 100) / 100,
210+
duration: Math.round((el.duration ?? 0) * scale * 100) / 100,
211+
});
212+
}
213+
comp.dispatch({ type: "setCompositionMetadata", duration: totalDuration });
214+
});
215+
216+
return comp.serialize();
217+
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/**
2+
* Archetype (a) — React app embedding the SDK (T1 standalone)
3+
*
4+
* Shows: openComposition, event subscription, typed methods, selection sugar,
5+
* batch + brand kit, useSyncExternalStore pattern, undo/redo, export.
6+
*
7+
* Note: JSX/React not imported here to keep this file framework-agnostic .ts.
8+
* In a real React app: wrap createEditorSession in useEffect, subscribe with
9+
* useSyncExternalStore (see comment blocks below).
10+
*/
11+
12+
import { openComposition, ORIGIN_APPLY_PATCHES } from "../src/index.js";
13+
import { createMemoryAdapter } from "../src/adapters/memory.js";
14+
import type { Composition, ElementSnapshot } from "../src/index.js";
15+
16+
// ── Session factory ───────────────────────────────────────────────────────────
17+
// Typically called once in useEffect(() => { createEditorSession(html).then(setComp) }, [])
18+
19+
export async function createEditorSession(html: string): Promise<Composition> {
20+
const persist = createMemoryAdapter();
21+
22+
const comp = await openComposition(html, { persist });
23+
24+
// Persist failures surface as events, never fatal exceptions.
25+
comp.on("persist:error", ({ error }) => {
26+
console.error(`Auto-save failed: ${error.message}`);
27+
// In a real app: show a toast notification
28+
});
29+
30+
return comp;
31+
}
32+
33+
// ── useSyncExternalStore integration ─────────────────────────────────────────
34+
// React 18+ pattern:
35+
//
36+
// const selection = useSyncExternalStore(
37+
// (cb) => comp.on('selectionchange', cb),
38+
// () => comp.getSelection(),
39+
// )
40+
//
41+
// Imperative equivalent for non-React consumers:
42+
43+
export function subscribeToSelection(
44+
comp: Composition,
45+
onChange: (ids: string[]) => void,
46+
): () => void {
47+
return comp.on("selectionchange", onChange);
48+
}
49+
50+
// ── Property panel bindings ───────────────────────────────────────────────────
51+
52+
export function applyStyle(comp: Composition, id: string, prop: string, value: string): void {
53+
// F1: explicit target — panel holds the id when rendering the current element
54+
comp.setStyle(id, { [prop]: value });
55+
}
56+
57+
export function applyFontSize(comp: Composition, id: string, px: number): void {
58+
comp.setStyle(id, { fontSize: `${px}px` });
59+
}
60+
61+
export function applyTextContent(comp: Composition, id: string, value: string): void {
62+
comp.setText(id, value);
63+
}
64+
65+
// Selection sugar — resolves getSelection() → explicit ops at call time.
66+
// Equivalent to: ids = comp.getSelection(); comp.setStyle(ids, {...})
67+
export function applyColorToSelection(comp: Composition, color: string): void {
68+
comp.selection().setStyle({ color });
69+
}
70+
71+
// ── Brand kit (batch) ─────────────────────────────────────────────────────────
72+
// One undo entry, one persist write, one change event.
73+
74+
export function applyBrandKit(comp: Composition, kit: Record<string, string>): void {
75+
comp.batch(() => {
76+
for (const [variableId, value] of Object.entries(kit)) {
77+
comp.setVariableValue(variableId, value);
78+
}
79+
});
80+
}
81+
82+
// ── Timeline drag ─────────────────────────────────────────────────────────────
83+
84+
export function onClipDrag(comp: Composition, id: string, start: number, duration: number): void {
85+
comp.setTiming(id, { start, duration });
86+
}
87+
88+
// ── GSAP animation panel ──────────────────────────────────────────────────────
89+
90+
// Phase 3b: GSAP ops throw UnsupportedOpError until the parser-backed engine
91+
// lands — feature-detect with can() and disable the panel control if false.
92+
93+
export function addBounceIn(comp: Composition, targetId: string): string | null {
94+
const tween = {
95+
method: "from",
96+
position: 0,
97+
duration: 0.5,
98+
ease: "bounce.out",
99+
fromProperties: { y: 40, opacity: 0 },
100+
} as const;
101+
if (!comp.can({ type: "addGsapTween", target: targetId, id: "preflight", tween })) return null;
102+
return comp.addGsapTween(targetId, tween);
103+
}
104+
105+
export function updateEase(comp: Composition, animationId: string, ease: string): void {
106+
if (!comp.can({ type: "setGsapTween", animationId, properties: { ease } })) return;
107+
comp.setGsapTween(animationId, { ease });
108+
}
109+
110+
// ── Undo / redo ───────────────────────────────────────────────────────────────
111+
112+
export function undo(comp: Composition): void {
113+
comp.undo();
114+
}
115+
116+
export function redo(comp: Composition): void {
117+
comp.redo();
118+
}
119+
120+
// ── T3 host undo integration (embedded mode) ─────────────────────────────────
121+
// When the SDK is embedded in a host with its own undo timeline:
122+
123+
export type HostHistoryEntry =
124+
| { kind: "sdk"; patches: ReturnType<Composition["getOverrides"]>; inversePatches: unknown[] }
125+
| { kind: "native"; data: unknown };
126+
127+
export function setupHostUndo(
128+
comp: Composition,
129+
pushToHostHistory: (entry: HostHistoryEntry) => void,
130+
): () => void {
131+
return comp.on("patch", ({ patches, inversePatches, origin }) => {
132+
// Origin guard: skip re-emissions from applyPatches to avoid undo loops (F4)
133+
if (origin === ORIGIN_APPLY_PATCHES) return;
134+
135+
pushToHostHistory({
136+
kind: "sdk",
137+
patches: patches as unknown as ReturnType<Composition["getOverrides"]>,
138+
inversePatches: [...inversePatches],
139+
});
140+
});
141+
}
142+
143+
// ── Export ────────────────────────────────────────────────────────────────────
144+
145+
export function exportHtml(comp: Composition): string {
146+
return comp.serialize();
147+
}
148+
149+
// ── Query API usage ───────────────────────────────────────────────────────────
150+
151+
export function findTextElements(comp: Composition): ElementSnapshot[] {
152+
const ids = comp.find({ tag: "div" });
153+
return ids.map((id) => comp.getElement(id)).filter((el): el is ElementSnapshot => el !== null);
154+
}

0 commit comments

Comments
 (0)