Skip to content

Commit 0d7ea0c

Browse files
committed
feat(sdk): session API, optional history + persist-queue, adapters — Phase 3a complete
1 parent 4bd9dd9 commit 0d7ea0c

11 files changed

Lines changed: 1587 additions & 0 deletions

File tree

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
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+
comp.batch(() => {
115+
textEls.forEach((id, i) => {
116+
comp.addGsapTween(id, {
117+
method: "from",
118+
position: i * staggerDelay,
119+
duration: 0.5,
120+
ease: "power3.out",
121+
fromProperties: { opacity: 0, y: 30 },
122+
});
123+
});
124+
});
125+
126+
return comp.serialize();
127+
}
128+
129+
// ── Composition metadata normalization ────────────────────────────────────────
130+
131+
export async function normalizeToPortrait(html: string): Promise<string> {
132+
const comp = await openComposition(html);
133+
comp.dispatch({ type: "setCompositionMetadata", width: 1080, height: 1920 });
134+
return comp.serialize();
135+
}
136+
137+
// ── Variable override agent ───────────────────────────────────────────────────
138+
// Apply a brand kit as composition variable overrides.
139+
140+
export async function applyVariableKit(
141+
html: string,
142+
kit: Record<string, string | number | boolean>,
143+
): Promise<string> {
144+
const comp = await openComposition(html);
145+
146+
comp.batch(() => {
147+
for (const [id, value] of Object.entries(kit)) {
148+
comp.setVariableValue(id, value);
149+
}
150+
});
151+
152+
return comp.serialize();
153+
}
154+
155+
// ── Inspection utility ────────────────────────────────────────────────────────
156+
// Agents need to discover what's in a composition before editing.
157+
158+
export async function inspectComposition(html: string): Promise<{
159+
elementCount: number;
160+
textElements: ElementSnapshot[];
161+
imageElements: ElementSnapshot[];
162+
ids: string[];
163+
}> {
164+
const comp = await openComposition(html);
165+
166+
const all = comp.getElements();
167+
const textElements = all.filter((el) => ["div", "p", "h1", "h2", "h3", "span"].includes(el.tag));
168+
const imageElements = all.filter((el) => el.tag === "img");
169+
170+
comp.dispose();
171+
172+
return {
173+
elementCount: all.length,
174+
textElements,
175+
imageElements,
176+
ids: all.map((el) => el.id),
177+
};
178+
}
179+
180+
// ── Timing normalization agent ────────────────────────────────────────────────
181+
182+
export async function normalizeTiming(html: string, totalDuration: number): Promise<string> {
183+
const comp = await openComposition(html);
184+
185+
const timedEls = comp.getElements().filter((el) => el.start !== null && el.duration !== null);
186+
187+
const lastEnd = timedEls.reduce(
188+
(max, el) => Math.max(max, (el.start ?? 0) + (el.duration ?? 0)),
189+
0,
190+
);
191+
if (lastEnd === 0) return comp.serialize();
192+
193+
const scale = totalDuration / lastEnd;
194+
195+
comp.batch(() => {
196+
for (const el of timedEls) {
197+
comp.setTiming(el.id, {
198+
start: Math.round((el.start ?? 0) * scale * 100) / 100,
199+
duration: Math.round((el.duration ?? 0) * scale * 100) / 100,
200+
});
201+
}
202+
comp.dispatch({ type: "setCompositionMetadata", duration: totalDuration });
203+
});
204+
205+
return comp.serialize();
206+
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
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+
export function addBounceIn(comp: Composition, targetId: string): string {
91+
return comp.addGsapTween(targetId, {
92+
method: "from",
93+
position: 0,
94+
duration: 0.5,
95+
ease: "bounce.out",
96+
fromProperties: { y: 40, opacity: 0 },
97+
});
98+
}
99+
100+
export function updateEase(comp: Composition, animationId: string, ease: string): void {
101+
comp.setGsapTween(animationId, { ease });
102+
}
103+
104+
// ── Undo / redo ───────────────────────────────────────────────────────────────
105+
106+
export function undo(comp: Composition): void {
107+
comp.undo();
108+
}
109+
110+
export function redo(comp: Composition): void {
111+
comp.redo();
112+
}
113+
114+
// ── T3 host undo integration (embedded mode) ─────────────────────────────────
115+
// When the SDK is embedded in a host with its own undo timeline:
116+
117+
export type HostHistoryEntry =
118+
| { kind: "sdk"; patches: ReturnType<Composition["getOverrides"]>; inversePatches: unknown[] }
119+
| { kind: "native"; data: unknown };
120+
121+
export function setupHostUndo(
122+
comp: Composition,
123+
pushToHostHistory: (entry: HostHistoryEntry) => void,
124+
): () => void {
125+
return comp.on("patch", ({ patches, inversePatches, origin }) => {
126+
// Origin guard: skip re-emissions from applyPatches to avoid undo loops (F4)
127+
if (origin === ORIGIN_APPLY_PATCHES) return;
128+
129+
pushToHostHistory({
130+
kind: "sdk",
131+
patches: patches as unknown as ReturnType<Composition["getOverrides"]>,
132+
inversePatches: [...inversePatches],
133+
});
134+
});
135+
}
136+
137+
// ── Export ────────────────────────────────────────────────────────────────────
138+
139+
export function exportHtml(comp: Composition): string {
140+
return comp.serialize();
141+
}
142+
143+
// ── Query API usage ───────────────────────────────────────────────────────────
144+
145+
export function findTextElements(comp: Composition): ElementSnapshot[] {
146+
const ids = comp.find({ tag: "div" });
147+
return ids.map((id) => comp.getElement(id)).filter((el): el is ElementSnapshot => el !== null);
148+
}

0 commit comments

Comments
 (0)