banner_studio/ARCHITECTURE.md
Simeon Schecter ccbdb47162 Day 5+6 of the vertical slice: multi-size + per-row strips
Expands the slice from a single 300x250 banner to four IAB sizes
(300x600, 300x250, 728x90, 160x600) driven by a designer-authored
TypeSystem and a per-row strip review surface.

Layout engine
- TypeSystem with role-based typography (headline/subheadline/cta/legal)
  and piecewise size-class derivation: half_page / rectangle /
  leaderboard / skyscraper / mobile_banner.
- resolveLayout now derives per-size font/leading from the role +
  artboard size, then clamps to a legibility floor and emits a
  constraint_signal when copy does not fit at the floor.
- Four reference templates with character constraints per size.

AI pipeline (Shape B)
- One extract + one generate per feed row; generate returns per-size
  copy keyed by artboard_id plus a shared rationale block.
- Constraint-signal retry: orchestrator tightens per-(artboard, field)
  limits and re-calls generate before giving up.
- orchestrateRow returns specs[] + rationale + constraint_signals.

Review UI
- /review renders one strip per feed row, all four sizes side-by-side
  at true pixel dimensions, synced on a single GSAP master timeline.
- AiReasoningDrawer shows a per-size copy table, shared rationale, and
  any constraint signals that fired.
- /api/generate response grouped by row; /api/export accepts the same
  shape and writes exports/row-N/artboard_id.zip.

Render worker
- render-to-zip / render-many accept optional subdir + filename
  overrides so multi-size exports can be grouped by feed row.

Docs
- VERTICAL_SLICE and BUILD_SEQUENCE updated for the multi-size scope.
- RESOLVED_FEED.md documents the V1 Resolved Creative Feed proposal.
- SLICE_DEVIATIONS.md records where the slice diverges from V1.

Tests: 56 pass (28 layout-engine + 14 api-lib + 14 render-worker).
Web app: tsc clean, next build succeeds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-18 14:22:26 -04:00

65 KiB
Raw Blame History

# 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)

---

## 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
  },

  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 TypeSystem concept — 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. See RESOLVED_FEED.md for 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_id and figma_file_key optional fields on BaseLayer
  • figma_source optional field on Template
  • figma_sync version type in CampaignVersion
  • figma_sync_events, figma_conflicts, and figma_resolution_preferences tables 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: 810 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.