Specifies the V1 animation system end-to-end. Authored after two Deep Research passes (preserved as ANIMATION_V1_RESEARCH.md and ANIMATION_V1_DESIGN_DECISIONS.md for provenance). ANIMATION_V1.md covers: - Hard constraints: Chrome Heavy Ad Intervention (4MB / 15s burst / 60s total CPU), composite-only animation, 150KB initial-load cap, GSAP via s0.2mdn.net CDN, free-tier only. - Custom JSON schema (not Lottie) — block-based timeline, absolute start times, preset references only, no inline keyframes. Designed for AI authoring and human-readable diffs. - 25-preset library across entrance / exit / emphasis / typography / mask / list categories. Each preset specifies start state, end state, default ease, default duration, and split/mask requirements. - 9-category easing matrix using GSAP stock eases; bounce, slow, rough, and circ excluded from the V1 surface. - Mask system: mask is a property on the masked layer (not a standalone layer). clip-path mandatory over interactive elements to prevent ghost-click failures. Konva ↔ HTML parity table. - Per-character animation: SplitType at render time, Dropflow at spec time, automated aria-label / aria-hidden contract, 150-node ceiling enforced by QA gate. - Animated bounding-box math: discrete sampling at 30 fps, unionBoundingBox() called from asset selection, render worker, and QA gate. Adds required_source_size to ResolvedLayer. - 12 QA gates (G1-G12) covering schema, performance, asset, accessibility, and parity. ARCHITECTURE.md updates: - Forward-notes section at the top pointing to ANIMATION_V1.md and RESOLVED_FEED.md, matching the existing Part 7 forward-note style. - Inline forward note in the Part 3 animation stack block. - Old content preserved as historical record. Decisions baked in (resolved during draft): - Loops are global (max 3), not per-block. Per-block loops invite nested-infinite-loop bugs in AI-generated specs. - Block triggers are time-anchored only. Event/interaction triggers wait for V2 rich media. - blur_in and shake_horizontal dropped from the 27-preset research list. Blur is a video pattern; shake reads as a rendering error. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
67 KiB
# Architecture Document: Agentic HTML5 Banner Production Platform
**Status:** Pre-build planning reference
**Purpose:** Claude Code architecture guide — read before writing any code
**Version:** 1.1 (includes Figma V2 integration architecture)
---
## Forward Notes (May 2026)
Two V1 design documents have been authored since this architecture was
written and should be read alongside it. Where the documents conflict,
the newer documents win.
- **`RESOLVED_FEED.md`** reshapes the copy-override portion of Part 7.
Copy edits flow through a sparse per-product-per-size feed upstream
of generation, not through patches applied downstream. See the
inline forward note in Part 7 for the specific scope.
- **`ANIMATION_V1.md`** supersedes the animation discussion in Parts 3,
4, and 6 of this document. It specifies the JSON schema, the 25-preset
library, the easing matrix, the mask system, the per-character
animation contract, the `unionBoundingBox` math for animated asset
sizing, and the 12 QA gates that enforce all of the above. The
`animation_presets: AnimationPreset[]` field on Template, the
`TimelineSpec` on ArtboardSpec, and the `animation_max_duration_ms`
/ `animation_max_loops` fields on AdServerProfile in Part 4 are
replaced by the schema in `ANIMATION_V1.md` §2. The
`animation_rationale` field on `BannerSpec.ai_reasoning` is retained.
---
## Part 1: Research Summary — Key Findings
### What the Research Confirmed
**Canvas rendering:** Konva.js with react-konva is the correct choice. DOM-based canvas has unacceptable reflow performance. WebGL is engineering overkill for banner layer counts. Konva's dual-canvas architecture (visible scene + hidden hit-graph) gives precise event delegation without per-frame intersection math.
**The text group problem:** This is the defining technical challenge. The research confirms what was suspected — no existing tool solves it cleanly. The correct approach is a WebAssembly-compiled headless layout engine (Dropflow) running text measurement completely outside the browser's rendering path. This is the architectural decision that determines whether the tool wins or loses against Celtra.
**Logo lockup variants:** Treat as smart asset variant groups with semantic metadata. The AI does not "choose" an asset creatively — it executes a deterministic metadata query. This distinction is critical for brand safety.
**AI orchestration:** Monolithic prompting fails in production. The pipeline must be decomposed into discrete agents: Extract, Generate, Route (deterministic, no AI), Execute. Each step has a strict typed interface. Failures in one step cannot corrupt others.
**Version control:** Event sourcing with delta compression (jsondiffpatch). Never destructive overwrites. Human override patches are a separate layer that survives regeneration. Conflict detection is a UI requirement, not an edge case.
**HTML5 compliance:** GSAP (GreenSock) for animation — it is whitelisted by major ad servers and excluded from weight calculations. This is not a preference, it is a production requirement. Each ad server (CM360, Amazon DSP, Xandr, Trade Desk, Adform) requires a different click-tag implementation — this must be profile-driven.
**Rendering:** Playwright over Puppeteer for concurrent renders. Fonts must be baked into Docker containers at build time. BullMQ + Redis for the render queue with hardware-matched concurrency limits.
---
## Part 2: System Architecture
### Service Map
┌─────────────────────────────────────────────────────────────────┐ │ CLIENT (Next.js) │ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │ │ │ Template │ │ Review │ │ Asset Library │ │ │ │ Builder │ │ Interface │ │ Manager │ │ │ │ (Konva.js) │ │ (Grid+Edit) │ │ (Upload+Tag+Crop) │ │ │ └──────────────┘ └──────────────┘ └──────────────────────┘ │ └────────────────────────────┬────────────────────────────────────┘ │ REST / tRPC ┌────────────────────────────▼────────────────────────────────────┐ │ API SERVER (Node.js / TypeScript) │ │ │ │ ┌────────────┐ ┌────────────┐ ┌──────────┐ ┌───────────────┐ │ │ │ Template │ │ Asset │ │ Brief/ │ │ Version │ │ │ │ Service │ │ Service │ │ Feed │ │ Service │ │ │ │ │ │ │ │ Service │ │ │ │ │ └────────────┘ └────────────┘ └──────────┘ └───────────────┘ │ │ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ AI Orchestration Service │ │ │ │ [Extract Agent] → [Generate Agent] → [Route] → [Spec] │ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ │ ┌────────────┐ ┌────────────┐ ┌──────────┐ ┌───────────────┐ │ │ │ QA │ │ Approval │ │ Export │ │ Trafficking │ │ │ │ Service │ │ Service │ │ Service │ │ Sheet Gen │ │ │ └────────────┘ └────────────┘ └──────────┘ └───────────────┘ │ └────────────────────────────┬────────────────────────────────────┘ │ ┌──────────────┼──────────────┐ │ │ │ ┌─────────────▼──┐ ┌────────▼────┐ ┌─────▼──────────────────┐ │ PostgreSQL │ │ Redis │ │ S3 / Asset Storage │ │ + JSONB │ │ (BullMQ) │ │ + CloudFront CDN │ └────────────────┘ └──────┬──────┘ └────────────────────────┘ │ ┌────────────▼────────────────┐ │ Render Worker Pool │ │ (Playwright in Docker) │ │ Font-complete containers │ │ BullMQ concurrency-gated │ └─────────────────────────────┘
### Service Ownership — Hard Boundaries
Each service owns its domain entirely. No service reaches into another's data store directly.
| Service | Owns | Never Touches |
|---|---|---|
| Template Service | Template specs, constraint definitions, artboard configs | Asset binaries, campaign data |
| Asset Service | Binary storage, metadata, crop definitions, rights | Template structure, copy |
| Brief/Feed Service | Campaign intake, feed validation, dead letter queue | Rendering, AI calls |
| AI Orchestration | Claude API calls, prompt templates, spec assembly | Database writes (returns spec only) |
| Version Service | Snapshots, delta patches, conflict detection | Rendering, export |
| Render Service | Playwright execution, image encoding, HTML5 compilation | AI, database |
| QA Service | Automated gate checks, compliance validation | Rendering, AI |
| Export Service | ZIP assembly, ad server packaging, format conversion | Template editing |
---
## Part 3: Technology Stack — Decisions and Rationale
### Locked Decisions (Do Not Revisit Without Strong Cause)
```typescript
const STACK = {
frontend: {
framework: 'Next.js 14 (App Router)',
// SSR for review UI, API routes for lightweight endpoints
state: 'Zustand',
// Lightweight, maps cleanly to the banner spec JSON tree
// Avoids Redux boilerplate for what is essentially a document editor
canvas: 'react-konva (Konva.js)',
// Dual-canvas architecture (scene + hit-graph)
// React-declarative state maps directly to canvas nodes
// NOT Fabric.js (memory issues at scale)
// NOT WebGL (engineering overhead without proportional benefit)
layout_engine: 'Dropflow (WebAssembly)',
// Headless text measurement outside browser rendering path
// HarfBuzz integration for accurate line-breaking
// Same engine runs in browser AND in Node.js render workers
// This is the key to text consistency across environments
},
backend: {
runtime: 'Node.js with TypeScript',
// Shared types between client and server (monorepo)
// Native Playwright support
// Claude SDK available
api: 'tRPC',
// End-to-end type safety from DB to client
// Eliminates entire class of interface mismatch bugs
},
database: {
primary: 'PostgreSQL 16',
jsonb: true,
// JSONB for banner specs, template configs, version deltas
// Relational structure for users, campaigns, approvals, rights
versioning: 'jsondiffpatch',
// Delta compression for version history
// Conflict detection algorithm built in
// Event sourcing pattern: master + sequential deltas
},
ai: {
provider: 'Anthropic Claude API',
models: {
orchestration: 'claude-opus-4', // Complex reasoning, asset selection
generation: 'claude-sonnet-4', // Copy generation (cost/quality balance)
validation: 'claude-haiku-4', // Output validation, QA checks (fast/cheap)
},
pattern: 'multi-step decoupled agents',
// NOT monolithic prompting
// Each agent has typed input and typed output schema
// Failures in one step cannot contaminate others
},
rendering: {
engine: 'Playwright',
// NOT Puppeteer — Playwright supports isolated browser contexts
// in a single browser instance (critical for concurrency)
containerization: 'Docker',
// Fonts baked into image at build time
// Sub-pixel rendering disabled for deterministic output
// Same container spec across all environments
queue: 'BullMQ + Redis',
// Concurrency limited to available hardware cores
// Distributed locking prevents job duplication on worker crash
// Dead letter queue for failed renders
},
animation: {
library: 'GSAP (GreenSock)',
// MANDATORY — not a preference
// Whitelisted by major ad servers (not counted in weight budget)
// Guarantees frame synchronization across browser environments
// API mirrors the timeline data model in the spec
//
// Forward note (May 2026): The animation system is specified in
// detail in ANIMATION_V1.md — JSON schema, 25-preset library,
// easing matrix, mask system, per-character contract, bounding-box
// math, and QA gates. GSAP loads from s0.2mdn.net (CDN-whitelisted,
// weight-exempt) as gsap_3.9.1_min.js. SplitText (Club GreenSock)
// is replaced by the MIT-licensed SplitType library for
// per-character text animation.
},
assets: {
storage: 'S3 + CloudFront',
processing: 'Sharp',
// Metadata stripping, format conversion, normalization on ingest
// Output: JPEG/WebP/PNG optimized per target
},
monorepo: 'Turborepo',
// Shared TypeScript types across all packages
// Critical: the BannerSpec type must be identical in client,
// API, and render worker
auth: 'Clerk',
// Multi-user from day one
// Role-based: Designer, Producer, Creative Director, Trafficker
deployment: {
frontend: 'Vercel',
api: 'Railway or AWS ECS',
render_workers: 'AWS ECS (dedicated — Playwright cannot run on serverless)',
// This is a hard constraint: Playwright requires persistent processes
// Standard serverless cannot run it reliably
database: 'Supabase (managed Postgres) or AWS RDS',
redis: 'Upstash Redis or ElastiCache',
assets: 'AWS S3 + CloudFront',
}
}
Part 4: Data Models
Core TypeScript Interfaces
These types are the single source of truth. Every service, every agent, every UI component uses these exact shapes.
// ─── Template System ──────────────────────────────────────────────────────────
interface Template {
id: string;
version: number;
name: string;
client_id: string;
brand_profile_id: string;
created_by: string;
created_at: string;
updated_at: string;
status: 'draft' | 'approved' | 'archived';
artboards: Artboard[];
global_constraints: GlobalConstraints;
variant_groups: VariantGroup[];
animation_presets: AnimationPreset[];
// ── Figma V2 — optional, null until Figma integration is connected ──────────
figma_source?: {
file_key: string; // Figma's stable file identifier
file_name: string; // human-readable, for display only
last_synced: string; // ISO timestamp of last successful sync
sync_status: 'in_sync' | 'pending_review' | 'conflicts' | 'manual';
// 'manual' = template was built in banner tool, no Figma connection
};
}
interface Artboard {
id: string; // e.g. "300x250", "728x90"
label: string;
width: number;
height: number;
master: boolean; // one artboard is the design master
layers: Layer[];
safe_zones: SafeZone[];
background: BackgroundSpec;
}
interface GlobalConstraints {
max_weight_kb_initial: number; // typically 150
max_weight_kb_total: number; // typically 5000
max_animation_duration_ms: number; // typically 15000
max_animation_loops: number; // typically 3
click_destinations: ClickDestination[];
banned_words: string[];
required_elements: string[]; // ['logo', 'disclaimer'] etc.
}
// ─── Layer Types ──────────────────────────────────────────────────────────────
type Layer = TextLayer | SmartAssetLayer | ShapeLayer | GroupLayer;
interface BaseLayer {
id: string;
name: string;
type: 'text' | 'smart_asset' | 'shape' | 'group';
x: number;
y: number;
width: number;
height: number;
z_index: number;
visible: boolean;
locked: boolean;
opacity: number;
rotation: number;
anchors: AnchorConstraints;
// ── Figma V2 — optional, null until Figma integration is connected ──────────
figma_node_id?: string; // Figma's stable node identifier (survives renames)
figma_file_key?: string; // which Figma file this layer originated from
figma_last_synced?: string; // ISO timestamp — last time this layer was synced
}
interface TextLayer extends BaseLayer {
type: 'text';
content_field: string; // maps to data feed field or brief field
typography: TypographySpec;
behavior: TextBehaviorRules; // THE CRITICAL SPEC — see Part 5
character_constraints: CharacterConstraints;
copy_bound: boolean; // if true, AI populates this field
click_destination?: string; // key into click_destinations
}
interface TextBehaviorRules {
auto_resize: boolean;
min_font_size: number;
max_font_size: number;
expansion_direction: 'down' | 'up' | 'both' | 'none';
overflow_behavior: 'shrink' | 'clip' | 'warn' | 'truncate';
push_siblings: PushSiblingRule[]; // WHAT MOVES WHEN THIS EXPANDS
group_id?: string; // if part of a text group
padding: { top: number; right: number; bottom: number; left: number };
}
interface PushSiblingRule {
layer_id: string;
direction: 'down' | 'up';
maintain_gap: number; // px gap to preserve
max_push: number; // px — hard limit
}
interface CharacterConstraints {
per_artboard: { [artboard_id: string]: number };
warning_threshold: number; // 0-1, show warning at this % of limit
hard_enforce: boolean; // block generation if exceeded
}
interface SmartAssetLayer extends BaseLayer {
type: 'smart_asset';
variant_group_id: string; // references VariantGroup
selected_variant_id: string; // which variant is currently active
fit_mode: 'fill' | 'fit' | 'crop' | 'exact';
focal_point?: { x: number; y: number }; // 0-1 normalized
}
interface GroupLayer extends BaseLayer {
type: 'group';
children: Layer[];
group_behavior: 'fixed' | 'flex_vertical' | 'flex_horizontal';
// flex groups participate in the headless layout engine
}
// ─── Smart Assets and Variant Groups ─────────────────────────────────────────
interface VariantGroup {
id: string;
name: string; // e.g. "Logo Lockup"
slot_type: 'logo' | 'badge' | 'product' | 'background' | 'custom';
variants: AssetVariant[];
selection_rules: SelectionRule[]; // deterministic rules before AI
fallback_variant_id: string;
}
interface AssetVariant {
id: string;
name: string; // e.g. "Standard Horizontal"
asset_id: string; // references Asset in asset library
width_override?: number;
height_override?: number;
position_override?: { x: number; y: number };
metadata: VariantMetadata;
}
interface VariantMetadata {
regions: string[]; // ['US', 'CA'] or ['*'] for all
markets: string[]; // ['retail', 'brand', 'co-op']
languages: string[];
campaign_types: string[]; // ['sale', 'launch', 'always_on']
promotions: string[]; // ['member_exclusive', 'clearance']
partner_brands: string[]; // for co-branded lockups
is_default: boolean;
priority: number; // higher = preferred when multiple match
}
interface SelectionRule {
condition: string; // e.g. "brief.region === 'UK'"
select_variant_id: string;
}
// ─── Asset Library ────────────────────────────────────────────────────────────
interface Asset {
id: string;
filename: string;
storage_url: string;
cdn_url: string;
mime_type: string;
file_size_bytes: number;
width_px: number;
height_px: number;
uploaded_by: string;
uploaded_at: string;
rights: AssetRights;
metadata: AssetMetadata;
approved_crops: { [template_artboard_key: string]: ApprovedCrop };
processing_status: 'pending' | 'ready' | 'failed';
optimized_variants: OptimizedVariant[];
}
interface AssetRights {
type: 'owned' | 'licensed' | 'royalty_free';
expiry_date?: string;
approved_markets: string[];
excluded_markets: string[];
approved_channels: string[]; // ['display', 'social', 'ooh']
notes?: string;
}
interface AssetMetadata {
subject: string;
subject_type: 'product' | 'lifestyle' | 'model' | 'abstract' | 'logo_lockup' | 'badge';
product_skus: string[];
categories: string[];
dominant_colors: string[];
background_type: 'white' | 'transparent' | 'lifestyle' | 'gradient' | 'color';
focal_point: { x: number; y: number };
mood_tags: string[];
suitable_campaign_types: string[];
language_safe: boolean;
ai_description?: string;
ai_focal_point?: { x: number; y: number };
ai_mood_tags?: string[];
human_verified: boolean;
}
interface ApprovedCrop {
x: number; y: number;
width: number; height: number;
approved_by: string;
approved_at: string;
focal_point: { x: number; y: number };
}
// ─── Campaign and Brief ───────────────────────────────────────────────────────
type Campaign = {
id: string;
name: string;
client_id: string;
template_id: string;
template_version: number;
status: CampaignStatus;
mode: 'feed' | 'brief';
brief: Brief;
versions: CampaignVersion[];
active_version_id: string;
approved_version_id?: string;
trafficked_at?: string;
created_by: string;
created_at: string;
}
type CampaignStatus =
| 'draft'
| 'brief_validated'
| 'generating'
| 'qa_check'
| 'in_review'
| 'revision_requested'
| 'approved'
| 'exported'
| 'trafficked'
| 'archived';
interface Brief {
feed_url?: string;
feed_format?: 'json' | 'xml' | 'csv';
feed_data?: FeedRecord[];
product?: string;
campaign_type?: string;
offer?: string;
offer_end_date?: string;
audience?: string;
region: string;
language: string;
tone?: string;
brand_voice_profile: string;
copy_constraints: CopyConstraints;
locked_copy?: { [field_name: string]: string };
ad_server_profile: AdServerProfile;
}
interface CopyConstraints {
banned_words: string[];
required_elements: string[];
approved_ctas: string[];
allow_exclamation_marks: boolean;
allow_superlatives: boolean;
disclaimer_required: boolean;
disclaimer_text?: string;
}
// ─── Version Control ──────────────────────────────────────────────────────────
interface CampaignVersion {
id: string;
campaign_id: string;
version_number: number;
created_at: string;
created_by: string;
type: 'generated' | 'human_edited' | 'approved' | 'rolled_back' | 'figma_sync';
// figma_sync: template updated from Figma, changes applied to this campaign version
note?: string;
is_full_snapshot: boolean;
spec?: BannerSpec;
delta?: JsonPatch;
human_overrides: HumanOverride[];
qa_results?: QAResult[];
}
interface HumanOverride {
id: string;
field_path: string; // e.g. "artboards[0].layers[2].content"
original_generated_value: any;
human_value: any;
overridden_by: string;
overridden_at: string;
locked: boolean;
lock_reason?: string; // "client specified this exact line"
note?: string;
}
interface ConflictReport {
version_id: string;
conflicts: Conflict[];
}
interface Conflict {
field_path: string;
human_override: HumanOverride;
new_generated_value: any;
resolution?: 'keep_human' | 'take_generated';
resolved_at?: string;
resolved_by?: string;
}
// ─── The Banner Spec — The Central Contract ───────────────────────────────────
interface BannerSpec {
template_id: string;
template_version: number;
campaign_id: string;
version_id: string;
copy_variant: string;
generated_at: string;
ai_reasoning: {
asset_selection: string;
copy_rationale: string;
variant_selection: string;
animation_rationale: string;
};
artboards: ArtboardSpec[];
ad_server_profile: AdServerProfile;
click_destinations: ClickDestination[];
}
interface ArtboardSpec {
artboard_id: string;
width: number;
height: number;
background: string;
layers: ResolvedLayer[];
timeline: TimelineSpec;
estimated_weight_kb?: number;
qa_status?: 'pending' | 'pass' | 'fail' | 'warning';
}
interface ResolvedLayer {
layer_id: string;
type: 'text' | 'smart_asset' | 'shape' | 'group';
content?: string;
character_count?: number;
within_character_limit?: boolean;
asset_id?: string;
variant_id?: string;
crop?: ApprovedCrop;
computed_x: number;
computed_y: number;
computed_width: number;
computed_height: number;
computed_font_size?: number;
layout_log?: {
original_height: number;
computed_height: number;
siblings_pushed: { layer_id: string; pushed_by_px: number }[];
font_size_reduced: boolean;
overflow_triggered: boolean;
};
}
// ─── Ad Server Profiles ───────────────────────────────────────────────────────
interface AdServerProfile {
id: string;
name: string;
click_tag_implementation: 'iab_standard' | 'cm360' | 'amazon_dsp' | 'xandr' | 'ttd' | 'adform';
requires_sdk: boolean;
sdk_url?: string;
weight_limit_initial_kb: number;
weight_limit_total_kb: number;
animation_max_duration_ms: number;
animation_max_loops: number;
requires_backup_png: boolean;
requires_meta_ad_size: boolean;
flat_zip_structure: boolean;
custom_metadata?: Record<string, string>;
}
// ─── QA Gates ────────────────────────────────────────────────────────────────
interface QAResult {
check_id: string;
check_name: string;
status: 'pass' | 'fail' | 'warning';
severity: 'blocking' | 'advisory';
detail: string;
value?: number | string;
limit?: number | string;
artboard_id?: string;
}
// ─── Export ───────────────────────────────────────────────────────────────────
interface ExportPackage {
campaign_id: string;
version_id: string;
generated_at: string;
format: 'image_zip' | 'html5_zip' | 'combined';
ad_server_profile: string;
artboards: ExportedArtboard[];
trafficking_sheet_url: string;
}
interface TraffickingSheetRow {
campaign_name: string;
artboard_size: string;
copy_variant: string;
placement_type: string;
click_url: string;
creative_id: string;
file_name: string;
file_weight_kb: number;
animation_duration_s: number;
loop_count: number;
format: string;
ad_server: string;
notes: string;
}
Part 5: The Text Group System — Detailed Specification
This is the most critical implementation area. Build this before anything else. If this works correctly, the tool wins.
How It Works
Designer sets up template:
└── Creates a "Text Group" container (GroupLayer with group_behavior: 'flex_vertical')
└── Adds HeadlineLayer inside group
└── Adds SubheadlineLayer inside group
└── Sets push_siblings on HeadlineLayer: [{layer_id: 'subheadline', direction: 'down', maintain_gap: 8px}]
└── Sets push_siblings on SubheadlineLayer: [{layer_id: 'cta_button', direction: 'down', maintain_gap: 16px}]
└── Sets expansion_direction: 'down' on the group
└── Sets max_push limits on CTA button (can only be pushed 40px before overflow warning)
└── Character limit simulator shows ghost box of max content volume
AI populates copy:
└── Headline: "Up to 40% Off All Performance Footwear This Weekend"
└── (This is 54 chars — longer than the template's default)
Headless layout engine fires:
1. Pass headline string + typography to Dropflow (WASM)
2. Dropflow returns: "this text needs 64px height at this width, not 40px"
3. System evaluates: 64 > 40, expansion needed
4. Check auto_resize: true → try shrinking font first
5. Recursive: reduce font 1px at a time until text fits in 40px OR hits min_font_size
6. If min_font_size hit before fit: expand container height to 64px
7. Calculate delta: expanded by 24px
8. Apply push_siblings: subheadline Y += 24, maintain 8px gap
9. Subheadline now moved, recalculate: does CTA still fit?
10. CTA push_siblings: CTA Y += 24
11. Check: new CTA Y <= max_push limit?
12. If yes: layout resolves cleanly
13. If no: overflow_behavior fires (warn, truncate, or clip)
14. Log all decisions in layout_log for debugging
15. Push computed coordinates to Zustand → Konva re-renders
Character Limit Simulator (Template Builder UI)
During template design:
└── Input field: "Type max expected copy here"
└── As designer types, Dropflow calculates live bounding volume
└── Ghost overlay shows: this is your maximum content box
└── Red highlight when content would exceed safe area
└── Per-artboard: the 300x250 headline might cap at 35 chars
but the 728x90 might allow 65 chars (different template slot)
└── Designer sees this visually — not in a settings panel
Forward note (May 2026): The vertical slice introduced a
TypeSystemconcept — a designer-authored, role-based type system on the Template, with per-size values derived by formula and a constraint signal that flows back to the Generate agent when overflow hits the legibility floor. See SLICE_DEVIATIONS.md entry #10. This concept should be folded into V1 Part 5; the slice implementation is the reference.
Part 6: AI Orchestration Pipeline — Detailed Specification
The Four-Agent Architecture
Input: Brief or Feed Record
│
▼
┌──────────────────────────────────────┐
│ AGENT 1: Context Extraction │
│ Model: claude-haiku │
│ Input: Raw brief or feed record │
│ Output: Typed ExtractedContext │
│ Fails if: mandatory fields missing │
│ On fail: HALT, prompt user │
└──────────────┬───────────────────────┘
│ ExtractedContext (typed)
▼
┌──────────────────────────────────────┐
│ AGENT 2: Copy Generation │
│ Model: claude-sonnet │
│ Input: ExtractedContext + │
│ BrandVoiceProfile + │
│ CharacterConstraints + │
│ LockedCopy (bypass fields) │
│ Output: GeneratedCopy (typed) │
│ Validated: char counts, banned │
│ words, required elements │
│ On fail: retry once, then flag │
└──────────────┬───────────────────────┘
│ GeneratedCopy (typed)
▼
┌──────────────────────────────────────┐
│ ROUTING NODE (No AI) │
│ Deterministic asset selection │
│ 1. Apply SelectionRules first │
│ 2. If no rule matches, build │
│ metadata query from context │
│ 3. Execute database query │
│ 4. Score results, return ranked │
│ 5. If no match: HALT, flag │
│ Output: SelectedAssets (typed) │
└──────────────┬───────────────────────┘
│ SelectedAssets (typed)
▼
┌──────────────────────────────────────┐
│ AGENT 3: Spec Assembly │
│ Model: claude-haiku (light) │
│ Input: ExtractedContext + │
│ GeneratedCopy + │
│ SelectedAssets + │
│ AnimationPresets │
│ Output: BannerSpec (complete) │
│ This agent selects animation │
│ preset and writes AI reasoning log │
└──────────────┬───────────────────────┘
│ BannerSpec
▼
┌──────────────────────────────────────┐
│ PROGRAMMATIC VALIDATION │
│ No AI. Pure code. │
│ - Schema validation │
│ - Character count verification │
│ - Asset rights check │
│ - Weight estimation │
│ - Required element check │
│ On fail: return to Agent 2 once │
│ On second fail: flag for human │
└──────────────┬───────────────────────┘
│ Validated BannerSpec
▼
Version Service
(commit as generated snapshot)
│
▼
Render Queue
Prompt Strategy — Do Not Embed in Application Code
All prompt templates live in /prompts/ directory, version-controlled separately. They are loaded by the orchestration service at runtime, not hardcoded.
Each prompt file contains:
- System prompt
- User message template with typed variable slots
- Output schema (JSON Schema format)
- Example valid outputs (few-shot)
- Example invalid outputs with explanations
When a prompt is updated, the version is logged. This allows A/B testing of prompt versions and rollback if outputs degrade.
Part 7: Version Control Implementation
Forward note (May 2026): A V1 architecture proposal — the Resolved Creative Feed — significantly reshapes the copy-override portion of this Part. The override-patch model below remains correct for non-copy properties (visual, layout, animation, asset selection), but copy edits should be handled via the resolved feed model described in
RESOLVED_FEED.md. The conflict-resolution layer for copy collapses because edits write to feed cells upstream of generation rather than to patches applied downstream of it. SeeRESOLVED_FEED.mdfor the full proposal; this Part is preserved as the non-copy override system.
Database Schema
-- Campaign table
CREATE TABLE campaigns (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
client_id UUID NOT NULL,
template_id UUID NOT NULL,
template_version INTEGER NOT NULL,
status campaign_status NOT NULL DEFAULT 'draft',
mode campaign_mode NOT NULL,
brief JSONB NOT NULL,
active_version_id UUID,
approved_version_id UUID,
created_by UUID NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Version table — event sourced
CREATE TABLE campaign_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
campaign_id UUID NOT NULL REFERENCES campaigns(id),
version_number INTEGER NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
created_by TEXT NOT NULL, -- 'ai' or user_id
type version_type NOT NULL,
note TEXT,
is_full_snapshot BOOLEAN NOT NULL DEFAULT FALSE,
spec JSONB, -- populated for generated versions
delta JSONB, -- populated for human-edit versions
qa_results JSONB,
UNIQUE(campaign_id, version_number)
);
-- Human overrides — separate table, survive regeneration
CREATE TABLE human_overrides (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
campaign_id UUID NOT NULL REFERENCES campaigns(id),
field_path TEXT NOT NULL,
original_generated_value JSONB,
human_value JSONB NOT NULL,
overridden_by UUID NOT NULL,
overridden_at TIMESTAMPTZ DEFAULT NOW(),
locked BOOLEAN DEFAULT FALSE,
lock_reason TEXT,
note TEXT,
superseded_at TIMESTAMPTZ,
superseded_by UUID
);
-- Conflicts table
CREATE TABLE version_conflicts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
campaign_id UUID NOT NULL,
version_id UUID NOT NULL,
override_id UUID NOT NULL REFERENCES human_overrides(id),
field_path TEXT NOT NULL,
human_value JSONB NOT NULL,
new_generated_value JSONB NOT NULL,
resolution conflict_resolution,
resolved_at TIMESTAMPTZ,
resolved_by UUID
);
-- Figma sync log
CREATE TABLE figma_sync_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
template_id UUID NOT NULL REFERENCES templates(id),
figma_file_key TEXT NOT NULL,
synced_at TIMESTAMPTZ DEFAULT NOW(),
triggered_by TEXT NOT NULL, -- 'webhook' | 'manual'
changes_detected JSONB,
changes_auto_applied JSONB,
changes_pending_review JSONB,
conflicts JSONB,
status TEXT NOT NULL -- 'clean' | 'pending_review' | 'conflicts'
);
-- Figma property conflicts
CREATE TABLE figma_conflicts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sync_event_id UUID NOT NULL REFERENCES figma_sync_events(id),
template_id UUID NOT NULL,
layer_id TEXT NOT NULL,
figma_node_id TEXT NOT NULL,
property_path TEXT NOT NULL,
figma_value JSONB NOT NULL,
banner_tool_value JSONB NOT NULL,
resolution TEXT, -- 'take_figma' | 'keep_banner_tool' | 'pending'
resolved_at TIMESTAMPTZ,
resolved_by UUID,
preference_saved BOOLEAN DEFAULT FALSE
);
-- Standing resolution preferences
CREATE TABLE figma_resolution_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
template_id UUID NOT NULL REFERENCES templates(id),
property_path TEXT NOT NULL,
preferred_source TEXT NOT NULL, -- 'figma' | 'banner_tool'
set_by UUID NOT NULL,
set_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes critical for performance
CREATE INDEX idx_versions_campaign ON campaign_versions(campaign_id, version_number DESC);
CREATE INDEX idx_overrides_campaign ON human_overrides(campaign_id) WHERE superseded_at IS NULL;
CREATE INDEX idx_conflicts_unresolved ON version_conflicts(campaign_id) WHERE resolution IS NULL;
CREATE INDEX idx_figma_conflicts_pending ON figma_conflicts(template_id) WHERE resolution = 'pending';
CREATE INDEX idx_figma_sync_template ON figma_sync_events(template_id, synced_at DESC);
Override Preservation Algorithm
async function applyOverridesAfterRegeneration(
campaignId: string,
newGeneratedSpec: BannerSpec
): Promise<{ spec: BannerSpec; conflicts: Conflict[] }> {
// 1. Fetch all active (non-superseded) human overrides
const overrides = await db.humanOverrides.findActive(campaignId);
const conflicts: Conflict[] = [];
let mergedSpec = deepClone(newGeneratedSpec);
for (const override of overrides) {
// 2. Read the current generated value at this field path
const generatedValue = getByPath(mergedSpec, override.field_path);
// 3. Check if generated value differs from what human overrode
if (deepEqual(generatedValue, override.original_generated_value)) {
// 3a. Generated same as before — safe to apply override
setByPath(mergedSpec, override.field_path, override.human_value);
} else if (override.locked) {
// 3b. Field is locked — human value wins unconditionally
setByPath(mergedSpec, override.field_path, override.human_value);
} else {
// 3c. Conflict — AI changed something human had overridden
// Do NOT apply override — flag for human resolution
conflicts.push({
field_path: override.field_path,
human_override: override,
new_generated_value: generatedValue,
resolution: undefined
});
}
}
return { spec: mergedSpec, conflicts };
}
Part 8: HTML5 Export System
Ad Server Profile Configs
const AD_SERVER_PROFILES: Record<string, AdServerProfile> = {
iab_standard: {
id: 'iab_standard',
name: 'IAB Standard',
click_tag_implementation: 'iab_standard',
requires_sdk: false,
weight_limit_initial_kb: 150,
weight_limit_total_kb: 5000,
animation_max_duration_ms: 15000,
animation_max_loops: 3,
requires_backup_png: true,
requires_meta_ad_size: true,
flat_zip_structure: true,
},
cm360: {
id: 'cm360',
name: 'Google Campaign Manager 360 / DV360',
click_tag_implementation: 'cm360',
requires_sdk: false,
// CM360 uses: var clickTag = ""; (declared, overridden by server)
// window.open(clickTag, '_blank') on click
weight_limit_initial_kb: 150,
weight_limit_total_kb: 5000,
animation_max_duration_ms: 15000,
animation_max_loops: 3,
requires_backup_png: true,
requires_meta_ad_size: true,
flat_zip_structure: true,
},
amazon_dsp: {
id: 'amazon_dsp',
name: 'Amazon DSP',
click_tag_implementation: 'amazon_dsp',
requires_sdk: true,
sdk_url: 'https://c.amazon-adsystem.com/aax2/apstag.js',
weight_limit_initial_kb: 100,
weight_limit_total_kb: 5000,
animation_max_duration_ms: 15000,
animation_max_loops: 3,
requires_backup_png: true,
requires_meta_ad_size: true,
flat_zip_structure: true,
},
xandr: {
id: 'xandr',
name: 'Xandr (AppNexus)',
click_tag_implementation: 'xandr',
requires_sdk: true,
// Uses APPNEXUS.getClickTag() method
weight_limit_initial_kb: 150,
weight_limit_total_kb: 5000,
animation_max_duration_ms: 15000,
animation_max_loops: 3,
requires_backup_png: false,
requires_meta_ad_size: true,
flat_zip_structure: false,
},
};
QA Gate Definitions
const QA_GATES: QAGateDefinition[] = [
{
id: 'weight_initial',
name: 'Initial file weight',
severity: 'blocking',
check: (spec, profile, files) => {
const weight = measureInitialWeight(files);
return {
pass: weight <= profile.weight_limit_initial_kb,
value: weight,
limit: profile.weight_limit_initial_kb,
detail: `${weight}KB / ${profile.weight_limit_initial_kb}KB limit`
};
}
},
{
id: 'animation_duration',
name: 'Animation duration',
severity: 'blocking',
check: (spec, profile) => {
const duration = spec.artboards[0].timeline.duration_ms;
return {
pass: duration <= profile.animation_max_duration_ms,
value: duration,
limit: profile.animation_max_duration_ms,
detail: `${duration}ms / ${profile.animation_max_duration_ms}ms limit`
};
}
},
{
id: 'character_counts',
name: 'Character limits per artboard',
severity: 'blocking',
check: (spec, profile) => {
const violations = [];
spec.artboards.forEach(ab => {
ab.layers.forEach(layer => {
if (layer.type === 'text' && !layer.within_character_limit) {
violations.push(`${ab.artboard_id}: ${layer.layer_id}`);
}
});
});
return {
pass: violations.length === 0,
detail: violations.length ? `Overflow: ${violations.join(', ')}` : 'All within limits'
};
}
},
{
id: 'click_tag_present',
name: 'Click destination defined',
severity: 'blocking',
check: (spec) => ({
pass: spec.click_destinations.length > 0 &&
spec.click_destinations[0].url.startsWith('http'),
detail: spec.click_destinations[0]?.url || 'No click URL defined'
})
},
{
id: 'asset_rights',
name: 'Asset rights valid',
severity: 'blocking',
check: async (spec) => {
const expired = await checkAssetRightsExpiry(spec);
return {
pass: expired.length === 0,
detail: expired.length ? `Expired rights: ${expired.join(', ')}` : 'All rights valid'
};
}
},
{
id: 'backup_png',
name: 'Backup PNG present',
severity: 'blocking',
check: (spec, profile, files) => ({
pass: !profile.requires_backup_png || files.some(f => f.name === 'backup.png'),
detail: 'Required by ad server profile'
})
},
{
id: 'cta_approved',
name: 'CTA on approved list',
severity: 'blocking',
check: (spec, profile, files, campaign) => {
const violations = [];
// Check each artboard for CTA layers
// Compare against campaign.brief.copy_constraints.approved_ctas
return { pass: violations.length === 0, detail: '' };
}
},
{
id: 'color_contrast',
name: 'Text contrast ratio (WCAG 2.1 AA)',
severity: 'advisory',
check: async (spec) => {
return { pass: true, detail: '' };
}
},
];
Part 9: V1 Scope — Locked
Everything in V1. Nothing else.
In Scope for V1
Template Builder:
- Konva.js canvas with pan, zoom, rulers, guides, magnetic snapping
- Layer types: text, smart asset (single variant), shape (rect only), group
- Text group with push_siblings behavior (headless layout engine)
- Character limit simulator (visual, live)
- Linked artboards (master → variants with constraint propagation)
- Logo lockup variant groups (2-4 variants per slot)
- Export template as JSON spec
Asset Library:
- Upload, tag (structured metadata form, not freeform)
- Pre-approved crop tool per template size
- Rights expiry tracking with warnings
- Asset variant grouping for logo lockups
Brief/Feed Ingestion:
- CSV and JSON feed formats
- Brief mode (structured form, not freeform text)
- Field locking (override specific fields with pre-approved copy)
- Validation with dead letter queue for malformed records
AI Generation:
- Four-agent pipeline (Extract → Generate → Route → Assemble)
- Structured output with schema validation
- AI reasoning log (visible in review UI)
- Retry once on validation failure, flag on second failure
Review Interface:
- Grid view (all sizes × all copy variants)
- Synchronized animation playback across grid
- Inline text editing with immediate re-render
- Asset swap (pick from library, pre-approved crops auto-apply)
- Override logging (all human edits captured)
- Conflict resolution UI (when regeneration collides with override)
- Per-banner approval and bulk approval
- AI reasoning panel (why Claude made each decision)
Version Control:
- Full snapshot on each generation
- Delta on each human edit
- Override preservation across regeneration
- Rollback to any version
- Version history panel
Export:
- Image export: PNG, JPEG, WebP at 1x and 2x
- HTML5 export: IAB Standard and CM360 profiles
- GSAP animation in HTML5 output
- Polite load implementation
- Backup PNG generation
- Auto-generated trafficking sheet (CSV)
- QA gate report alongside export
Roles:
- Designer (template builder access)
- Producer (asset library, brief creation, review)
- Creative Director (approval)
- Trafficker (export only)
Included in V1 to enable clean V2 Figma integration (zero migration cost):
figma_node_idandfigma_file_keyoptional fields on BaseLayerfigma_sourceoptional field on Templatefigma_syncversion type in CampaignVersionfigma_sync_events,figma_conflicts, andfigma_resolution_preferencestables created (empty in V1)- Layer naming convention documented and enforced in template builder
Explicitly Out of Scope for V1
- RTL language support
- Video / animated GIF assets
- Social media formats (display only in V1)
- Amazon DSP, Xandr, Trade Desk ad server profiles (CM360 + IAB only)
- Vision model for automated focal point detection
- Multi-client workspaces (single client in V1)
- Comments / annotation system (approvals only)
- Figma plugin (annotation UI, layer auto-detection)
- Figma REST API extraction (server-side file reading)
- Figma webhook subscription (live sync on file update)
- Figma conflict resolution UI (three-state merge screen)
- Custom shape tools beyond rectangle
- Advanced animation keyframe editor (presets only in V1)
- A/B performance data integration
Part 10: Known Risks and Mitigations
| Risk | Severity | Mitigation |
|---|---|---|
| Typographic boundary drift between canvas preview and render output | High | Same Dropflow WASM instance runs in browser and in render worker. 5% internal padding on all text containers as safety buffer. |
| Font rendering inconsistency across environments | High | Fonts baked into Docker container at build. Sub-pixel rendering disabled. Font loading verified before screenshot taken. |
| Cross-origin polite load failure | Medium | Dual-pronged: cross-domain messaging protocol + hard 1500ms timeout fallback. Timer is embedded, not dependent on parent access. |
| Feed schema drift breaking generation | High | Strict schema validation at ingestion boundary. Malformed records to dead letter queue. Primary pipeline never sees corrupted data. |
| AI hallucinating outside character limits | Medium | Character counts validated programmatically after generation, not trusted from AI. Retry once. Flag on second fail. |
| Override conflicts causing reviewer confusion | Medium | Conflict resolution UI is a first-class screen, not a modal. Diff view shows exactly what changed. Default preserves human override. |
| Render worker memory exhaustion on large batches | Medium | BullMQ concurrency limited to hardware cores. Playwright contexts isolated. Memory limit per worker + watchdog restart. |
| Asset rights expiry mid-campaign | Low | Rights expiry checked at generation time and at export time. Warning at 30 days before expiry. Hard block at expiry. |
| Logo variant causing layout collision | Medium | Variant dimension overrides trigger full headless reflow. Max push limits enforce safe area constraints. |
| Figma API changes breaking V2 sync before it ships | Low | Figma REST API is stable and versioned. Plugin API has longer deprecation windows. Risk is post-V1 and monitored at V2 build time. |
| Designer naming layers inconsistently in Figma | Medium | Layer naming convention documented and published before V1 ships. V2 plugin auto-detects standard names and flags non-standard ones for manual mapping. Convention is additive — unmapped layers render as static. |
Part 11: Build Sequence Recommendation
Do not begin with the canvas. Begin with the data layer.
Phase 1: Database schema + all TypeScript types (the contract)
Phase 2: Template spec JSON — write it, validate it, stress-test it
Phase 3: Headless layout engine (Dropflow) integration — text group math
Phase 4: Konva.js canvas — basic artboard, layers, snapping
Phase 5: Text layer with live character limit simulator
Phase 6: Smart asset layer + variant group system
Phase 7: Asset library (upload, tag, crop tool)
Phase 8: AI orchestration pipeline (agents, prompt templates, validation)
Phase 9: Brief/feed ingestion with validation
Phase 10: Version service (snapshots, deltas, override preservation)
Phase 11: Render worker (Playwright + Docker + BullMQ)
Phase 12: Review UI (grid, inline edit, approval)
Phase 13: HTML5 export (IAB + CM360 profiles, QA gates)
Phase 14: Trafficking sheet, end-to-end testing, production hardening
The headless layout engine is built before the canvas because the canvas depends on it. The version service is built before the review UI because the review UI depends on it. Every dependency is respected in this sequence.
Part 12: Figma V2 Integration Architecture
Status: Out of scope for V1. Architected now to prevent V2 migration cost. Prerequisite: V1 shipped and in production with real users. Estimated build: 8–10 phases post-V1.
Architectural Principle
The tool is fully functional and production-ready without Figma. V2 adds a bridge for clients who already have significant design investment in Figma — saving them from rebuilding work that already exists. It does not gate V1 functionality.
Figma and the banner tool own different domains. The integration is designed around this ownership boundary.
FIGMA OWNS (auto-accept on sync):
Typography — font family, size, weight, color, line height
Color palette — backgrounds, fills, strokes
Layout — positions, dimensions, spacing
Visual hierarchy — z-order, grouping
Component variants — logo lockup structure
New decorative layers
BANNER TOOL OWNS (Figma cannot overwrite):
Character limits per artboard
Push sibling rules and expansion behavior
AI field designations (which layers Claude populates)
Animation presets
Approved crops per size
Copy lock states
Ad server profiles
Override history
Campaign content
CONFLICT ZONE (requires human resolution):
Font family — both sides might legitimately change this
Font size — affects character limit calculations
Layer dimensions — affects layout engine
Layer visibility — might be intentional in either direction
Layer removal — might have active campaign content
The Three API Surfaces
1. FIGMA PLUGIN API 2. FIGMA REST API
Runs inside Figma Runs on your server
Designer-facing UI Data extraction
Annotation + labeling Full file structure
Layer name detection Image rendering
Push to banner tool Webhook receiver
3. FIGMA WEBHOOKS
Event-driven
FILE_VERSION_UPDATE
Triggers on publish
Initiates sync pipeline
Layer Naming Convention
Established in V1 documentation. Enforced as auto-detection targets in V2 plugin. Unmapped layers are treated as static decorative elements.
CONTENT FIELDS (AI-populated or feed-mapped):
headline → primary headline slot
subheadline → secondary headline
body_copy → body text
offer → offer / discount text
cta → call to action button label
disclaimer → legal / compliance text
price → feed field, product price
product_name → feed field, product name
offer_end_date → feed field, expiry date
SMART ASSET SLOTS:
logo_lockup → VariantGroup slot type: 'logo'
hero_image → VariantGroup slot type: 'product'
background → VariantGroup slot type: 'background'
badge → VariantGroup slot type: 'badge'
TEXT GROUPS:
[name]_group → GroupLayer with group_behavior: 'flex_vertical'
e.g. "headline_group" contains headline + subheadline layers
ARTBOARD FRAMES:
300x250 → Artboard { width: 300, height: 250 }
728x90 → Artboard { width: 728, height: 90 }
(any WxH pattern is detected as an artboard size)
CONVENTION RULES:
— Case-insensitive matching
— Underscores and hyphens are equivalent
— Prefix matching: "headline_EN" maps to headline field
— Non-matching names → static decorative layer (no mapping)
— Convention is additive: name only layers you want mapped
Plugin Architecture
// What the plugin does:
// 1. Layer detection on open
function onPluginOpen() {
const selection = figma.currentPage.selection;
const detected = detectByNamingConvention(selection);
const annotations = readPluginAnnotations(selection);
showAnnotationPanel(detected, annotations);
}
// 2. Annotation storage — stored on Figma node, travels with file
function saveAnnotation(nodeId: string, annotation: LayerAnnotation) {
const node = figma.getNodeById(nodeId);
node.setPluginData('bannerstudio_annotation', JSON.stringify(annotation));
}
// 3. Push to banner tool
function pushToBannerTool() {
// Collect: file key, annotated frame IDs, OAuth token
// POST to /api/figma/import
// Plugin receives: redirect URL to template confirmation screen
}
interface LayerAnnotation {
field_name: string;
field_type: 'ai_generated' | 'feed_field' | 'locked_static';
character_limits: { [artboard_id: string]: number };
overflow_behavior: 'shrink' | 'clip' | 'warn' | 'truncate';
min_font_size?: number;
expansion_direction?: 'down' | 'up' | 'both' | 'none';
push_siblings?: PushSiblingRule[];
variant_group_metadata?: VariantMetadata;
figma_node_id: string;
figma_file_key: string;
last_annotated: string;
}
What the Plugin Auto-Detects
// Artboard size from frame name
if (frame.name.match(/^\d+x\d+$/i)) {
suggest: Artboard with detected dimensions
}
// Text group from Figma vertical auto-layout
if (frame.layoutMode === 'VERTICAL' &&
frame.primaryAxisSizingMode === 'AUTO') {
suggest: GroupLayer with group_behavior: 'flex_vertical'
}
// Variant group from Figma component set
if (node.type === 'COMPONENT_SET') {
suggest: VariantGroup — map each variant to AssetVariant
}
// Typography spec from applied Figma text style
if (textNode.textStyleId) {
auto-extract: TypographySpec (font, size, weight, line-height)
}
// Anchor constraints from Figma constraints
// 'MIN' → anchor: 'left' or 'top'
// 'MAX' → anchor: 'right' or 'bottom'
// 'CENTER' → anchor: 'center'
// 'STRETCH' → anchor: 'stretch'
// 'SCALE' → anchor: 'proportional'
// Linked artboard set detection
if (framesWithIdenticalStructure.length > 1) {
suggest: Linked artboard set, largest frame as master
}
Server-Side Extraction Flow
async function figmaImport(request: FigmaImportRequest) {
const { figma_file_key, frame_ids, oauth_token } = request;
// 1. Fetch full file structure via Figma REST API
const fileData = await figmaClient.getFile(figma_file_key, {
oauth_token,
geometry: 'paths',
plugin_data: PLUGIN_ID
});
// 2. Extract annotated nodes
const nodes = extractNodes(fileData, frame_ids);
const annotations = extractPluginAnnotations(nodes, PLUGIN_ID);
// 3. Fetch image renders for smart asset slots
const smartAssetNodeIds = findSmartAssetNodes(nodes, annotations);
const images = await figmaClient.getImages(figma_file_key, {
ids: smartAssetNodeIds,
format: 'png',
scale: 2,
oauth_token
});
// 4. Translate annotated Figma structure → Template spec
const templateSpec = translateFigmaToTemplate(
nodes, annotations, images, figma_file_key
);
// 5. Store as draft template with figma_source populated
const template = await templateService.createDraft({
...templateSpec,
figma_source: {
file_key: figma_file_key,
file_name: fileData.name,
last_synced: new Date().toISOString(),
sync_status: 'pending_review'
}
});
// 6. Return URL to template confirmation screen
return { redirect: `/templates/${template.id}/confirm` };
}
Live Sync Pipeline (Webhook-Triggered)
async function figmaWebhook(event: FigmaWebhookEvent) {
if (event.event_type !== 'FILE_VERSION_UPDATE') return;
const templates = await templateService.findByFigmaKey(event.file_key);
for (const template of templates) {
const updatedFile = await figmaClient.getFile(event.file_key);
const changes = diffFigmaAgainstTemplate(updatedFile, template);
const classified = classifyChanges(changes, template);
// Returns: { auto_apply, notify_and_apply, conflicts }
const afterPreferences = applyStandingPreferences(
classified.conflicts, template.id
);
if (classified.auto_apply.length > 0) {
await templateService.applyChanges(template.id, classified.auto_apply);
}
if (classified.notify_and_apply.length > 0) {
await templateService.applyChanges(template.id, classified.notify_and_apply);
await notificationService.send({
type: 'figma_template_updated',
template_id: template.id,
changes: classified.notify_and_apply,
recalculations_required: findCharacterLimitRecalculations(
classified.notify_and_apply
)
});
}
if (afterPreferences.remaining_conflicts.length > 0) {
await figmaSyncService.createConflicts(
template.id, afterPreferences.remaining_conflicts
);
await notificationService.send({
type: 'figma_conflicts_require_resolution',
template_id: template.id,
conflict_count: afterPreferences.remaining_conflicts.length
});
}
await flagActiveCampaigns(template.id, classified);
}
}
The Three-State Update Model
STATE 1: AUTO-APPLY
Figma owns this property. Apply silently. Log it.
Triggers on:
Font color changes, background color changes,
decorative layer additions, spacing adjustments
between non-text-group layers, component variant renames
UX: No prompt. "Template updated from Figma"
in notification feed. Dismissable.
─────────────────────────────────────────────────────
STATE 2: NOTIFY AND APPLY
Figma owns this but it affects banner tool calculations.
Apply it, but tell the producer.
Triggers on:
Font size changes (affects character limit math),
text frame dimension changes (affects layout engine),
layer added without annotation (may need mapping),
layer removed that was decorative
UX: Notification: "Figma template updated. Headline
font size changed 28px → 24px. Character limits
recalculated. Review before next generation."
[Review] [Dismiss]
─────────────────────────────────────────────────────
STATE 3: CONFLICT — HUMAN CHOOSES
Both sides changed the same property.
System cannot decide. Human resolves.
Triggers on:
Any property changed in both Figma AND banner tool
since last sync, Figma layer removed that has active
campaign content, structural layer hierarchy changes
UX: Conflict resolution screen.
Template update held until resolved.
Active campaigns notified but not blocked.
Conflict Resolution UI Specification
┌──────────────────────────────────────────────────────────────┐
│ Figma Template Updated │
│ 4 changes applied automatically · 2 need your input │
├──────────────────────────────────────────────────────────────┤
│ │
│ CONFLICT 1 OF 2 [Skip for now] │
│ Headline Text — Font family │
│ │
│ ┌───────────────────────┐ ┌───────────────────────┐ │
│ │ FROM FIGMA │ │ YOUR VERSION │ │
│ │ │ │ │ │
│ │ Neue Haas Grotesk │ │ Inter │ │
│ │ │ │ │ │
│ │ [Use this] │ │ [Keep this] │ │
│ └───────────────────────┘ └───────────────────────┘ │
│ │
│ □ Remember this choice for font changes on this template │
│ │
├──────────────────────────────────────────────────────────────┤
│ CONFLICT 2 OF 2 [Skip for now] │
│ CTA Button — Width │
│ │
│ ┌───────────────────────┐ ┌───────────────────────┐ │
│ │ FROM FIGMA │ │ YOUR VERSION │ │
│ │ │ │ │ │
│ │ 140px │ │ 160px │ │
│ │ │ │ │ │
│ │ [Use this] │ │ [Keep this] │ │
│ └───────────────────────┘ └───────────────────────┘ │
│ │
│ □ Remember this choice for dimension changes on this │
│ template │
│ │
└──────────────────────────────────────────────────────────────┘
UI Rules:
- No technical language. No "merge conflict." No diff syntax.
- Two versions, pick one.
- "Remember this choice" saves a standing preference — that conflict type stops appearing for this template.
- Skip for now is always available — unresolved conflicts do not block the current review session.
- Unresolved conflicts block the next AI generation — the system will not generate against an unresolved template state.
Active Campaign Protection Rules
RULE 1: Trafficked banners are immutable.
A Figma update never touches an approved, exported creative. Ever.
RULE 2: In-review campaigns receive a notification only.
"The template this campaign uses was updated in Figma.
Re-generate with the new template? Your current review
session and all human overrides will be preserved."
[Re-generate] [Continue with current] [Remind me later]
RULE 3: Draft campaigns (no generation yet) auto-update.
Template updates apply silently to drafts.
RULE 4: Conflicted templates block generation.
If a template has unresolved Figma conflicts,
AI generation is blocked until conflicts are resolved.
Human edits and review of existing versions continue.
V2 Build Sequence
Phase 1-2: Figma plugin scaffold
Layer name auto-detection
Annotation UI (field name, type, character limits)
Annotation storage via setPluginData
Phase 3-4: Push to banner tool flow
Server-side REST API extraction
Figma → Template spec translation
Template confirmation screen in banner tool
Phase 5-6: Webhook subscription
File diff engine
Three-state change classification
Auto-apply and notify pipelines
Phase 7-8: Conflict resolution UI
Standing preference storage
Active campaign protection rules
End-to-end testing with real Figma files
Phase 9-10: Production hardening
Edge cases: deleted layers, renamed frames,
component restructuring, large files
Performance: files with 50+ artboards
V2 Out of Scope (V3 Candidates)
- Two-way sync (changes in banner tool pushing back to Figma)
- Figma comment integration
- Figma prototype animations → banner animation presets
- Figma variables → design token sync
- Multi-file templates (template spans multiple Figma files)
- Figma branch support (syncing from a specific branch)
This document is the architecture reference for Claude Code planning mode. All decisions recorded here are locked for V1. Figma integration decisions in Part 12 are locked for V2. Deviations require explicit rationale documented before implementation.