diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 883451c..a16d626 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -703,6 +703,13 @@ During template design:
└── 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
@@ -800,6 +807,16 @@ When a prompt is updated, the version is logged. This allows A/B testing of prom
## 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
```sql
diff --git a/BUILD_SEQUENCE.md b/BUILD_SEQUENCE.md
index 0768a08..c644352 100644
--- a/BUILD_SEQUENCE.md
+++ b/BUILD_SEQUENCE.md
@@ -69,6 +69,13 @@ Tasks:
Exit: A designer can build a template with a text group, type long copy into the simulator, and watch siblings cascade in real time. The preview matches what the render worker will produce.
+> **Note from the slice:** Multi-artboard support landed in the vertical slice
+> (four artboards: 300x600, 300x250, 728x90, 160x600) along with a
+> designer-authored TypeSystem. This phase in V1 *extends* that foundation with
+> variant groups, the asset library, linked-set conflict resolution, and the
+> template builder UI. The data model and the per-size layout-resolution path are
+> already in place from the slice. See SLICE_DEVIATIONS.md entries #9 and #10.
+
---
## Phase 6: Smart asset layer + variant groups
@@ -143,6 +150,16 @@ Tasks:
Exit: Unit tests cover: regeneration that preserves clean overrides, regeneration that creates a conflict, conflict resolution (keep human / take generated), rollback to an arbitrary version.
+> **Note: Resolved Creative Feed proposal.** A V1 architecture proposal reshapes
+> the override layer described above. Copy edits should write to a sparse,
+> per-product-per-size resolved feed *upstream* of generation, not to patches
+> applied *downstream* of it. This collapses the copy-override conflict
+> resolution problem entirely; the override-preservation algorithm above
+> remains correct for non-copy edits (visual, layout, animation, asset
+> selection). When this phase is built, split it: implement the resolved feed
+> for copy first (RESOLVED_FEED.md), then layer the smaller patch system on
+> top for non-copy. The two systems coexist; they don't compete.
+
---
## Phase 11: Render worker — Playwright in Docker
diff --git a/CLAUDE.md b/CLAUDE.md
index 72e768e..352a911 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -3,13 +3,21 @@
You are building a vertical slice of the platform, not V1. The architecture document
and BUILD_SEQUENCE.md describe the full V1. This slice is deliberately narrower.
-What's in the slice: one template (hardcoded in TypeScript), one feed format (CSV),
-one AI pipeline (four agents, real Claude calls), one review grid (Konva canvas
-preview, no editing), one render path (Playwright local), one ad server profile (IAB).
+What's in the slice: one template containing four artboards (300x600 reference,
+300x250, 728x90, 160x600), authored against a designer-defined TypeSystem; one
+feed format (CSV); one AI pipeline (four agents, real Claude calls, size-aware
+copy adaptation); one review grid (per-row strip showing all four sizes at true
+pixel dimensions); one render path (Playwright local, four zips per row); one ad
+server profile (IAB).
-What's out: template builder UI, asset library, variant groups, multi-artboard,
-version control, human overrides, conflict resolution, approval workflow, auth,
-Docker, CM360, trafficking sheet, brief mode, Figma. None of these exist in the slice.
+What's out: template builder UI, asset library, variant groups, version control,
+human overrides, conflict resolution, approval workflow, auth, Docker, CM360,
+trafficking sheet, brief mode, Figma. None of these exist in the slice.
+
+The TypeSystem is a slice-originated artifact that lives on the Template and is
+consumed by packages/layout-engine/src/type-scale.ts. Per-size font sizes are
+derived, not hand-authored, for non-reference artboards. See SLICE_DEVIATIONS.md
+entry #10 for the full rationale.
When in doubt: build less, ship the demo, extend after. If you find yourself adding
a feature not in VERTICAL_SLICE.md, stop and confirm.
@@ -65,7 +73,7 @@ These decisions are made. Do not re-litigate them when working on a task. If you
4. **Validation is programmatic, not AI.** Character counts, asset rights, weight estimation, schema conformance — all checked by code after AI returns. AI output is never trusted.
5. **Every prompt is versioned in `/prompts/`, never hardcoded.** When a prompt changes, the version is logged with the generation event. This enables A/B testing and rollback.
6. **Event sourcing for versions.** A campaign is a sequence of versions: full snapshots on AI generation, deltas on human edits. Never destructive overwrites.
-7. **Human overrides survive regeneration.** They live in a separate table with `field_path` references. The merge algorithm is in Part 7 of the architecture doc.
+7. **Human overrides survive regeneration.** They live in a separate table with `field_path` references. The merge algorithm is in Part 7 of the architecture doc. **V1 direction (see `RESOLVED_FEED.md`):** copy edits collapse into a sparse per-product-per-size *resolved creative feed* that lives upstream of generation, eliminating the copy-override conflict-resolution problem entirely. Non-copy overrides (visual, layout, animation, asset selection) remain on a smaller patch layer per Part 7. The slice does not implement the resolved feed but persists per-size editorial decisions in `BannerSpec.ai_reasoning.per_size_decisions` as a forward-pointer (see SLICE_DEVIATIONS.md entry #11).
---
@@ -82,6 +90,7 @@ These decisions are made. Do not re-litigate them when working on a task. If you
- **Do not trust character counts from AI output.** Validate programmatically after each generation. Retry once, then flag for human.
- **Do not deploy render workers to serverless.** Playwright requires persistent processes. ECS with dedicated containers.
- **Do not skip the 5% internal padding on text containers.** This absorbs sub-pixel font rendering drift between browser preview and Playwright render.
+- **Do not hand-author per-layer font sizes in non-reference templates.** Sizes derive from the TypeSystem on the reference artboard via `deriveTypeSpec`. Templates declare layout, layer composition, hero treatment, and animation; typography is the system's job. Per-template overrides exist for legitimate surface reactions (color on a light vs. dark hero, for example) but base sizes are formula-derived.
---
@@ -177,3 +186,4 @@ The data model has Figma hooks (`figma_node_id`, `figma_file_key`, `figma_sync_e
- `PHASE_1_BRIEF.md` — first phase, what to build, in what order
- `BUILD_SEQUENCE.md` — all 14 phases with acceptance criteria
- `RESEARCH.md` — research backing the locked decisions (uploaded as `Research`)
+- `RESOLVED_FEED.md` — V1 proposal for the copy-override architecture; the resolved creative feed model
diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md
index 2a618d1..6fe6b25 100644
--- a/PROJECT_STRUCTURE.md
+++ b/PROJECT_STRUCTURE.md
@@ -74,6 +74,7 @@ packages/layout-engine/
│ ├── dropflow-wrapper.ts WASM init, text measurement primitives
│ ├── shrink-to-fit.ts Recursive font reduction algorithm
│ ├── push-siblings.ts Cascade logic for flex_vertical groups
+│ ├── type-scale.ts Designer-authored TypeSystem → per-size TypographySpec
│ ├── resolve-layout.ts Top-level: spec → ResolvedLayer[]
│ ├── layout-log.ts Build the debug trace
│ └── index.ts
diff --git a/RESOLVED_FEED.md b/RESOLVED_FEED.md
new file mode 100644
index 0000000..5645640
--- /dev/null
+++ b/RESOLVED_FEED.md
@@ -0,0 +1,477 @@
+# RESOLVED_FEED.md
+
+> **Status:** V1 architecture proposal. Not implemented in the vertical slice.
+> The slice persists per-size editorial decisions in `BannerSpec.ai_reasoning`
+> as a forward-pointer to this model (see SLICE_DEVIATIONS.md).
+
+A proposal for the V1 version/override architecture. Replaces — or significantly
+reshapes — the override-merge layer described in `ARCHITECTURE.md` Part 7. The
+load-bearing claim: **copy belongs in a sparse, per-product-per-size feed, not in
+a patch layer applied after generation.**
+
+---
+
+## The Two-Feed Model
+
+The system manages two feeds, not one.
+
+**Inbound Feed.** What the client provides. One row per product. Flat columns
+(`headline`, `subheadline`, `hero_image_url`, `cta_text`, `click_url`, …).
+Authored by the client or by humans inside the agency. Never edited by the
+system. Refreshes on CSV upload or live API pull.
+
+**Resolved Creative Feed.** What the system manages internally. One *cell* per
+product per size per copy field. Sparse — cells exist only where variation is
+needed. Authored by the AI (most cells), the constraint signal (per-size
+deviations), or humans (review-UI edits). The render worker reads from this
+feed. The review UI reads from this feed. The export pipeline reads from this
+feed.
+
+The user only ever interacts with the inbound feed (upload) or the review UI
+(per-banner). The resolved creative feed is the engine room — visible in audit
+views, never edited as a spreadsheet.
+
+```
+INBOUND FEED RESOLVED CREATIVE FEED
+───────────────────────── ────────────────────────────────────────
+client artifact system artifact
+one row per product one cell per product per size per field
+flat, dense sparse — cells only where needed
+human-authored only AI + constraint + human
+refresh = overwrite (with locks) refresh = orchestrated regeneration
+```
+
+---
+
+## Cell Types
+
+A copy cell in the resolved feed has a `source` indicating how it came to be.
+The source determines who can write to it and what happens on regeneration.
+
+**1. Inbound cell.** Equal to the inbound feed value for this product +
+field. No size-specific variation. Read-only from the system; only changes on
+external feed refresh.
+
+**2. Generated baseline cell.** Written by the AI on first pass against the
+reference artboard's constraints and the brand voice. This is the "house
+version" of the copy — what would render at the reference size and at any
+non-reference size that didn't trigger a constraint signal. One per
+product + field.
+
+**3. Per-size cell.** Created in exactly two situations:
+ - The layout engine emitted a `constraint_signal` for this product at this
+ size, and the AI rewrote shorter copy to fit.
+ - A human edited the banner at this size in the review UI.
+
+**Read order at render time.** For a given product, size, and field, the
+renderer reads in priority order:
+
+```
+per-size cell → generated baseline cell → inbound cell
+```
+
+If a per-size cell exists, it wins. Otherwise the generated baseline. Otherwise
+the inbound value (which means the AI's baseline pass was a no-op for this
+field — typically because the inbound value already met every constraint and
+the brand voice didn't require a rewrite).
+
+**Sparseness in practice.** A typical 5-product, 4-size campaign has 5 inbound
+rows. The resolved feed adds ~15 generated baseline cells (3 fields × 5
+products, give or take, since some fields may not need rewriting). Per-size
+cells appear only where the leaderboard couldn't fit the baseline headline,
+or the producer adjusted something specific. Total cells in the resolved feed
+for the campaign: maybe 25–40 across what would be 60 dense cells (5 × 4 × 3).
+Sparseness ratio matters because it's the audit-trail signal — *every per-size
+cell represents a real editorial decision.*
+
+---
+
+## How Cells Are Created
+
+```
+NEW INBOUND ROW
+ → 1 inbound cell per field
+
+AI BASELINE PASS (Generate agent, reference-size constraints)
+ → 1 generated baseline cell per field where the AI rewrote
+ → No cell where the inbound value already passes validation
+
+CONSTRAINT SIGNAL FROM LAYOUT ENGINE
+ Layout engine: "728x90 headline doesn't fit at 18px floor, max 32 chars"
+ Orchestrator re-invokes Generate with tightened limit for that size
+ → 1 per-size cell at (product, 728x90, headline)
+
+HUMAN EDIT IN REVIEW UI
+ Producer changes headline on the 728x90 banner for product 3
+ → 1 per-size cell at (product 3, 728x90, headline)
+ → Cell flagged human_authored: true (see Lock Mechanism below)
+```
+
+If a producer edits at the reference size (300x600), the system has a choice:
+update the generated baseline cell, or create a per-size cell at the reference
+size. The right answer is **update the baseline cell** and mark it
+`human_authored: true`, so the edit propagates to any non-reference size that
+was reading the baseline. This is the only place the cell model gets subtle;
+the UI should make the propagation visible ("this edit will apply to all sizes
+except 728x90, where you previously customized the headline").
+
+---
+
+## The Override Problem, Reframed
+
+The current `ARCHITECTURE.md` Part 7 model:
+
+```
+AI generates → human edits → override patch stored separately
+Regenerate → new generation + patch re-applied
+ ↑ conflicts possible at this merge step
+```
+
+The merge step is where complexity lives. The patch is a `field_path` + value;
+on regeneration, the patch is re-applied to the new spec; if the field shape
+changed (e.g., the AI removed a layer the override referenced), the merge
+algorithm has to resolve the conflict.
+
+The resolved-feed model:
+
+```
+AI generates → writes baseline + per-size cells
+Human edits → writes per-size cell, sets human_authored: true
+Regenerate → reads resolved feed; AI only rewrites un-locked cells
+ ↑ no merge — edited cells were already the canonical source
+```
+
+There is no conflict because there is no after-the-fact patch application. The
+edit *is* the cell value. Regeneration reads the cell; the AI is invoked only
+for cells without `human_authored: true`.
+
+**Important honest scope limit.** This collapses the override problem **for
+copy**. Non-copy overrides — visual properties on a layer, hero focal point
+adjustments, GSAP timing changes, layer visibility per artboard, asset variant
+selection — do not naturally live as cells in a copy feed. Those still require
+the existing override layer (or some shrunken version of it). The resolved
+feed eliminates the largest, highest-conflict surface of the override system;
+it does not eliminate it entirely.
+
+V1 should ship with both: resolved feed for copy, smaller patch layer for
+non-copy. Future cleanup can examine whether non-copy edits can be folded
+into a generalized cell model (likely yes for layer-level properties keyed
+by `layer_id`; probably no for cross-cutting decisions like animation
+preset selection).
+
+---
+
+## Lock Mechanism
+
+Two independent flags on every cell. Default values matter; both default to
+the producer-protective setting on human edit.
+
+**`human_authored`** — true when a human authored or edited this cell.
+- Effect on regeneration: AI does not rewrite this cell.
+- Default on human edit: `true`.
+- Producer can explicitly reset to `false` ("let the AI try again").
+
+**`external_pinned`** — true when this cell should resist inbound feed refresh.
+- Effect on external feed refresh: inbound value does not overwrite this cell.
+- Default on human edit: `true`.
+- Producer can relax to `false` if they want the new product price from the
+ next feed pull but want to keep their headline edit.
+
+The combinations:
+
+| `human_authored` | `external_pinned` | Behavior |
+|---|---|---|
+| false | false | AI may rewrite; feed refresh may overwrite |
+| true | false | AI does not rewrite; feed refresh may overwrite |
+| false | true | AI may rewrite; feed refresh does not overwrite |
+| true | true | AI does not rewrite; feed refresh does not overwrite |
+
+The two flags are independent because they answer different questions
+("who controls editorial decisions?" vs "is this synced with the source of
+record?"). Conflating them — as the original write-up did with a single
+"locked" flag — loses real producer leverage.
+
+---
+
+## Revert / Delete Semantics
+
+A per-size cell exists because something deviated from the baseline. When
+a producer wants to revert ("let the AI decide again, or fall back to
+baseline"), the cell is *deleted*, not edited to match the baseline.
+
+Deletion drops back through the source chain:
+
+```
+delete per-size cell → reads generated baseline cell (if exists)
+delete baseline cell → reads inbound cell
+delete inbound cell → impossible (inbound feed is the floor)
+```
+
+The UI shows a single "revert to system" action per cell. Producers
+think in terms of the rendered banner, not the cell tree, so the action
+is phrased "let the AI/system decide this again." Under the hood it's a
+cell deletion.
+
+**Open question for V1.** Should deletion *also* clear `human_authored`
+and `external_pinned` on the underlying baseline cell if that cell exists?
+Probably yes — if the producer is reverting, they're signaling they no
+longer want the lock. Worth confirming with producers before locking the
+behavior.
+
+---
+
+## External Feed Refresh
+
+When a new CSV is uploaded or the live feed updates, the system:
+
+1. Diffs new inbound rows against existing inbound cells.
+2. For each changed cell:
+ - If the resolved feed cell at this product + field is `external_pinned: true`
+ anywhere (baseline or any per-size), do nothing.
+ - Otherwise, update the inbound cell value.
+ - If a baseline cell exists for this field, mark it stale and queue
+ regeneration. (The new inbound copy may invalidate the AI's baseline
+ rewrite.)
+ - If per-size cells exist for this field and are not pinned, mark them stale
+ and queue regeneration.
+
+3. Surface the stale set in the review UI so the producer chooses when to
+ trigger regeneration.
+
+**Note on stale state.** Stale cells render with their current value (no banner
+breaks because of a feed refresh). They're flagged in the UI as "needs
+regeneration." Regeneration runs only the affected cells, not the whole
+campaign. This is what makes "production gets faster with every campaign" real
+— most refreshes touch a small set of cells, not all of them.
+
+---
+
+## Brief Mode
+
+This model applies in brief mode with one structural difference: there is no
+inbound feed.
+
+- No inbound cells exist.
+- Generated baseline cells are written from the brief on first pass.
+- Per-size cells are created from constraint signals or human edits, same as
+ feed mode.
+- `external_pinned` is meaningless and becomes a no-op (no inbound source to
+ resist). UI hides the flag in brief mode.
+- `human_authored` does the same job it does in feed mode.
+
+The override-preservation backstop (the smaller patch layer mentioned above)
+is more load-bearing in brief mode for non-copy overrides, but copy edits
+still write to cells.
+
+---
+
+## What This Unlocks Beyond Solving the Override Problem
+
+**Auditable creative history.** The resolved feed *is* the creative record.
+Every campaign leaves behind a structured document of what shipped at every
+size for every product, with sources attributed (AI baseline, constraint
+signal, human edit) and timestamps. Compliance, brand audits, and "what did
+we run last Black Friday?" all become queries against the feed.
+
+**Compounding speed across campaigns.** Next campaign, same products:
+the producer starts from the previous campaign's resolved feed as a baseline.
+The brief writer marks which products are unchanged. For unchanged products,
+the system reuses cells verbatim (no AI calls). For changed products, only
+those rows regenerate. For campaign-level changes (new brand voice, new
+character constraints), all `human_authored: false` cells regenerate.
+
+This speedup is a **feature to build**, not a property that emerges. The
+resolved feed makes it *possible*; the campaign-rerun flow has to be
+designed and shipped. Worth a dedicated build phase in V1.
+
+**A real surface for editorial review.** Right now, "see what the AI
+decided" is a derived view assembled from logs and `ai_reasoning` fields.
+With the resolved feed, it's a table: product × size × field, with each cell
+showing source, value, timestamp, and any constraint signal that drove the
+decision. Reviewing a campaign becomes a structured pass through this table
+rather than clicking through 25 banner previews.
+
+**Cleaner V2 hooks.** Multi-language: another dimension on the cell key
+(`product × size × field × locale`). Multi-market: same shape. The cell
+model generalizes more cleanly than the patch model does, because patches
+are inherently anchored to a single spec at a single point in time.
+
+---
+
+## What This Doesn't Solve
+
+**Non-copy overrides.** As above. Visual, layout, animation, and asset
+selection edits still need the patch layer (or a generalized cell model
+keyed by `layer_id + property_path`, which is a V2 question).
+
+**Cross-product editorial coherence.** A campaign may need its headlines
+to feel consistent across products in tone and structure. The cell model
+doesn't encode this constraint; the Generate agent's prompt does, and the
+producer reviews. This is fine — but worth not claiming the resolved feed
+solves something it doesn't.
+
+**Inbound feed schema changes.** When the client adds, drops, or renames
+columns, the resolved feed doesn't automatically know what to do. Adds
+are easy (new field, no cells yet, AI generates on next pass). Drops are
+trickier — historical cells for the dropped column should probably be
+preserved, marked stale, and hidden from the active campaign view but
+retained for audit. Renames are the hardest and likely require explicit
+producer intent ("treat `subheadline` as the new `subhead`"). V1 should
+handle adds and drops; renames are a V2 capability.
+
+---
+
+## Schema Sketch
+
+Not a final V1 schema — a sketch to make the model concrete.
+
+```sql
+-- The two cell roots.
+
+inbound_cells (
+ product_id TEXT,
+ field TEXT, -- 'headline', 'subheadline', 'cta_text', …
+ value TEXT,
+ source TEXT, -- 'csv_upload' | 'live_feed' | 'manual'
+ updated_at TIMESTAMP,
+ PRIMARY KEY (product_id, field)
+)
+
+resolved_cells (
+ campaign_id TEXT,
+ product_id TEXT,
+ size_id TEXT NULL, -- NULL for generated baseline; '300x600', '728x90', … for per-size
+ field TEXT,
+ value TEXT,
+ cell_source TEXT, -- 'inbound' | 'generated_baseline' | 'constraint_emitted' | 'human_authored'
+ human_authored BOOLEAN DEFAULT FALSE,
+ external_pinned BOOLEAN DEFAULT FALSE,
+ constraint_signal JSONB NULL, -- when source is constraint_emitted
+ authored_at TIMESTAMP,
+ authored_by TEXT NULL, -- user id when human, AI prompt version when AI
+ stale BOOLEAN DEFAULT FALSE,
+ PRIMARY KEY (campaign_id, product_id, size_id, field)
+)
+```
+
+Notes on the schema:
+
+- `size_id IS NULL` marks the generated baseline (or absence). Modeling
+ baselines as a `size_id = NULL` row is cleaner than a separate table because
+ the read query for "give me the value for (campaign, product, size, field)"
+ is one query with a coalesce on `size_id`.
+- `cell_source` is the *origin* of the value; `human_authored` is a *behavior
+ flag*. They're related but not the same — a human-authored cell has
+ `cell_source = 'human_authored'` and `human_authored = true`, but a baseline
+ cell that a human edited becomes `cell_source = 'human_authored'` (the
+ previous AI baseline is overwritten) with `human_authored = true`. The flag
+ is what drives regeneration logic; the source is what drives audit views.
+- `constraint_signal` carries the layout-engine emission for cells created
+ by the constraint loop. This is exactly the data structure the slice's
+ layout engine already produces.
+
+---
+
+## Relationship to the Slice
+
+The slice does not implement the resolved feed. It runs end-to-end in one
+session: CSV in → BannerSpec out → render. There is no persistence, no
+campaign reruns, no human editing.
+
+The slice does, however, produce data that maps directly to the resolved
+feed model:
+
+- **Per-row constraint signals.** When the layout engine emits a
+ `constraint_signal` and the orchestrator retries Generate with a tightened
+ limit, the resulting copy is conceptually a per-size cell with
+ `cell_source = 'constraint_emitted'`. In the slice, this lives in the
+ resolved `BannerSpec` and the `ai_reasoning` field.
+
+- **AI baseline reasoning.** The Generate agent's per-size output is the
+ generated baseline + per-size cell set. The slice surfaces these in the
+ AI reasoning panel.
+
+For the slice's `BannerSpec.ai_reasoning`, a `per_size_decisions` field
+captures this as a forward-pointer:
+
+```ts
+ai_reasoning: {
+ asset_selection: string;
+ copy_rationale: string;
+ variant_selection: string;
+ animation_rationale: string;
+ per_size_decisions?: Array<{
+ artboard_id: string;
+ role: 'headline' | 'subheadline' | 'cta' | 'legal';
+ reason: 'baseline' | 'constraint_emitted';
+ constraint_signal?: {
+ max_chars_at_floor: number;
+ derived_font_size: number;
+ floor_font_size: number;
+ };
+ decided_at: string;
+ }>;
+}
+```
+
+This is the audit-trail data the future resolved feed would carry, captured
+in the slice as a flat array on the spec. Cheap to add, narratively powerful
+for the demo ("here are the editorial decisions the AI made per size, with
+reasons"), and a clean migration path to the V1 cell model.
+
+---
+
+## V1 Build Path
+
+Rough sequencing for V1, not committed:
+
+1. **Schema + storage.** Implement `inbound_cells` and `resolved_cells` per
+ the sketch above. Postgres + JSONB columns where appropriate.
+2. **Read path.** Implement `resolveCellValue(campaign, product, size, field)`
+ as the canonical read. Render worker, review UI, export pipeline all use
+ it.
+3. **Write path — AI.** Generate agent writes baseline + per-size cells.
+ Constraint-signal retry writes `cell_source = 'constraint_emitted'`.
+4. **Write path — human.** Review UI inline edit writes
+ `cell_source = 'human_authored'` with `human_authored = true` by default.
+5. **Refresh path.** Inbound feed refresh diffs against `inbound_cells`,
+ respects `external_pinned`, marks affected resolved cells stale.
+6. **Revert / delete.** UI action for "revert to system" deletes the
+ appropriate cell and recomputes downstream reads.
+7. **Campaign rerun.** Brief writer flow that diffs new brief against
+ previous campaign's resolved feed, marks unchanged products, regenerates
+ only the changed set.
+
+Steps 1–4 are the minimum to ship copy editing in V1. Steps 5–7 are the
+compounding-speed payoff and can land in subsequent V1 milestones.
+
+---
+
+## Open Questions for V1
+
+- **Cell deletion and lock interaction.** When a per-size cell is deleted,
+ should the underlying baseline cell's `human_authored` flag be cleared
+ if it was true? Probably yes; worth confirming with producers.
+- **Multi-campaign sharing.** Should the resolved feed be scoped per
+ campaign or per client? Per campaign is simpler; per client enables
+ cross-campaign reuse but complicates lock semantics.
+- **Audit retention.** How long are deleted cells kept? Forever (soft
+ delete with `deleted_at`) is the conservative answer; cost may justify
+ a TTL eventually.
+- **Conflict between concurrent edits.** Two producers editing the same
+ cell. The patch model already had this problem; the cell model has it
+ too. Optimistic locking with last-write-wins + audit log is probably
+ enough for V1.
+- **Generalizing to non-copy properties.** Whether to extend the cell
+ model to layer-level visual properties in V2, eliminating the residual
+ patch layer entirely.
+
+---
+
+## One-Line Summary
+
+Copy lives in a sparse resolved feed — one cell per product per size per
+field, only created on need. AI writes baseline and per-size deviations;
+humans edit cells directly; regeneration reads from the feed, so copy
+edits survive without an override-merge layer. Non-copy overrides — visual,
+layout, animation — remain on a smaller patch layer.
diff --git a/SLICE_DEVIATIONS.md b/SLICE_DEVIATIONS.md
index 5266862..10d94a7 100644
--- a/SLICE_DEVIATIONS.md
+++ b/SLICE_DEVIATIONS.md
@@ -179,6 +179,111 @@ template on `spec.ad_server_profile`.
---
+## 9. Multi-size in the slice (4 artboards instead of 1)
+
+**VERTICAL_SLICE.md originally said:** *"One template, hand-built in code…
+A single 300x250 artboard… Multi-artboard linked sets (one size only) —
+out of scope."*
+
+**Slice now does:** One template containing four artboards: 300x600
+(reference), 300x250, 728x90, 160x600. Same `Template` shape from the V1
+architecture (`artboards: Artboard[]` was already an array); we just
+populate four entries instead of one.
+
+**Why:** Single-size obscures the thesis. The product value is in scale
+— one designed system, many adaptive outputs. A demo with one size reads
+as "AI rewrites copy." A demo with four sizes reads as "humans design,
+AI scales," which is the actual claim. The V1 architecture supported
+multi-artboard from day one; the slice was scoped down for time, not for
+data-model reasons.
+
+**V1 reversal:** None needed. Multi-artboard is the V1 shape. The slice
+restores it. Variant groups, the asset library, and linked-set conflict
+resolution are still V1 work, layered on top of what the slice ships.
+
+---
+
+## 10. TypeSystem — designer-authored, formula-derived
+
+**ARCHITECTURE.md says:** Each `TextLayer` carries a `TypographySpec`
+with raw `font_size`, `font_weight`, `line_height`, etc. There is no
+shared system across layers or artboards.
+
+**Slice does:** Adds a `TypeSystem` interface on `Template` declaring the
+four roles (`headline`, `subheadline`, `cta`, `legal`) with base size,
+weight, line-height, tracking, color, and legibility floor each.
+Authored once on the 300x600 reference artboard. A new module —
+`packages/layout-engine/src/type-scale.ts` — exposes
+`deriveTypeSpec(system, target)` which returns per-role `TypographySpec`
+values for any target artboard using piecewise size-class math
+(half-page, rectangle, leaderboard, skyscraper) with a width-correction
+term.
+
+When `resolveLayout` runs, it shrinks the derived font down to 85% of
+the formula-derived size to fit the actual copy. If still overflowing at
+the floor, it emits a `constraint_signal` on the resolved spec carrying
+the max characters that *would* fit. The orchestrator reads this signal
+and re-invokes the Generate agent with a tighter per-size character
+limit. One retry, then flag as failed.
+
+**Why:** Per-size hand-authored typography doesn't scale beyond a
+handful of templates. Encoding role *relationships* once and deriving
+sizes by formula is how a designer actually thinks about a multi-size
+system. Tying the layout engine's overflow signal back to the AI's copy
+generation is the lever that turns the four-agent pipeline from "copy
+rewriter" into "copy *adapter*" — adaptation against system-emitted
+constraints, per surface.
+
+**V1 reversal:** The `TypeSystem` concept should graduate to V1
+architecture, not be removed. It's a slice-originated artifact worth
+keeping. Folding it into `ARCHITECTURE.md` Part 5 (Text Group System) is
+the natural V1 step.
+
+---
+
+## 11. `BannerSpec.ai_reasoning.per_size_decisions` — forward-pointer to the resolved feed
+
+**ARCHITECTURE.md says:** `ai_reasoning` carries flat strings (asset_selection,
+copy_rationale, variant_selection, animation_rationale). Per-size editorial
+decisions are not captured as structured data in the spec.
+
+**Slice does:** Adds an optional `per_size_decisions` array on
+`ai_reasoning` capturing each per-size editorial decision the AI made,
+along with the constraint signal (if any) that drove it. Shape:
+
+```ts
+per_size_decisions?: Array<{
+ artboard_id: string;
+ role: 'headline' | 'subheadline' | 'cta' | 'legal';
+ reason: 'baseline' | 'constraint_emitted';
+ constraint_signal?: {
+ max_chars_at_floor: number;
+ derived_font_size: number;
+ floor_font_size: number;
+ };
+ decided_at: string;
+}>
+```
+
+**Why:** This is the audit-trail data that V1's Resolved Creative Feed
+(see `RESOLVED_FEED.md`) will carry as per-size cells. The slice has no
+persistence, no campaign reruns, no human editing — so the resolved feed
+doesn't earn its weight yet. But the *editorial decisions* are still
+real and worth surfacing in the demo reasoning panel. Capturing them as
+a flat array on the spec lets the demo show the "AI decided differently
+per size, here's why" story, and gives V1 a zero-friction migration path:
+each `per_size_decisions` entry maps one-to-one onto a future resolved-feed
+cell.
+
+**V1 reversal:** Move this data out of `ai_reasoning` and into the
+`resolved_cells` table (RESOLVED_FEED.md schema). Each entry becomes a
+row keyed by `(campaign, product, size, field)` with `cell_source` set
+to `'constraint_emitted'` or `'generated_baseline'`. The `ai_reasoning`
+field reverts to flat-strings only, or grows other structured fields as
+they're needed.
+
+---
+
## Things that are NOT deviations (in case it looks like they might be)
- **Resolved BannerSpec from orchestrator.** VERTICAL_SLICE.md line 161
diff --git a/VERTICAL_SLICE.md b/VERTICAL_SLICE.md
index 4be4c6a..e320a0b 100644
--- a/VERTICAL_SLICE.md
+++ b/VERTICAL_SLICE.md
@@ -21,35 +21,66 @@ Everything else is deliberately deferred.
## Slice scope — what's in
**One template, hand-built in code, no template builder UI.**
-- A single 300x250 artboard.
-- One text group containing headline + subheadline with `push_siblings` cascade.
-- One smart asset slot for a hero image (no variant groups — just one asset).
+- Four artboards: 300x600 (reference), 300x250, 728x90, 160x600.
+- One `TypeSystem` authored on the 300x600 reference, declaring the
+ four roles (headline, subheadline, cta, legal) with base sizes,
+ weights, line-heights, tracking, colors, and legibility floors. The
+ other three sizes derive their typography from this system via the
+ type-scale formula.
+- Per-artboard layer composition: each size can declare which layers
+ it includes (e.g., the 728x90 may drop the subheadline; the 160x600
+ may collapse headline+subheadline into headline only).
+- One text group per artboard containing the included text layers
+ with `push_siblings` cascade.
+- One smart asset slot for a hero image (no variant groups — one
+ asset per row, per-size crop via focal point).
- One static logo (PNG, no variant logic).
-- One GSAP animation preset (fade in, hold, fade out).
+- One GSAP animation preset (fade in, hold, fade out), with per-size
+ timing scale.
**One CSV feed.**
-- 5 rows. Columns: `headline`, `subheadline`, `hero_image_url`, `cta_text`, `click_url`.
+- 3–5 rows. Columns: `headline`, `subheadline`, `hero_image_url`,
+ `cta_text`, `click_url`, `hero_focal_x`, `hero_focal_y`.
- Hand-authored, no validation rigor beyond "does it parse."
-**One AI pipeline.**
+**One AI pipeline, size-aware.**
- Four agents: extract → generate → route → assemble.
-- Generate agent rewrites/polishes feed copy against character limits and a hardcoded brand voice ("confident, modern, no exclamation marks").
-- Route node is trivial — just maps the `hero_image_url` field to the asset slot. No variant group logic.
-- Assemble produces a `BannerSpec`.
+- Extract runs once per row, producing an `ExtractedContext`.
+- Generate runs once per row and returns structured copy variants for
+ all four sizes simultaneously (Shape B), respecting per-size
+ character limits and the brand voice ("confident, modern, no
+ exclamation marks").
+- Route node maps `hero_image_url` and focal point to the asset slot.
+ No variant group logic.
+- Assemble produces a `BannerSpec` with four `ArtboardSpec`s, one per
+ size.
+- Programmatic post-validation: per-size character count check. If a
+ size overflows after shrink-to-fit hits the floor, the layout
+ engine emits a `constraint_signal` and Generate is re-invoked with
+ the tighter limit for that size. One retry, then flag as failed.
-**One review UI.**
-- Grid of 5 banners (one per feed row), all rendered live in the browser using Konva + Dropflow.
-- Click a banner to see the AI reasoning panel.
-- No editing, no version control, no conflict resolution, no approval workflow.
+**One review UI — per-row strip.**
+- Each feed row is a horizontal strip showing all four sizes at true
+ pixel dimensions, side by side.
+- All four sizes in a row animate together via a synchronized GSAP
+ timeline ("play all" per row, or "play all" across the grid).
+- Click any banner in a row to see the AI reasoning panel — including
+ the editorial decisions across sizes.
+- No editing, no version control, no conflict resolution, no
+ approval workflow.
**One render path.**
- Playwright runs locally (not in Docker yet).
-- Produces an HTML5 zip with: index.html, GSAP CDN script tag, the resolved spec inlined as JS, one click tag (IAB standard pattern), assets, a backup PNG.
+- Produces four HTML5 zips per row (one per size), each with:
+ index.html, GSAP CDN script tag, the resolved spec inlined as JS,
+ one IAB click tag, assets, a backup PNG.
**One ad server profile.**
- IAB Standard. CM360 deferred.
-That's it. Six "ones." Each one is a real implementation of the corresponding V1 component, scoped so the integration is what matters, not the breadth.
+That's the slice. Same "ones" as before, but the template is now a
+four-artboard system with a designer-authored type system, and the AI
+adapts copy per surface.
---
@@ -60,7 +91,7 @@ Deliberately, to make the timeline real:
- Template builder UI (templates are TypeScript objects in the slice)
- Asset library (assets are URLs hardcoded in the CSV)
- Variant groups, logo lockups, selection rules
-- Multi-artboard linked sets (one size only)
+- Variant groups, logo lockups, selection rules (multi-size hero crops use a focal point only)
- Character limit simulator UI
- Inline editing in the review UI
- Version service, deltas, snapshots
@@ -167,19 +198,64 @@ The two highest-risk pieces. Build them first so if Dropflow doesn't behave the
**End-of-day check:** Real HTML5 zips on disk. One of them opens in a browser and animates correctly. The click tag works.
-### Day 5 — Polish and the demo loop
+### Day 5 — Design system + 300x600 reference template
**Morning (3-4 hours):**
-- Wire the "Generate" button — let the user upload a different CSV and re-run the whole pipeline.
-- Add a second feed to demonstrate variety (different products, different copy lengths). The point is to show the layout engine handling a 30-character headline and a 70-character headline differently.
-- Visual polish on the review UI. It should look like something a CD would want to use, not a localhost test page. Dieter Rams adjacent. Restrained typography. Functional grid. No dashboard chrome.
+- Implement `TypeSystem` interface in `packages/types`.
+- Implement `type-scale.ts` module in `packages/layout-engine`
+ with `deriveTypeSpec(system, target)` and the size-class piecewise
+ formula.
+- Wire the shrink-then-constrain loop in `resolve-layout.ts`: shrink
+ to 85% of derived size, then emit `constraint_signal` if still
+ overflowing at the legibility floor.
**Afternoon (3-4 hours):**
-- Record the demo. Screen capture of: upload CSV, see banners generate live, see AI reasoning, export zips, open a zip in a browser. 90 seconds, tight cut.
-- Write the README for the repo. Honest about what's in scope and what's not.
-- Draft a short technical summary covering the thesis (humans design, AI scales) and the technical claim (Dropflow runs identically in both environments).
+- Author the 300x600 half-page template against the TypeSystem.
+ Hero treatment, logo position, headline/subhead/CTA placement,
+ GSAP timeline. This is the design-system reference — the
+ visual standard the other three sizes inherit from.
+- First render. React, adjust, lock the design system.
-**End-of-day check:** You have a working demo, a recording, and a written summary.
+**End-of-day check:** 300x600 banners render with type derived from
+the system. One row generates a polished half-page banner.
+
+### Day 6 — The other three sizes + size-aware AI
+
+**Morning (3-4 hours):**
+- Author 300x250 by adapting the 300x600 design.
+- Author 728x90 (the hard one — vertical space is tight; may drop
+ the subheadline).
+- Author 160x600 (narrow; constraints stress-test the formula).
+
+**Afternoon (3-4 hours):**
+- Upgrade the AI pipeline: Generate agent emits structured per-size
+ copy variants. Orchestrator handles the constraint-signal retry.
+- Update the post-validation logic for per-size character counts.
+- Run the pipeline against the demo CSV. All four sizes should
+ generate from each row.
+
+**End-of-day check:** Four banners per row, all sizes generated from
+one Generate call per row, all fitting their constraints.
+
+### Day 7 — Per-row strip review UI, multi-size render, polish, demo
+
+**Morning (3-4 hours):**
+- Rebuild `/review` as per-row strips. Each feed row shows all
+ four sizes at true pixel dimensions. GSAP timelines synchronized
+ per row.
+- Reasoning panel reframed to show editorial decisions across sizes
+ (which words survived the leaderboard cut, why).
+- Update the render worker to loop over sizes per row, organizing
+ exports as `exports/row-N/SIZE.zip`.
+
+**Afternoon (3-4 hours):**
+- Visual polish on the strip layout. Dieter Rams adjacent.
+- Record the demo. Voiceover: one campaign intent → four surfaces →
+ AI adapts copy per size. 90 seconds, tight cut.
+- Update README and technical summary.
+
+**End-of-day check:** Working demo of four-size adaptation, a
+recording, a written summary.
---
@@ -277,6 +353,20 @@ The rest of `CLAUDE.md` stays as-is — locked stack decisions, what not to do,
**Underestimating Day 5 polish.** "Visual polish on the review UI" sounds like an afternoon. It is not. The difference between "localhost test page" and "I would show this to a CD" is real work. Give it a full day. If you have to cut, cut Day 4's zip composition polish or one of the AI agents' prompt iteration, not Day 5.
+**Type scale calibration on the constrained sizes.** The 728x90 and
+160x600 are where the formula gets stress-tested. If the legibility
+floor pushes the system into emitting constraint signals on every row,
+the AI ends up writing very short copy and the demo feels less
+impressive. Tune the floors so they trigger on edge cases, not the
+common case. Budget time on Day 6 morning to iterate on the size-class
+math.
+
+**Hero image cropping across aspect ratios.** A wide landscape hero
+shot fails in 160x600 tall format. Pick demo hero images with centered
+subjects and breathable composition that survive aggressive cropping in
+multiple directions. The focal-point hint in the CSV is the primary
+lever; use it.
+
---
## After the slice
diff --git a/apps/web/app/api/export/route.ts b/apps/web/app/api/export/route.ts
index 9527e0a..9b71ed7 100644
--- a/apps/web/app/api/export/route.ts
+++ b/apps/web/app/api/export/route.ts
@@ -1,11 +1,16 @@
// POST /api/export
-// Body: { specs: BannerSpec[] }
+// Body (multi-size): { rows: [{ rowIndex: number, specs: BannerSpec[] }] }
+// Body (legacy): { specs: BannerSpec[] }
//
// Validates each spec via BannerSpecSchema. Invalid specs collect into the
// errors array rather than 400-ing the whole request — the producer should
// still get zips for the valid ones. Valid specs are rendered through
-// renderMany() (single Chromium, concurrent contexts, capped at 3) and
-// written to /exports//.zip.
+// renderMany() (single Chromium, concurrent contexts, capped at 3).
+//
+// Per-row body groups output by feed row:
+// exports/row-/.zip (one zip per size, per row)
+// Legacy specs body keeps the older layout:
+// exports//.zip
//
// Synchronous: returns when every zip is on disk. The slice only produces
// a handful of banners per call so ~3-8 seconds blocking is acceptable.
@@ -26,9 +31,15 @@ export const dynamic = 'force-dynamic';
interface ExportRequestBody {
specs?: unknown;
+ rows?: unknown;
}
-export async function POST(req: Request) {
+interface RowInput {
+ rowIndex: number;
+ specs: BannerSpec[];
+}
+
+export async function POST(req: Request): Promise {
let body: ExportRequestBody;
try {
body = (await req.json()) as ExportRequestBody;
@@ -39,49 +50,94 @@ export async function POST(req: Request) {
);
}
- const rawSpecs = Array.isArray(body.specs) ? body.specs : null;
- if (!rawSpecs) {
- return NextResponse.json(
- { error: 'Request body must include a "specs" array' },
- { status: 400 }
- );
- }
-
const path = await import('node:path');
const { BannerSpecSchema } = await import('@banner-studio/types');
const { renderMany } = await import('@banner-studio/render-worker');
- const validSpecs: BannerSpec[] = [];
const errors: { version_id?: string; error: string }[] = [];
- for (let i = 0; i < rawSpecs.length; i++) {
- const raw = rawSpecs[i];
- const parsed = BannerSpecSchema.safeParse(raw);
- if (parsed.success) {
- validSpecs.push(parsed.data);
- } else {
- // Try to surface a version_id even on validation failure so the UI
- // can attribute the error to a card.
- const maybeVersionId =
- raw && typeof raw === 'object' && 'version_id' in raw
- ? String((raw as { version_id?: unknown }).version_id ?? '')
- : undefined;
- errors.push({
- version_id: maybeVersionId,
- error: `spec[${i}] failed validation: ${parsed.error.message}`
- });
+ // Per-row mode: validate every spec inside every row. Each spec carries
+ // its row index forward so the renderer can emit exports/row-N/.zip.
+ let perRow: { spec: BannerSpec; rowIndex: number }[] | null = null;
+ if (Array.isArray(body.rows)) {
+ perRow = [];
+ for (let r = 0; r < body.rows.length; r++) {
+ const row = body.rows[r] as Partial | null;
+ if (!row || typeof row !== 'object' || !Array.isArray(row.specs)) continue;
+ const rowIndex = typeof row.rowIndex === 'number' ? row.rowIndex : r;
+ for (let i = 0; i < row.specs.length; i++) {
+ const parsed = BannerSpecSchema.safeParse(row.specs[i]);
+ if (parsed.success) {
+ perRow.push({ spec: parsed.data, rowIndex });
+ } else {
+ const raw = row.specs[i];
+ const maybeVersionId =
+ raw && typeof raw === 'object' && 'version_id' in raw
+ ? String((raw as { version_id?: unknown }).version_id ?? '')
+ : undefined;
+ errors.push({
+ version_id: maybeVersionId,
+ error: `row[${r}].specs[${i}] failed validation: ${parsed.error.message}`
+ });
+ }
+ }
+ }
+ }
+
+ // Legacy specs mode: kept for any older caller. Flat array, default paths.
+ let flat: BannerSpec[] | null = null;
+ if (!perRow) {
+ const rawSpecs = Array.isArray(body.specs) ? body.specs : null;
+ if (!rawSpecs) {
+ return NextResponse.json(
+ { error: 'Request body must include either a "rows" or "specs" array' },
+ { status: 400 }
+ );
+ }
+ flat = [];
+ for (let i = 0; i < rawSpecs.length; i++) {
+ const parsed = BannerSpecSchema.safeParse(rawSpecs[i]);
+ if (parsed.success) {
+ flat.push(parsed.data);
+ } else {
+ const raw = rawSpecs[i];
+ const maybeVersionId =
+ raw && typeof raw === 'object' && 'version_id' in raw
+ ? String((raw as { version_id?: unknown }).version_id ?? '')
+ : undefined;
+ errors.push({
+ version_id: maybeVersionId,
+ error: `spec[${i}] failed validation: ${parsed.error.message}`
+ });
+ }
}
}
const repoRoot = path.resolve(process.cwd(), '..', '..');
const outputDir = path.join(repoRoot, 'exports');
- const result = await renderMany({
- specs: validSpecs,
- outputDir,
- concurrency: 3,
- repoRoot
- });
+ let result;
+ if (perRow) {
+ const specsFlat = perRow.map((p) => p.spec);
+ const rowIndexFor = perRow.map((p) => p.rowIndex);
+ result = await renderMany({
+ specs: specsFlat,
+ outputDir,
+ concurrency: 3,
+ repoRoot,
+ pathFor: (spec, idx) => ({
+ subdir: `row-${(rowIndexFor[idx] ?? idx) + 1}`,
+ filename: spec.artboards[0]!.artboard_id
+ })
+ });
+ } else {
+ result = await renderMany({
+ specs: flat!,
+ outputDir,
+ concurrency: 3,
+ repoRoot
+ });
+ }
return NextResponse.json({
exported: result.exported,
diff --git a/apps/web/app/api/generate/route.ts b/apps/web/app/api/generate/route.ts
index a6432c2..e01e4a6 100644
--- a/apps/web/app/api/generate/route.ts
+++ b/apps/web/app/api/generate/route.ts
@@ -46,7 +46,7 @@ async function ensureNodeEngine(): Promise {
return nodeEnginePromise;
}
-export async function POST() {
+export async function POST(): Promise {
if (!process.env.ANTHROPIC_API_KEY) {
const { MissingApiKeyError } = await import('@banner-studio/api-lib');
return NextResponse.json(
@@ -65,9 +65,9 @@ export async function POST() {
const repoRoot = path.resolve(process.cwd(), '..', '..');
const feedPath = path.join(repoRoot, 'feeds', 'demo.csv');
- let rows;
+ let feedRows;
try {
- rows = loadFeed(feedPath);
+ feedRows = loadFeed(feedPath);
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : String(err) },
@@ -75,7 +75,7 @@ export async function POST() {
);
}
- const results = await runWithConcurrency(rows, 3, (row, i) =>
+ const results = await runWithConcurrency(feedRows, 3, (row, i) =>
orchestrateRow({
row,
rowIndex: i,
@@ -84,10 +84,22 @@ export async function POST() {
})
);
- const specs = results
+ // Group results by row so the per-row strip UI can render one strip per
+ // feed row containing the four per-size BannerSpecs. Skipped/errored rows
+ // are surfaced separately.
+ const rows = results
.filter((r) => r.status === 'ok')
- .map((r) => (r.status === 'ok' ? r.spec : null))
- .filter((s) => s !== null);
+ .map((r) =>
+ r.status === 'ok'
+ ? {
+ rowIndex: r.rowIndex,
+ specs: r.specs,
+ rationale: r.rationale,
+ constraint_signals: r.constraint_signals
+ }
+ : null
+ )
+ .filter((x) => x !== null);
const skipped = results
.filter((r) => r.status === 'skipped')
.map((r) => (r.status === 'skipped' ? { rowIndex: r.rowIndex, reason: r.reason } : null));
@@ -95,7 +107,7 @@ export async function POST() {
.filter((r) => r.status === 'error')
.map((r) => (r.status === 'error' ? { rowIndex: r.rowIndex, error: r.error } : null));
- return NextResponse.json({ specs, skipped, errors });
+ return NextResponse.json({ rows, skipped, errors });
}
export async function GET() {
diff --git a/apps/web/app/review/ReviewClient.tsx b/apps/web/app/review/ReviewClient.tsx
index f605be0..ba85e9e 100644
--- a/apps/web/app/review/ReviewClient.tsx
+++ b/apps/web/app/review/ReviewClient.tsx
@@ -1,8 +1,9 @@
'use client';
// Producer-visible demo. Hits /api/generate on mount, lays the resulting
-// specs (and any skipped/error rows) into a grid of cards, plays each on
-// its own GSAP timeline, and opens a side drawer with AI reasoning when a
-// card is clicked.
+// per-row strips (and any skipped/error rows) into a vertical list. Each
+// strip plays all four sizes on a synced GSAP master timeline. Clicking
+// a strip opens the AiReasoningDrawer for that row (shared rationale +
+// per-size editorial decisions).
//
// SSR strategy: the parent /review/page.tsx loads this file via
// next/dynamic(ssr:false). Konva touches `window` at import time and the
@@ -13,9 +14,16 @@ import { useEffect, useRef, useState } from 'react';
import type { BannerSpec } from '@banner-studio/types';
import { ensureBrowserEngine } from '../../lib/engine';
import { BannerGrid, type GridItem } from './components/BannerGrid';
+import type { ConstraintSignal, RowOk } from './components/RowStrip';
import { AiReasoningDrawer } from './components/AiReasoningDrawer';
import styles from './styles.module.css';
+interface OkRow {
+ rowIndex: number;
+ specs: BannerSpec[];
+ rationale: BannerSpec['ai_reasoning'];
+ constraint_signals: ConstraintSignal[];
+}
interface SkippedRow {
rowIndex: number;
reason: string;
@@ -25,7 +33,7 @@ interface ErrorRow {
error: string;
}
interface GenerateResponse {
- specs: BannerSpec[];
+ rows: OkRow[];
skipped: SkippedRow[];
errors: ErrorRow[];
}
@@ -55,30 +63,43 @@ type ExportState =
| { kind: 'done'; exported: ExportedItem[]; errors: ExportErrorItem[] }
| { kind: 'error'; message: string };
-function productNameFromSpec(spec: BannerSpec, rowIndex: number): string {
- // No product_name on the spec; the headline content is the closest human
- // label we have. Fall back to row index if even that's missing.
- const ab = spec.artboards[0];
- const headline = ab?.layers.find((l) => l.layer_id === 'headline');
- return headline?.content || `Row ${rowIndex + 1}`;
+function productNameFromSpecs(specs: BannerSpec[], rowIndex: number): string {
+ // Pick the first spec's headline as the row label. All four sizes carry
+ // the same headline conceptually; we just need a human-readable handle.
+ for (const spec of specs) {
+ const ab = spec.artboards[0];
+ const headline = ab?.layers.find((l) => l.layer_id === 'headline');
+ if (headline?.content) return headline.content;
+ }
+ return `Row ${rowIndex + 1}`;
}
function buildItems(data: GenerateResponse): GridItem[] {
- // /api/generate filters orchestrator results into three parallel arrays.
- // Skipped/error rows carry an explicit rowIndex; ok rows don't, but they
- // arrive in feed-row order. Walk the rowIndex space, slotting skipped/
- // error in their declared positions and ok specs into the gaps.
+ // Three parallel arrays (ok rows, skipped, errors) carry explicit rowIndex.
+ // Walk the rowIndex space in order and slot each row into its declared
+ // position so producers see exactly which CSV row produced what.
+ const okByIdx = new Map();
+ for (const r of data.rows) okByIdx.set(r.rowIndex, r);
const skippedByIdx = new Map();
for (const s of data.skipped) skippedByIdx.set(s.rowIndex, s);
const errorByIdx = new Map();
for (const e of data.errors) errorByIdx.set(e.rowIndex, e);
- const totalRows =
- data.specs.length + data.skipped.length + data.errors.length;
-
+ const totalRows = data.rows.length + data.skipped.length + data.errors.length;
const items: GridItem[] = [];
- let nextSpec = 0;
for (let i = 0; i < totalRows; i++) {
+ const ok = okByIdx.get(i);
+ if (ok) {
+ items.push({
+ kind: 'ok',
+ rowIndex: i,
+ productName: productNameFromSpecs(ok.specs, i),
+ specs: ok.specs,
+ rationale: ok.rationale,
+ constraintSignals: ok.constraint_signals
+ });
+ continue;
+ }
const sk = skippedByIdx.get(i);
if (sk) {
items.push({
@@ -97,27 +118,18 @@ function buildItems(data: GenerateResponse): GridItem[] {
error: er.error,
productName: `Row ${i + 1}`
});
- continue;
}
- const spec = data.specs[nextSpec++];
- if (!spec) continue;
- items.push({
- kind: 'ok',
- rowIndex: i,
- spec,
- productName: productNameFromSpec(spec, i)
- });
}
return items;
}
export default function ReviewClient() {
const [status, setStatus] = useState({ kind: 'init' });
- const [selected, setSelected] = useState(null);
+ const [selectedRow, setSelectedRow] = useState(null);
const [exportState, setExportState] = useState({ kind: 'idle' });
- // Map of version_id → restart fn. Cards register on mount, deregister on
- // unmount. Play-All iterates and calls each restart fn.
+ // Map of row-key → restart fn. Each RowStrip registers on mount, deregisters
+ // on unmount. Play-All iterates and calls each restart fn.
const timelinesRef = useRef
>
);
}
+
+function RowReasoning({ row }: { row: RowOk }) {
+ // Rationale is shared across all sizes (one generate call returns one
+ // rationale block); fall back to the first spec's ai_reasoning if the
+ // row carries it separately.
+ const rationale = row.rationale ?? row.specs[0]?.ai_reasoning ?? null;
+
+ return (
+ <>
+ {/* Per-size copy comparison: a tight table is the fastest read for
+ "did the agent actually adapt the copy?" */}
+
+
+
+
+ {/* Shared rationale fields, ordered with copy_rationale first because
+ it's the most-asked-about field in review. */}
+ {rationale &&
+ SECTIONS.map(({ key, label }) => (
+
+
{label}
+
+ {rationale[key] || (empty)}
+
+
+ ))}
+
+ {/* Constraint signals: per-size cases where the layout engine had to
+ shrink copy to the legibility floor. Empty in the happy path. */}
+
+
Constraint signals
+ {row.constraintSignals.length === 0 ? (
+
+ None — every size resolved above the legibility floor.
+
- );
-}
diff --git a/apps/web/app/review/components/BannerGrid.tsx b/apps/web/app/review/components/BannerGrid.tsx
index 05702bd..ea60310 100644
--- a/apps/web/app/review/components/BannerGrid.tsx
+++ b/apps/web/app/review/components/BannerGrid.tsx
@@ -1,61 +1,30 @@
'use client';
-// Renders one card per feed row in feed-row order. ok rows get a BannerCard,
-// skipped/error rows get a gray placeholder with a status pill so producers
-// can see exactly which row was which.
+// Renders one RowStrip per feed row in row order. Skipped/error rows still
+// take a slot in the list so producers can see exactly which feed row hit a
+// failure mode and why. The strip itself owns its GSAP master timeline.
-import type { BannerSpec } from '@banner-studio/types';
-import { BannerCard } from './BannerCard';
+import { RowStrip, type RowItem, type RowOk } from './RowStrip';
import styles from '../styles.module.css';
-export type GridItem =
- | { kind: 'ok'; rowIndex: number; spec: BannerSpec; productName: string }
- | { kind: 'skipped'; rowIndex: number; reason: string; productName: string }
- | { kind: 'error'; rowIndex: number; error: string; productName: string };
+export type GridItem = RowItem;
interface Props {
items: GridItem[];
- onSelect: (spec: BannerSpec) => void;
+ onSelect: (row: RowOk) => void;
registerTimeline: (key: string, restart: (() => void) | null) => void;
}
export function BannerGrid({ items, onSelect, registerTimeline }: Props) {
return (
-