Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 217 additions & 0 deletions packages/sdk/examples/headless-agent.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 154 additions & 0 deletions packages/sdk/examples/react-embed.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading