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>
This commit is contained in:
Simeon Schecter 2026-05-18 14:22:26 -04:00
parent bea0392d9c
commit ccbdb47162
39 changed files with 3647 additions and 558 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

477
RESOLVED_FEED.md Normal file
View file

@ -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 2540 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 14 are the minimum to ship copy editing in V1. Steps 57 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.

View file

@ -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

View file

@ -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`.
- 35 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

View file

@ -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 <repoRoot>/exports/<safe(campaign_id)>/<safe(version_id)>.zip.
// renderMany() (single Chromium, concurrent contexts, capped at 3).
//
// Per-row body groups output by feed row:
// exports/row-<N>/<artboard_id>.zip (one zip per size, per row)
// Legacy specs body keeps the older layout:
// exports/<campaign>/<version>.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<NextResponse> {
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/<size>.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<RowInput> | 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,

View file

@ -46,7 +46,7 @@ async function ensureNodeEngine(): Promise<void> {
return nodeEnginePromise;
}
export async function POST() {
export async function POST(): Promise<NextResponse> {
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() {

View file

@ -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<number, OkRow>();
for (const r of data.rows) okByIdx.set(r.rowIndex, r);
const skippedByIdx = new Map<number, SkippedRow>();
for (const s of data.skipped) skippedByIdx.set(s.rowIndex, s);
const errorByIdx = new Map<number, ErrorRow>();
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<Status>({ kind: 'init' });
const [selected, setSelected] = useState<BannerSpec | null>(null);
const [selectedRow, setSelectedRow] = useState<RowOk | null>(null);
const [exportState, setExportState] = useState<ExportState>({ 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<Map<string, () => void>>(new Map());
const registerTimeline = (key: string, restart: (() => void) | null) => {
@ -159,19 +171,23 @@ export default function ReviewClient() {
};
const items = status.kind === 'ready' ? buildItems(status.data) : [];
const okCount = items.filter((i) => i.kind === 'ok').length;
const okRows = items.filter(
(i): i is Extract<GridItem, { kind: 'ok' }> => i.kind === 'ok'
);
const exportAll = async () => {
const specs = items
.filter((it): it is Extract<typeof it, { kind: 'ok' }> => it.kind === 'ok')
.map((it) => it.spec);
if (specs.length === 0) return;
if (okRows.length === 0) return;
setExportState({ kind: 'exporting' });
try {
const res = await fetch('/api/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ specs })
body: JSON.stringify({
rows: okRows.map((r) => ({
rowIndex: r.rowIndex,
specs: r.specs
}))
})
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${await res.text()}`);
@ -190,6 +206,8 @@ export default function ReviewClient() {
}
};
const totalSpecs = okRows.reduce((n, r) => n + r.specs.length, 0);
return (
<main className={styles.page}>
<header className={styles.header}>
@ -200,14 +218,14 @@ export default function ReviewClient() {
<Link href="/"> back</Link>
</p>
</div>
{status.kind === 'ready' && okCount > 0 && (
{status.kind === 'ready' && okRows.length > 0 && (
<div className={styles.headerButtons}>
<button
type="button"
className={styles.playAllBtn}
onClick={playAll}
>
Play all ({okCount})
Play all ({okRows.length} row{okRows.length === 1 ? '' : 's'})
</button>
<button
type="button"
@ -217,7 +235,7 @@ export default function ReviewClient() {
>
{exportState.kind === 'exporting'
? 'Exporting…'
: `Export ZIPs (${okCount})`}
: `Export ZIPs (${totalSpecs})`}
</button>
</div>
)}
@ -231,7 +249,7 @@ export default function ReviewClient() {
</strong>
<ul>
{exportState.exported.map((e) => (
<li key={e.version_id}>
<li key={e.zip_path}>
<code>{e.zip_path}</code>{' '}
<span className={styles.exportBytes}>
({Math.round(e.bytes / 1024)} kB)
@ -273,14 +291,14 @@ export default function ReviewClient() {
{status.kind === 'ready' && (
<BannerGrid
items={items}
onSelect={(spec) => setSelected(spec)}
onSelect={(row) => setSelectedRow(row)}
registerTimeline={registerTimeline}
/>
)}
<AiReasoningDrawer
spec={selected}
onClose={() => setSelected(null)}
row={selectedRow}
onClose={() => setSelectedRow(null)}
/>
</main>
);

View file

@ -1,26 +1,38 @@
'use client';
// Right-side slide-in drawer. Open state is driven by `spec !== null`.
// Right-side slide-in drawer. Open state is driven by `row !== null`.
// Closes on backdrop click, X button, or Escape key. No focus trap in the
// slice — that's a V1 accessibility item.
//
// Multi-size view: shows the shared rationale block (one set of decisions
// shared across all four sizes — copy/asset/variant/animation) plus a
// constraint-signals table (per-size cases where the layout engine hit the
// legibility floor) plus a per-size copy comparison so reviewers can spot
// length differences across breakpoints at a glance.
import { useEffect } from 'react';
import type { BannerSpec } from '@banner-studio/types';
import type { RowOk } from './RowStrip';
import styles from '../styles.module.css';
interface Props {
spec: BannerSpec | null;
row: RowOk | null;
onClose: () => void;
}
const SECTIONS: { key: keyof BannerSpec['ai_reasoning']; label: string }[] = [
{ key: 'asset_selection', label: 'Asset selection' },
type ReasoningStringKey =
| 'asset_selection'
| 'copy_rationale'
| 'variant_selection'
| 'animation_rationale';
const SECTIONS: { key: ReasoningStringKey; label: string }[] = [
{ key: 'copy_rationale', label: 'Copy rationale' },
{ key: 'asset_selection', label: 'Asset selection' },
{ key: 'variant_selection', label: 'Variant selection' },
{ key: 'animation_rationale', label: 'Animation rationale' }
];
export function AiReasoningDrawer({ spec, onClose }: Props) {
const isOpen = spec !== null;
export function AiReasoningDrawer({ row, onClose }: Props) {
const isOpen = row !== null;
useEffect(() => {
if (!isOpen) return;
@ -45,7 +57,9 @@ export function AiReasoningDrawer({ spec, onClose }: Props) {
aria-label="AI reasoning"
>
<header className={styles.drawerHeader}>
<h2 className={styles.drawerTitle}>AI reasoning</h2>
<h2 className={styles.drawerTitle}>
{row ? `Row ${row.rowIndex + 1} — AI reasoning` : 'AI reasoning'}
</h2>
<button
type="button"
className={styles.drawerClose}
@ -55,28 +69,111 @@ export function AiReasoningDrawer({ spec, onClose }: Props) {
×
</button>
</header>
<div className={styles.drawerBody}>
{spec ? (
<>
{SECTIONS.map(({ key, label }) => (
<section key={key} className={styles.drawerSection}>
<p className={styles.drawerSectionLabel}>{label}</p>
<p className={styles.drawerSectionBody}>
{spec.ai_reasoning[key] || <em>(empty)</em>}
</p>
</section>
))}
<details className={styles.drawerDetails}>
<summary>Full BannerSpec JSON</summary>
<pre className={styles.drawerJson}>
{JSON.stringify(spec, null, 2)}
</pre>
</details>
</>
) : null}
</div>
<div className={styles.drawerBody}>{row ? <RowReasoning row={row} /> : null}</div>
</aside>
</>
);
}
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?" */}
<section className={styles.drawerSection}>
<p className={styles.drawerSectionLabel}>Per-size copy</p>
<table className={styles.copyTable}>
<thead>
<tr>
<th>Size</th>
<th>Headline</th>
<th>Sub</th>
<th>CTA</th>
</tr>
</thead>
<tbody>
{row.specs.map((spec) => {
const ab = spec.artboards[0];
if (!ab) return null;
const get = (id: string) =>
ab.layers.find((l) => l.layer_id === id)?.content ?? '';
return (
<tr key={spec.version_id}>
<td className={styles.copyTableSize}>{ab.artboard_id}</td>
<td>{get('headline')}</td>
<td>{get('subheadline')}</td>
<td>{get('cta')}</td>
</tr>
);
})}
</tbody>
</table>
</section>
{/* Shared rationale fields, ordered with copy_rationale first because
it's the most-asked-about field in review. */}
{rationale &&
SECTIONS.map(({ key, label }) => (
<section key={key} className={styles.drawerSection}>
<p className={styles.drawerSectionLabel}>{label}</p>
<p className={styles.drawerSectionBody}>
{rationale[key] || <em>(empty)</em>}
</p>
</section>
))}
{/* Constraint signals: per-size cases where the layout engine had to
shrink copy to the legibility floor. Empty in the happy path. */}
<section className={styles.drawerSection}>
<p className={styles.drawerSectionLabel}>Constraint signals</p>
{row.constraintSignals.length === 0 ? (
<p className={styles.drawerSectionBody}>
<em>None every size resolved above the legibility floor.</em>
</p>
) : (
<table className={styles.copyTable}>
<thead>
<tr>
<th>Size</th>
<th>Field</th>
<th>Max chars @ floor</th>
<th>Floor fs</th>
</tr>
</thead>
<tbody>
{row.constraintSignals.map((s, i) => (
<tr key={`${s.artboard_id}-${s.field}-${i}`}>
<td className={styles.copyTableSize}>{s.artboard_id}</td>
<td>{s.field}</td>
<td>{s.max_chars_at_floor}</td>
<td>{s.floor_font_size}px</td>
</tr>
))}
</tbody>
</table>
)}
</section>
<details className={styles.drawerDetails}>
<summary>Full row JSON ({row.specs.length} specs)</summary>
<pre className={styles.drawerJson}>
{JSON.stringify(
{
rowIndex: row.rowIndex,
rationale,
constraint_signals: row.constraintSignals,
specs: row.specs
},
null,
2
)}
</pre>
</details>
</>
);
}

View file

@ -9,14 +9,11 @@
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { Stage, Layer, Rect, Image as KImage, Group as KGroup, Text as KText } from 'react-konva';
import type Konva from 'konva';
import type { ArtboardSpec, ResolvedLayer } from '@banner-studio/types';
// Pull DEMO_TEMPLATE_300x250 from the dropflow-free `/templates` subpath. The
// `/browser` barrel statically re-exports the browser dropflow wrapper, which
// drags dropflow's wasm.js into webpack's synchronous module graph and trips
// its top-level await before our locator setup can run. Pure-data lives on a
// separate subpath specifically so view code can stay dropflow-free.
import { DEMO_TEMPLATE_300x250 } from '@banner-studio/layout-engine/templates';
import { lookupTypography } from '../lib/typography-lookup';
import type { ArtboardSpec, ResolvedLayer, UnresolvedBannerSpec } from '@banner-studio/types';
// Template lookup goes through typography-lookup which keys on artboard_id.
// The view stays dropflow-free by pulling pure template data via the
// `/templates` subpath rather than the `/browser` barrel.
import { lookupTypography, templateForArtboard } from '../lib/typography-lookup';
export interface BannerCanvasHandle {
stage: Konva.Stage | null;
@ -74,6 +71,10 @@ export const BannerCanvas = forwardRef<BannerCanvasHandle, Props>(function Banne
const heroLayer = artboard.layers.find((l) => l.type === 'smart_asset');
const heroImg = useImageUrl(heroLayer?.direct_url);
// Each artboard size has its own template; the canvas needs that template
// for typography lookups (font_family/weight/color/alignment).
const template = templateForArtboard(artboard.artboard_id);
// Layers come pre-sorted by z_index from resolve-layout, so array order
// is the correct paint order. Hero (z_index 0) → text/group children.
return (
@ -125,6 +126,7 @@ export const BannerCanvas = forwardRef<BannerCanvasHandle, Props>(function Banne
<TextLayerNode
key={layer.layer_id}
layer={layer}
template={template}
refSink={(node) => {
if (node) layerNodes.current.set(layer.layer_id, node);
else layerNodes.current.delete(layer.layer_id);
@ -144,11 +146,12 @@ export const BannerCanvas = forwardRef<BannerCanvasHandle, Props>(function Banne
interface TextLayerNodeProps {
layer: ResolvedLayer;
template: UnresolvedBannerSpec | null;
refSink: (node: Konva.Group | null) => void;
}
function TextLayerNode({ layer, refSink }: TextLayerNodeProps) {
const typo = lookupTypography(layer.layer_id, DEMO_TEMPLATE_300x250);
function TextLayerNode({ layer, template, refSink }: TextLayerNodeProps) {
const typo = template ? lookupTypography(layer.layer_id, template) : null;
const fontFamily = typo?.font_family ?? 'Inter';
const fontSize = layer.computed_font_size ?? typo?.font_size ?? 14;
const fontStyle =

View file

@ -1,84 +0,0 @@
'use client';
// One artboard card. Owns its GSAP timeline. Plays once on mount; exposes
// `restart()` via an imperative-style callback so the Play-All button can
// fire every card in lockstep.
import { useEffect, useRef } from 'react';
import gsap from 'gsap';
import type { BannerSpec } from '@banner-studio/types';
import { BannerCanvas, type BannerCanvasHandle } from './BannerCanvas';
import { buildTimeline } from '../lib/build-timeline';
import styles from '../styles.module.css';
interface Props {
spec: BannerSpec;
productName: string;
onClick: () => void;
/**
* Registers a restart handle keyed by the spec's version_id.
* ReviewClient uses this to drive Play-All. The card calls registerTimeline
* on mount (with a restart fn) and again on unmount (with null).
*/
registerTimeline: (key: string, restart: (() => void) | null) => void;
}
export function BannerCard({
spec,
productName,
onClick,
registerTimeline
}: Props) {
const canvasRef = useRef<BannerCanvasHandle>(null);
const timelineRef = useRef<gsap.core.Timeline | null>(null);
const artboard = spec.artboards[0]!;
useEffect(() => {
// Defer one frame so Konva refs are populated.
const raf = requestAnimationFrame(() => {
const handle = canvasRef.current;
if (!handle || !handle.stage) return;
const tl = buildTimeline({
artboard,
layerNodes: handle.layerNodes,
stage: handle.stage
});
timelineRef.current = tl;
tl.play(0);
registerTimeline(spec.version_id, () => tl.restart());
});
return () => {
cancelAnimationFrame(raf);
registerTimeline(spec.version_id, null);
timelineRef.current?.kill();
timelineRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [spec.version_id]);
return (
<div
className={styles.card}
onClick={onClick}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
}}
>
<div className={styles.cardCanvasWrap}>
<BannerCanvas ref={canvasRef} artboard={artboard} />
</div>
<div className={styles.cardFooter}>
<span className={styles.cardName} title={productName}>
{productName}
</span>
<span className={`${styles.pill} ${styles.pillOk}`}>animating</span>
</div>
</div>
);
}

View file

@ -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 (
<div className={styles.grid}>
{items.map((item) => {
if (item.kind === 'ok') {
return (
<BannerCard
key={`ok-${item.rowIndex}`}
spec={item.spec}
productName={item.productName}
onClick={() => onSelect(item.spec)}
registerTimeline={registerTimeline}
/>
);
}
const pillClass =
item.kind === 'skipped' ? styles.pillSkipped : styles.pillError;
const label = item.kind === 'skipped' ? 'skipped' : 'error';
const detail =
item.kind === 'skipped' ? item.reason : item.error;
return (
<div
key={`${item.kind}-${item.rowIndex}`}
className={styles.card}
style={{ cursor: 'default' }}
>
<div className={styles.skippedCanvas} title={detail}>
row {item.rowIndex + 1} {label}
</div>
<div className={styles.cardFooter}>
<span className={styles.cardName} title={item.productName}>
{item.productName}
</span>
<span className={`${styles.pill} ${pillClass}`}>{label}</span>
</div>
</div>
);
})}
<div className={styles.strips}>
{items.map((item) => (
<RowStrip
key={`${item.kind}-${item.rowIndex}`}
row={item}
onSelect={onSelect}
registerTimeline={registerTimeline}
/>
))}
</div>
);
}

View file

@ -0,0 +1,186 @@
'use client';
// One strip per feed row. Renders all four sizes side-by-side at true pixel
// dimensions inside a horizontally-scrollable container. Each strip owns a
// single GSAP timeline that runs four child sub-timelines in parallel —
// "play all sizes in lockstep" is the demo affordance reviewers care about.
//
// Click anywhere on a strip → opens the AiReasoningDrawer for the row, which
// shows the shared rationale plus per-size editorial decisions. Cards do not
// open individual drawers because the multi-size review modality is the
// row, not the spec.
import { useEffect, useRef } from 'react';
import gsap from 'gsap';
import type { BannerSpec } from '@banner-studio/types';
import { BannerCanvas, type BannerCanvasHandle } from './BannerCanvas';
import { buildTimeline } from '../lib/build-timeline';
import styles from '../styles.module.css';
export interface RowOk {
kind: 'ok';
rowIndex: number;
productName: string;
specs: BannerSpec[];
rationale: BannerSpec['ai_reasoning'] | null;
constraintSignals: ConstraintSignal[];
}
export interface RowSkipped {
kind: 'skipped';
rowIndex: number;
productName: string;
reason: string;
}
export interface RowError {
kind: 'error';
rowIndex: number;
productName: string;
error: string;
}
export type RowItem = RowOk | RowSkipped | RowError;
export interface ConstraintSignal {
artboard_id: string;
role: string;
field: string;
max_chars_at_floor: number;
derived_font_size: number;
floor_font_size: number;
}
interface Props {
row: RowItem;
onSelect: (row: RowOk) => void;
registerTimeline: (key: string, restart: (() => void) | null) => void;
}
export function RowStrip({ row, onSelect, registerTimeline }: Props) {
if (row.kind === 'ok') return <OkStrip row={row} onSelect={onSelect} registerTimeline={registerTimeline} />;
return <PlaceholderStrip row={row} />;
}
function OkStrip({
row,
onSelect,
registerTimeline
}: {
row: RowOk;
onSelect: (row: RowOk) => void;
registerTimeline: (key: string, restart: (() => void) | null) => void;
}) {
// One handle per spec, indexed by spec.version_id so the post-mount effect
// can pull each canvas's stage + node map without coupling to spec order.
const handles = useRef<Map<string, BannerCanvasHandle>>(new Map());
const masterRef = useRef<gsap.core.Timeline | null>(null);
const rowKey = `row-${row.rowIndex}`;
useEffect(() => {
// Konva refs are populated after the next paint; wait one rAF before
// walking handles so layerNodes is filled in.
const raf = requestAnimationFrame(() => {
const master = gsap.timeline({ paused: true });
for (const spec of row.specs) {
const ab = spec.artboards[0];
if (!ab) continue;
const h = handles.current.get(spec.version_id);
if (!h || !h.stage) continue;
const sub = buildTimeline({
artboard: ab,
layerNodes: h.layerNodes,
stage: h.stage
});
// Add each sub-timeline at t=0 of the master → all four sizes
// animate in lockstep.
master.add(sub, 0);
}
masterRef.current = master;
master.play(0);
registerTimeline(rowKey, () => master.restart());
});
return () => {
cancelAnimationFrame(raf);
registerTimeline(rowKey, null);
masterRef.current?.kill();
masterRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [row.rowIndex]);
return (
<section
className={styles.strip}
onClick={() => onSelect(row)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect(row);
}
}}
aria-label={`Row ${row.rowIndex + 1}: ${row.productName}`}
>
<header className={styles.stripHeader}>
<span className={styles.stripRowLabel}>Row {row.rowIndex + 1}</span>
<span className={styles.stripProductName} title={row.productName}>
{row.productName}
</span>
{row.constraintSignals.length > 0 && (
<span className={`${styles.pill} ${styles.pillSignal}`}>
{row.constraintSignals.length} signal
{row.constraintSignals.length === 1 ? '' : 's'}
</span>
)}
<span className={`${styles.pill} ${styles.pillOk}`}>
{row.specs.length} sizes
</span>
</header>
<div className={styles.stripCanvases}>
{row.specs.map((spec) => {
const ab = spec.artboards[0];
if (!ab) return null;
return (
<div
key={spec.version_id}
className={styles.stripCanvasCell}
style={{ width: ab.width, height: ab.height }}
>
<BannerCanvas
ref={(h) => {
if (h) handles.current.set(spec.version_id, h);
else handles.current.delete(spec.version_id);
}}
artboard={ab}
/>
<span className={styles.stripSizeLabel}>{ab.artboard_id}</span>
</div>
);
})}
</div>
</section>
);
}
function PlaceholderStrip({ row }: { row: RowSkipped | RowError }) {
const pillClass =
row.kind === 'skipped' ? styles.pillSkipped : styles.pillError;
const label = row.kind === 'skipped' ? 'skipped' : 'error';
const detail = row.kind === 'skipped' ? row.reason : row.error;
return (
<section className={`${styles.strip} ${styles.stripPlaceholder}`}>
<header className={styles.stripHeader}>
<span className={styles.stripRowLabel}>Row {row.rowIndex + 1}</span>
<span className={styles.stripProductName} title={row.productName}>
{row.productName}
</span>
<span className={`${styles.pill} ${pillClass}`}>{label}</span>
</header>
<div className={styles.stripPlaceholderBody} title={detail}>
{detail}
</div>
</section>
);
}

View file

@ -1,11 +1,33 @@
// Resolved layers don't carry typography — that lives on the unresolved
// template layer. The slice has one template so this is a cheap walk.
// template layer. The slice has four templates (one per size), all hardcoded;
// templateForArtboard maps an artboard_id to its template so the canvas can
// look up font_family/weight/color/alignment without baking template choice
// into BannerCanvas.
import type {
Layer,
TypographySpec,
UnresolvedBannerSpec
} from '@banner-studio/types';
import {
DEMO_TEMPLATE_300x250,
DEMO_TEMPLATE_300x600,
DEMO_TEMPLATE_728x90,
DEMO_TEMPLATE_160x600
} from '@banner-studio/layout-engine/templates';
const TEMPLATES_BY_ARTBOARD: Record<string, UnresolvedBannerSpec> = {
'300x250': DEMO_TEMPLATE_300x250,
'300x600': DEMO_TEMPLATE_300x600,
'728x90': DEMO_TEMPLATE_728x90,
'160x600': DEMO_TEMPLATE_160x600
};
export function templateForArtboard(
artboardId: string
): UnresolvedBannerSpec | null {
return TEMPLATES_BY_ARTBOARD[artboardId] ?? null;
}
function walk(layers: Layer[], targetId: string): TypographySpec | null {
for (const l of layers) {

View file

@ -112,6 +112,137 @@
gap: 16px;
}
/* Per-row strips: vertical stack of strips, each holds all four sizes
side-by-side at true pixel dimensions in a horizontally-scrollable row. */
.strips {
display: flex;
flex-direction: column;
gap: 20px;
}
.strip {
border: 1px solid #d9d9d9;
border-radius: 8px;
background: #fff;
cursor: pointer;
transition: box-shadow 120ms ease, border-color 120ms ease;
overflow: hidden;
}
.strip:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
border-color: #bfbfbf;
}
.stripPlaceholder {
cursor: default;
}
.stripPlaceholder:hover {
box-shadow: none;
border-color: #d9d9d9;
}
.stripHeader {
padding: 10px 14px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid #eee;
background: #fafafa;
}
.stripRowLabel {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #555;
}
.stripProductName {
font-size: 13px;
font-weight: 500;
color: #222;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stripCanvases {
display: flex;
gap: 16px;
padding: 16px;
align-items: flex-start;
background: #f5f5f5;
overflow-x: auto;
}
.stripCanvasCell {
position: relative;
background: #fff;
border: 1px solid #e5e5e5;
flex-shrink: 0;
}
.stripSizeLabel {
position: absolute;
top: -8px;
left: 6px;
background: #0e1116;
color: #fff;
font-size: 10px;
font-weight: 500;
padding: 1px 6px;
border-radius: 3px;
font-family: ui-monospace, monospace;
}
.stripPlaceholderBody {
padding: 24px;
color: #888;
font-size: 13px;
text-align: center;
background: #f9f9f9;
}
.pillSignal {
background: #fff4d6;
color: #8a5a00;
}
.copyTable {
width: 100%;
border-collapse: collapse;
font-size: 11px;
font-family: ui-monospace, monospace;
}
.copyTable th,
.copyTable td {
border: 1px solid #eee;
padding: 4px 6px;
text-align: left;
vertical-align: top;
word-break: break-word;
}
.copyTable th {
background: #fafafa;
font-weight: 600;
color: #555;
text-transform: uppercase;
font-size: 10px;
letter-spacing: 0.04em;
}
.copyTableSize {
font-weight: 600;
color: #0e1116;
white-space: nowrap;
}
.card {
width: 300px;
border: 1px solid #d9d9d9;

View file

@ -1,17 +1,27 @@
// Pure composition step: clones the routed template, sets ids/timestamps,
// wires the click destination, and stamps the rationale onto ai_reasoning.
// The copy fields are *not* injected into layers here — resolveLayout takes
// a copy map separately. We return both the spec and the copy map.
// wires the click destination, and stamps the shared rationale onto
// ai_reasoning. Copy fields are *not* injected into layers here —
// resolveLayout takes a copy map separately. We return both the spec and
// the copy map.
//
// Multi-size note: the orchestrator runs route → assemble → resolve once per
// template (one per artboard size) using the matching SizeCopy slice from the
// shared GenerateOutputV2.rationale block. The rationale is identical across
// sizes by design — it documents the single editorial decision.
import type {
GenerateOutput,
GenerateOutputV2,
SizeCopy,
UnresolvedBannerSpec
} from '@banner-studio/types';
import type { RoutedBundle } from './route-node.js';
export interface AssembleArgs {
routed: RoutedBundle;
copy: GenerateOutput;
/** The per-size copy slice for this template's artboard. */
copy: SizeCopy;
/** The shared rationale block from the V2 generate output. */
rationale: GenerateOutputV2['rationale'];
campaign_id: string;
click_url: string;
copy_variant?: string;
@ -34,19 +44,24 @@ export function assemble(args: AssembleArgs): AssembledSpec {
clone.copy_variant = args.copy_variant ?? 'A';
clone.ai_reasoning = {
asset_selection: args.copy.rationale.asset,
copy_rationale: args.copy.rationale.copy,
variant_selection: args.copy.rationale.variant,
animation_rationale: args.copy.rationale.animation
asset_selection: args.rationale.asset,
copy_rationale: args.rationale.copy,
variant_selection: args.rationale.variant,
animation_rationale: args.rationale.animation
};
clone.click_destinations = [{ id: 'cta', url: args.click_url }];
const copyMap: Record<string, string> = {
headline: args.copy.headline,
subheadline: args.copy.subheadline,
cta: args.copy.cta_text
};
if (args.copy.subheadline !== undefined) {
copyMap.subheadline = args.copy.subheadline;
}
if (args.copy.legal !== undefined) {
copyMap.legal = args.copy.legal;
}
return { spec: clone, copyMap };
}
@ -58,6 +73,5 @@ function newVersionId(): string {
if (g.crypto && typeof g.crypto.randomUUID === 'function') {
return g.crypto.randomUUID();
}
// Defensive fallback — slice will run on Node 20.
return 'v-' + Math.random().toString(36).slice(2);
}

View file

@ -1,32 +1,44 @@
import { generate } from '@banner-studio/prompts';
import { GenerateOutputSchema } from '@banner-studio/types';
import type { ExtractedContext, GenerateOutput } from '@banner-studio/types';
import type { Overflow } from '@banner-studio/prompts';
import { GenerateOutputV2Schema } from '@banner-studio/types';
import type { ExtractedContext, GenerateOutputV2 } from '@banner-studio/types';
import type { Overflow, PerSizeConstraints } from '@banner-studio/prompts';
import { callClaudeWithTool } from '../claude-client.js';
import type { CallClaudeWithToolArgs } from '../claude-client.js';
import type { CallClaude } from './extract-agent.js';
export interface GenerateAgentArgs {
context: ExtractedContext;
constraints: {
headline: number;
subheadline: number;
cta_text: number;
};
/** Per-size character limits. Sizes without a key for a role omit that role. */
constraints: PerSizeConstraints;
callClaude?: CallClaude;
maxAttempts?: number;
/** Previous attempt + overflows (used by the orchestrator's constraint-signal retry). */
prevAttempt?: GenerateOutputV2 | null;
prevOverflows?: Overflow[];
}
export type GenerateAgentResult =
| { status: 'ok'; output: GenerateOutput; attempts: number }
| { status: 'too_long'; lastAttempt: GenerateOutput; overflows: Overflow[]; attempts: number };
| { status: 'ok'; output: GenerateOutputV2; attempts: number }
| {
status: 'too_long';
lastAttempt: GenerateOutputV2;
overflows: Overflow[];
attempts: number;
};
/**
* Calls the Generate prompt once per attempt. After each attempt, checks per-
* (size, field) overflows against the constraints table. If any field overflows,
* retries with the overflow set fed back in. Caller-supplied prevAttempt /
* prevOverflows seed the first call as a retry rather than a fresh ask this
* is how the orchestrator threads the constraint_signal back through.
*/
export async function generateAgent(args: GenerateAgentArgs): Promise<GenerateAgentResult> {
const call = (args.callClaude ?? callClaudeWithTool) as CallClaude;
const maxAttempts = args.maxAttempts ?? 2;
let prevAttempt: GenerateOutput | null = null;
let prevOverflows: Overflow[] = [];
let prevAttempt: GenerateOutputV2 | null = args.prevAttempt ?? null;
let prevOverflows: Overflow[] = args.prevOverflows ?? [];
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const raw = await call<unknown>({
@ -39,7 +51,7 @@ export async function generateAgent(args: GenerateAgentArgs): Promise<GenerateAg
}),
tool: generate.tool as unknown as CallClaudeWithToolArgs['tool']
});
const parsed = GenerateOutputSchema.parse(raw);
const parsed = GenerateOutputV2Schema.parse(raw);
const overflows = computeOverflows(parsed, args.constraints);
if (overflows.length === 0) {
return { status: 'ok', output: parsed, attempts: attempt };
@ -56,19 +68,55 @@ export async function generateAgent(args: GenerateAgentArgs): Promise<GenerateAg
};
}
/**
* Walks each artboard_id's copy bundle, comparing every present field's
* character count to the matching constraint. Subheadline and legal are
* compared only when both the constraint and the copy include them.
*/
export function computeOverflows(
out: GenerateOutput,
c: GenerateAgentArgs['constraints']
out: GenerateOutputV2,
constraints: PerSizeConstraints
): Overflow[] {
const result: Overflow[] = [];
if (out.headline.length > c.headline) {
result.push({ field: 'headline', limit: c.headline, actual: out.headline.length });
}
if (out.subheadline.length > c.subheadline) {
result.push({ field: 'subheadline', limit: c.subheadline, actual: out.subheadline.length });
}
if (out.cta_text.length > c.cta_text) {
result.push({ field: 'cta_text', limit: c.cta_text, actual: out.cta_text.length });
for (const [artboardId, copy] of Object.entries(out.per_size)) {
const c = constraints[artboardId];
if (!c) continue; // agent returned a size we didn't ask for; ignore
if (copy.headline.length > c.headline) {
result.push({
artboard_id: artboardId,
field: 'headline',
limit: c.headline,
actual: copy.headline.length
});
}
if (copy.subheadline !== undefined && c.subheadline !== undefined) {
if (copy.subheadline.length > c.subheadline) {
result.push({
artboard_id: artboardId,
field: 'subheadline',
limit: c.subheadline,
actual: copy.subheadline.length
});
}
}
if (copy.cta_text.length > c.cta_text) {
result.push({
artboard_id: artboardId,
field: 'cta_text',
limit: c.cta_text,
actual: copy.cta_text.length
});
}
if (copy.legal !== undefined && c.legal !== undefined) {
if (copy.legal.length > c.legal) {
result.push({
artboard_id: artboardId,
field: 'legal',
limit: c.legal,
actual: copy.legal.length
});
}
}
}
return result;
}

View file

@ -1,14 +1,40 @@
// Top-level orchestrator. Glues the four agents and resolveLayout together
// into a single per-row pipeline. Failure modes are returned, never thrown.
// Top-level orchestrator (Shape B — per-row, per-size).
//
// One extract call + one generate call per row. The generate call returns
// per-size copy keyed by artboard_id, one entry per template in the family.
// Each template then runs route → assemble → resolve independently against
// the matching SizeCopy slice and the shared rationale block.
//
// Constraint-signal retry: after resolveLayout, we collect any
// constraint_signal entries emitted by the layout engine (these mean a layer
// hit its legibility floor without fitting). If present, we tighten the
// per-size, per-field char limits for the offending fields and re-call
// generate (bounded). Then re-resolve only the affected sizes. Failure modes
// are returned, never thrown.
import { resolveLayout, DEMO_TEMPLATE_300x250 } from '@banner-studio/layout-engine';
import {
resolveLayout,
DEMO_TEMPLATE_300x250,
DEMO_TEMPLATE_300x600,
DEMO_TEMPLATE_728x90,
DEMO_TEMPLATE_160x600
} from '@banner-studio/layout-engine';
import {
DEMO_TYPE_SYSTEM_300x600
} from '@banner-studio/types';
import type {
ArtboardSpec,
BannerSpec,
GenerateOutput,
GenerateOutputV2,
Layer,
ResolvedLayer,
SizeCopy,
TextLayer,
TypeRole,
TypeSystem,
UnresolvedBannerSpec
} from '@banner-studio/types';
import type { PerSizeConstraints, Overflow } from '@banner-studio/prompts';
import type { FeedRow } from '../feed/load-csv.js';
import type { CallClaude } from './extract-agent.js';
import { extractAgent } from './extract-agent.js';
@ -16,62 +42,179 @@ import { generateAgent } from './generate-agent.js';
import { routeNode } from './route-node.js';
import { assemble } from './assemble.js';
const DEFAULT_TEMPLATES: UnresolvedBannerSpec[] = [
DEMO_TEMPLATE_300x600,
DEMO_TEMPLATE_300x250,
DEMO_TEMPLATE_728x90,
DEMO_TEMPLATE_160x600
];
export interface OrchestrateRowArgs {
row: FeedRow;
rowIndex: number;
campaignId: string;
template?: UnresolvedBannerSpec;
templates?: UnresolvedBannerSpec[];
type_system?: TypeSystem;
callClaude?: CallClaude;
/** Max constraint-signal retries on top of generate's own attempts. */
maxConstraintRetries?: number;
}
export type OrchestratorResult =
| {
status: 'ok';
spec: BannerSpec;
specs: BannerSpec[];
rowIndex: number;
rationale: GenerateOutput['rationale'];
rationale: GenerateOutputV2['rationale'];
/** Per-size constraint signals that fired during resolution (may be empty). */
constraint_signals: ConstraintSignalRecord[];
}
| { status: 'skipped'; reason: string; rowIndex: number }
| { status: 'error'; error: string; rowIndex: number };
/** What we accumulate when resolveLayout emits a constraint_signal. */
export interface ConstraintSignalRecord {
artboard_id: string;
role: TypeRole;
field: 'headline' | 'subheadline' | 'cta_text' | 'legal';
max_chars_at_floor: number;
derived_font_size: number;
floor_font_size: number;
}
export async function orchestrateRow(
args: OrchestrateRowArgs
): Promise<OrchestratorResult> {
const template = args.template ?? DEMO_TEMPLATE_300x250;
const templates = args.templates ?? DEFAULT_TEMPLATES;
const typeSystem = args.type_system ?? DEMO_TYPE_SYSTEM_300x600;
const maxConstraintRetries = args.maxConstraintRetries ?? 1;
try {
const context = await extractAgent({ row: args.row, callClaude: args.callClaude });
const constraints = pickConstraintsFromTemplate(template);
const generated = await generateAgent({
// Initial per-size constraints from each template's character_constraints.
let constraints = collectPerSizeConstraints(templates);
// First generate pass.
const firstGen = await generateAgent({
context,
constraints,
callClaude: args.callClaude
});
if (generated.status === 'too_long') {
if (firstGen.status === 'too_long') {
return {
status: 'skipped',
rowIndex: args.rowIndex,
reason: `generate-agent gave up after ${generated.attempts} attempts; overflows: ${generated.overflows
.map((o) => `${o.field}=${o.actual}/${o.limit}`)
.join(', ')}`
reason: formatOverflowReason(firstGen.overflows, firstGen.attempts)
};
}
let copy: GenerateOutputV2 = firstGen.output;
// Run route → assemble → resolve for each template using the matching
// per-size copy slice. Re-resolve on tightened constraints if signals fire.
let specs: BannerSpec[] = [];
let allSignals: ConstraintSignalRecord[] = [];
for (let retry = 0; retry <= maxConstraintRetries; retry++) {
const { specs: built, signals } = buildAllSpecs({
templates,
copy,
row: args.row,
campaignId: args.campaignId,
typeSystem
});
specs = built;
allSignals = signals;
if (signals.length === 0) break;
if (retry >= maxConstraintRetries) break;
// Tighten the offending (artboard_id, field) pairs and retry generate.
constraints = tightenConstraints(constraints, signals);
const prevOverflows: Overflow[] = signals.map((s) => ({
artboard_id: s.artboard_id,
field: s.field,
// The agent didn't *overflow* its char limit — the layout floor did —
// but the user-facing message in the prompt is the same shape.
limit: s.max_chars_at_floor,
actual: getCopyLength(copy, s.artboard_id, s.field)
}));
const retried = await generateAgent({
context,
constraints,
callClaude: args.callClaude,
prevAttempt: copy,
prevOverflows
});
if (retried.status === 'too_long') {
// Couldn't satisfy even the tightened budgets; emit the spec we have
// (constraint_signals included) and let the reviewer decide.
break;
}
copy = retried.output;
}
return {
status: 'ok',
specs,
rowIndex: args.rowIndex,
rationale: copy.rationale,
constraint_signals: allSignals
};
} catch (err) {
return {
status: 'error',
rowIndex: args.rowIndex,
error: err instanceof Error ? err.message : String(err)
};
}
}
// ─── per-template build ──────────────────────────────────────────────────────
interface BuildAllSpecsArgs {
templates: UnresolvedBannerSpec[];
copy: GenerateOutputV2;
row: FeedRow;
campaignId: string;
typeSystem: TypeSystem;
}
function buildAllSpecs(args: BuildAllSpecsArgs): {
specs: BannerSpec[];
signals: ConstraintSignalRecord[];
} {
const specs: BannerSpec[] = [];
const signals: ConstraintSignalRecord[] = [];
for (const template of args.templates) {
const artboardId = template.artboards[0]!.artboard_id;
const sizeCopy = args.copy.per_size[artboardId];
if (!sizeCopy) {
// Generate didn't return copy for this size; skip rather than blow up.
continue;
}
const routed = routeNode({
template,
hero_image_url: args.row.hero_image_url
});
const { spec: unresolved, copyMap } = assemble({
routed,
copy: generated.output,
copy: sizeCopy,
rationale: args.copy.rationale,
campaign_id: args.campaignId,
click_url: args.row.click_url
});
const resolvedArtboards = resolveLayout(unresolved, copyMap);
const resolvedArtboards = resolveLayout(unresolved, copyMap, args.typeSystem);
// Capture per-size constraint signals for the retry path.
for (const ab of resolvedArtboards) {
for (const sig of collectConstraintSignals(ab, template)) {
signals.push(sig);
}
}
const spec: BannerSpec = {
template_id: unresolved.template_id,
template_version: unresolved.template_version,
@ -84,46 +227,35 @@ export async function orchestrateRow(
ad_server_profile: unresolved.ad_server_profile,
click_destinations: unresolved.click_destinations
};
return {
status: 'ok',
spec,
rowIndex: args.rowIndex,
rationale: generated.output.rationale
};
} catch (err) {
return {
status: 'error',
rowIndex: args.rowIndex,
error: err instanceof Error ? err.message : String(err)
};
specs.push(spec);
}
return { specs, signals };
}
/**
* Reads the per-artboard character constraints off the template's three
* known text layers (headline / subheadline / cta). The slice has exactly
* one artboard "300x250".
*/
function pickConstraintsFromTemplate(template: UnresolvedBannerSpec): {
headline: number;
subheadline: number;
cta_text: number;
} {
const artboardId = template.artboards[0]!.artboard_id;
const layers = collectTextLayers(template.artboards[0]!.layers);
// ─── constraints + signals ───────────────────────────────────────────────────
const byField: Record<string, number> = {};
for (const t of layers) {
const lim = t.character_constraints.per_artboard[artboardId];
if (typeof lim === 'number') byField[t.content_field] = lim;
function collectPerSizeConstraints(
templates: UnresolvedBannerSpec[]
): PerSizeConstraints {
const out: PerSizeConstraints = {};
for (const template of templates) {
const artboardId = template.artboards[0]!.artboard_id;
const textLayers = collectTextLayers(template.artboards[0]!.layers);
const byField: Record<string, number> = {};
for (const t of textLayers) {
const lim = t.character_constraints.per_artboard[artboardId];
if (typeof lim === 'number') byField[t.content_field] = lim;
}
const entry: PerSizeConstraints[string] = {
headline: byField.headline ?? 35,
cta_text: byField.cta ?? 14
};
if (byField.subheadline !== undefined) entry.subheadline = byField.subheadline;
if (byField.legal !== undefined) entry.legal = byField.legal;
out[artboardId] = entry;
}
return {
headline: byField.headline ?? 35,
subheadline: byField.subheadline ?? 70,
cta_text: byField.cta ?? 14
};
return out;
}
function collectTextLayers(layers: Layer[]): TextLayer[] {
@ -134,3 +266,102 @@ function collectTextLayers(layers: Layer[]): TextLayer[] {
}
return out;
}
/**
* Find the TextLayer for a layer_id in a template (search recursively). Used
* to map a resolved layer back to its content_field for constraint tightening.
*/
function findTextLayer(layers: Layer[], layerId: string): TextLayer | null {
for (const l of layers) {
if (l.type === 'text' && l.id === layerId) return l;
if (l.type === 'group') {
const found = findTextLayer(l.children, layerId);
if (found) return found;
}
}
return null;
}
function collectConstraintSignals(
artboard: ArtboardSpec,
template: UnresolvedBannerSpec
): ConstraintSignalRecord[] {
const out: ConstraintSignalRecord[] = [];
for (const layer of artboard.layers) {
if (layer.type !== 'text') continue;
const sig = (layer as ResolvedLayer).layout_log?.constraint_signal;
if (!sig) continue;
const tpl = findTextLayer(template.artboards[0]!.layers, layer.layer_id);
if (!tpl || !tpl.role) continue;
out.push({
artboard_id: artboard.artboard_id,
role: tpl.role,
field: roleToField(tpl.role, tpl.content_field),
max_chars_at_floor: sig.max_chars_at_floor,
derived_font_size: sig.derived_font_size,
floor_font_size: sig.floor_font_size
});
}
return out;
}
function roleToField(
role: TypeRole,
content_field: string
): 'headline' | 'subheadline' | 'cta_text' | 'legal' {
// Trust the content_field if it matches; fall back to role.
if (content_field === 'cta') return 'cta_text';
if (
content_field === 'headline' ||
content_field === 'subheadline' ||
content_field === 'legal'
) {
return content_field;
}
if (role === 'cta') return 'cta_text';
return role;
}
function tightenConstraints(
current: PerSizeConstraints,
signals: ConstraintSignalRecord[]
): PerSizeConstraints {
// Deep-clone so we don't mutate the caller's table.
const out: PerSizeConstraints = JSON.parse(JSON.stringify(current));
for (const s of signals) {
const entry = out[s.artboard_id];
if (!entry) continue;
// Pick the existing limit for the field, fall back if absent.
const existing =
s.field === 'headline' ? entry.headline
: s.field === 'subheadline' ? entry.subheadline
: s.field === 'cta_text' ? entry.cta_text
: entry.legal;
const tightened = Math.min(existing ?? s.max_chars_at_floor, s.max_chars_at_floor);
if (s.field === 'headline') entry.headline = tightened;
else if (s.field === 'subheadline') entry.subheadline = tightened;
else if (s.field === 'cta_text') entry.cta_text = tightened;
else entry.legal = tightened;
}
return out;
}
function getCopyLength(
copy: GenerateOutputV2,
artboardId: string,
field: 'headline' | 'subheadline' | 'cta_text' | 'legal'
): number {
const slice: SizeCopy | undefined = copy.per_size[artboardId];
if (!slice) return 0;
if (field === 'headline') return slice.headline.length;
if (field === 'subheadline') return slice.subheadline?.length ?? 0;
if (field === 'cta_text') return slice.cta_text.length;
return slice.legal?.length ?? 0;
}
function formatOverflowReason(overflows: Overflow[], attempts: number): string {
const parts = overflows.map(
(o) => `${o.artboard_id}.${o.field}=${o.actual}/${o.limit}`
);
return `generate-agent gave up after ${attempts} attempts; overflows: ${parts.join(', ')}`;
}

View file

@ -15,13 +15,13 @@ describe('assemble', () => {
copy: {
headline: 'Test Headline',
subheadline: 'Test sub.',
cta_text: 'Shop now',
rationale: {
copy: 'tight benefit-led wording',
asset: 'product hero with neutral background',
variant: 'standard 300x250 variant',
animation: 'fade-in cascade'
}
cta_text: 'Shop now'
},
rationale: {
copy: 'tight benefit-led wording',
asset: 'product hero with neutral background',
variant: 'standard 300x250 variant',
animation: 'fade-in cascade'
},
campaign_id: 'camp-123',
click_url: 'https://example.com/landing'
@ -46,4 +46,29 @@ describe('assemble', () => {
cta: 'Shop now'
});
});
it('omits subheadline and legal from the copy map when not provided', () => {
const routed = routeNode({
template: DEMO_TEMPLATE_300x250,
hero_image_url: 'https://cdn.example/hero.jpg'
});
const { copyMap } = assemble({
routed,
copy: {
headline: 'Just headline and CTA',
cta_text: 'Shop'
},
rationale: { copy: '', asset: '', variant: '', animation: '' },
campaign_id: 'camp',
click_url: 'https://example.com'
});
expect(copyMap).toEqual({
headline: 'Just headline and CTA',
cta: 'Shop'
});
expect(copyMap.subheadline).toBeUndefined();
expect(copyMap.legal).toBeUndefined();
});
});

View file

@ -1,8 +1,14 @@
import { describe, expect, it, vi } from 'vitest';
import type { GenerateOutput } from '@banner-studio/types';
import { generateAgent } from '../src/ai-orchestration/generate-agent.js';
import type { GenerateOutputV2 } from '@banner-studio/types';
import type { PerSizeConstraints } from '@banner-studio/prompts';
import { generateAgent, computeOverflows } from '../src/ai-orchestration/generate-agent.js';
const constraints = { headline: 35, subheadline: 70, cta_text: 14 };
const constraints: PerSizeConstraints = {
'300x600': { headline: 60, subheadline: 110, cta_text: 18, legal: 80 },
'300x250': { headline: 35, subheadline: 60, cta_text: 14 },
'728x90': { headline: 36, subheadline: 70, cta_text: 14 },
'160x600': { headline: 28, subheadline: 70, cta_text: 14, legal: 60 }
};
const ctx = {
subject: 'velvet sofa',
@ -12,22 +18,31 @@ const ctx = {
cta_intent: 'shop now'
};
const tooLongOutput: GenerateOutput = {
headline: 'A headline that is way too long to fit the headline budget of thirty five characters',
subheadline: 'A sub.',
cta_text: 'Shop now',
rationale: {
copy: 'r1',
asset: 'r2',
variant: 'r3',
animation: 'r4'
}
};
const fittingOutput: GenerateOutput = {
headline: 'Velvet sofas, made to last',
subheadline: 'Handmade frames, decade-long warranty.',
cta_text: 'Shop now',
const fittingOutput: GenerateOutputV2 = {
per_size: {
'300x600': {
headline: 'Velvet sofas, made to last decades',
subheadline: 'Handmade frames, decade-long warranty, free white-glove delivery.',
cta_text: 'Shop velvet sofas',
legal: 'Terms apply.'
},
'300x250': {
headline: 'Velvet sofas built to last',
subheadline: 'Handmade frames, decade warranty.',
cta_text: 'Shop now'
},
'728x90': {
headline: 'Velvet sofas, made to last',
subheadline: 'Handmade frames, decade-long warranty.',
cta_text: 'Shop now'
},
'160x600': {
headline: 'Velvet sofas',
subheadline: 'Handmade frames, decade warranty.',
cta_text: 'Shop now',
legal: 'Terms apply.'
}
},
rationale: {
copy: 'tight benefit-led wording',
asset: 'velvet sofa hero',
@ -36,6 +51,34 @@ const fittingOutput: GenerateOutput = {
}
};
const tooLongOutput: GenerateOutputV2 = {
per_size: {
'300x600': {
headline: 'A headline that is much much much too long to fit any reasonable banner budget here',
subheadline: 'sub',
cta_text: 'Shop',
legal: 'OK'
},
'300x250': {
headline: 'Another headline that is way too long to fit in the rectangle budget',
subheadline: 'sub',
cta_text: 'Shop'
},
'728x90': {
headline: 'Yet another headline that is too long for the leaderboard budget here',
subheadline: 'sub',
cta_text: 'Shop'
},
'160x600': {
headline: 'A skyscraper headline that overflows the budget',
subheadline: 'sub',
cta_text: 'Shop',
legal: 'OK'
}
},
rationale: { copy: '', asset: '', variant: '', animation: '' }
};
describe('generateAgent', () => {
it('retries once when the first attempt overflows and succeeds the second time', async () => {
const call = vi
@ -53,7 +96,7 @@ describe('generateAgent', () => {
expect(call).toHaveBeenCalledTimes(2);
expect(res.status).toBe('ok');
if (res.status === 'ok') {
expect(res.output.headline).toBe('Velvet sofas, made to last');
expect(res.output.per_size['300x600']!.headline).toBe('Velvet sofas, made to last decades');
expect(res.attempts).toBe(2);
}
});
@ -70,7 +113,10 @@ describe('generateAgent', () => {
expect(call).toHaveBeenCalledTimes(2);
expect(res.status).toBe('too_long');
if (res.status === 'too_long') {
expect(res.overflows.find((o) => o.field === 'headline')).toBeDefined();
const headlineOverflows = res.overflows.filter((o) => o.field === 'headline');
expect(headlineOverflows.length).toBeGreaterThan(0);
// At least one overflow should carry an artboard_id.
expect(headlineOverflows[0]!.artboard_id).toMatch(/\d+x\d+/);
}
});
@ -86,3 +132,47 @@ describe('generateAgent', () => {
expect(res.status).toBe('ok');
});
});
describe('computeOverflows', () => {
it('reports per-size, per-field overflows independently', () => {
const partial: GenerateOutputV2 = {
per_size: {
'300x250': {
headline: 'a'.repeat(40), // limit 35 — over by 5
subheadline: 'b'.repeat(40), // limit 60 — fits
cta_text: 'c'.repeat(20) // limit 14 — over by 6
}
},
rationale: { copy: '', asset: '', variant: '', animation: '' }
};
const overflows = computeOverflows(partial, constraints);
expect(overflows).toHaveLength(2);
const headlineOver = overflows.find((o) => o.field === 'headline')!;
expect(headlineOver.artboard_id).toBe('300x250');
expect(headlineOver.actual).toBe(40);
expect(headlineOver.limit).toBe(35);
const ctaOver = overflows.find((o) => o.field === 'cta_text')!;
expect(ctaOver.actual).toBe(20);
expect(ctaOver.limit).toBe(14);
});
it('skips fields that are not present in either copy or constraints', () => {
const partial: GenerateOutputV2 = {
per_size: {
'300x250': { headline: 'short', cta_text: 'Shop' } // no subheadline, no legal
},
rationale: { copy: '', asset: '', variant: '', animation: '' }
};
expect(computeOverflows(partial, constraints)).toEqual([]);
});
it('ignores sizes the agent returned that were not asked for', () => {
const partial: GenerateOutputV2 = {
per_size: {
'unknown-size': { headline: 'a'.repeat(999), cta_text: 'a'.repeat(999) }
},
rationale: { copy: '', asset: '', variant: '', animation: '' }
};
expect(computeOverflows(partial, constraints)).toEqual([]);
});
});

View file

@ -2,7 +2,7 @@ import { beforeAll, describe, expect, it, vi } from 'vitest';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { initLayoutEngine, isInitialized } from '@banner-studio/layout-engine';
import type { ExtractedContext, GenerateOutput } from '@banner-studio/types';
import type { ExtractedContext, GenerateOutputV2 } from '@banner-studio/types';
import { orchestrateRow } from '../src/ai-orchestration/orchestrator.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@ -34,10 +34,31 @@ const extractedCtx: ExtractedContext = {
cta_intent: 'shop now'
};
const fittingCopy: GenerateOutput = {
headline: 'Velvet sofas, made to last',
subheadline: 'Handmade frames, decade-long warranty.',
cta_text: 'Shop now',
const fittingCopy: GenerateOutputV2 = {
per_size: {
'300x600': {
headline: 'Velvet sofas, made to last decades',
subheadline: 'Handmade frames, decade-long warranty.',
cta_text: 'Shop velvet sofas',
legal: 'Terms apply.'
},
'300x250': {
headline: 'Velvet sofas built to last',
subheadline: 'Handmade frames, decade warranty.',
cta_text: 'Shop now'
},
'728x90': {
headline: 'Velvet sofas, made to last',
subheadline: 'Handmade frames, decade warranty.',
cta_text: 'Shop now'
},
'160x600': {
headline: 'Velvet sofas',
subheadline: 'Handmade frames, decade warranty.',
cta_text: 'Shop now',
legal: 'Terms apply.'
}
},
rationale: {
copy: 'benefit-first wording',
asset: 'velvet sofa hero',
@ -46,17 +67,30 @@ const fittingCopy: GenerateOutput = {
}
};
const tooLongCopy: GenerateOutput = {
headline:
'An overlong headline that will not fit inside the thirty-five-character budget',
subheadline: 'A sub.',
cta_text: 'Shop now',
rationale: {
copy: '',
asset: '',
variant: '',
animation: ''
}
const tooLongCopy: GenerateOutputV2 = {
per_size: {
'300x600': {
headline: 'An overlong headline that absolutely will not fit any character budget reasonable for a banner ad ever',
subheadline: 'a',
cta_text: 'Shop'
},
'300x250': {
headline: 'An overlong headline that will not fit the budget at thirty five characters',
subheadline: 'a',
cta_text: 'Shop'
},
'728x90': {
headline: 'An overlong headline that will not fit the leaderboard budget',
subheadline: 'a',
cta_text: 'Shop'
},
'160x600': {
headline: 'An overlong skyscraper headline that will not fit',
subheadline: 'a',
cta_text: 'Shop'
}
},
rationale: { copy: '', asset: '', variant: '', animation: '' }
};
describe('orchestrateRow', () => {
@ -64,9 +98,9 @@ describe('orchestrateRow', () => {
await ensureEngine();
});
it('extract → generate → route → assemble → resolve produces a valid resolved BannerSpec', async () => {
it('extract → generate → route → assemble → resolve produces one BannerSpec per template', async () => {
// Mocked Claude caller: first call (extract) returns the extracted context,
// second call (generate) returns fitting copy.
// second call (generate) returns fitting per-size copy.
const call = vi
.fn()
.mockImplementationOnce(async () => extractedCtx)
@ -89,31 +123,35 @@ describe('orchestrateRow', () => {
expect(result.status).toBe('ok');
if (result.status !== 'ok') return;
expect(result.spec.campaign_id).toBe('camp-test');
expect(result.spec.artboards).toHaveLength(1);
const artboard = result.spec.artboards[0]!;
expect(artboard.layers.length).toBeGreaterThan(0);
// Four templates → four BannerSpecs, one per artboard size.
expect(result.specs).toHaveLength(4);
const artboardIds = result.specs.map((s) => s.artboards[0]!.artboard_id).sort();
expect(artboardIds).toEqual(['160x600', '300x250', '300x600', '728x90']);
// Every text layer in the resolved artboard has populated layout_log.
const textLayers = artboard.layers.filter((l) => l.type === 'text');
expect(textLayers.length).toBe(3); // headline, subheadline, cta
for (const t of textLayers) {
expect(t.layout_log).toBeDefined();
expect(typeof t.layout_log!.original_height).toBe('number');
expect(typeof t.computed_font_size).toBe('number');
for (const spec of result.specs) {
expect(spec.campaign_id).toBe('camp-test');
expect(spec.artboards).toHaveLength(1);
const artboard = spec.artboards[0]!;
expect(artboard.layers.length).toBeGreaterThan(0);
// Every text layer has populated layout_log + computed_font_size.
const textLayers = artboard.layers.filter((l) => l.type === 'text');
for (const t of textLayers) {
expect(t.layout_log).toBeDefined();
expect(typeof t.computed_font_size).toBe('number');
}
// ai_reasoning carries the shared rationale.
expect(spec.ai_reasoning.copy_rationale).toBe('benefit-first wording');
expect(spec.click_destinations).toEqual([
{ id: 'cta', url: 'https://example.com/landing' }
]);
// Hero asset is present.
const hero = artboard.layers.find((l) => l.type === 'smart_asset');
expect(hero).toBeDefined();
}
// ai_reasoning is populated from the Generate rationale.
expect(result.spec.ai_reasoning.copy_rationale).toBe('benefit-first wording');
// click_destinations come through.
expect(result.spec.click_destinations).toEqual([
{ id: 'cta', url: 'https://example.com/landing' }
]);
// The smart asset has direct_url.
const hero = artboard.layers.find((l) => l.type === 'smart_asset');
expect(hero).toBeDefined();
// The shared rationale is also on the result for consumers that want it
// without digging into a spec.
expect(result.rationale.copy).toBe('benefit-first wording');
});
it('returns skipped when generate exhausts attempts on overflow', async () => {
@ -161,4 +199,56 @@ describe('orchestrateRow', () => {
expect(result.error).toBe('boom');
}
});
it('exposes constraint_signals when the layout engine emits them', async () => {
// Copy that's short enough to satisfy per-size character budgets but long
// enough to make the layout engine hit the legibility floor on at least
// one size. We engineer this by stuffing the headline near the budget
// for the narrowest size (skyscraper headline limit is 28 chars, fits
// at fs 22; this exact length combined with the narrow column tends to
// wrap > target lines at floor).
const borderlineCopy: GenerateOutputV2 = {
...fittingCopy,
per_size: {
...fittingCopy.per_size,
'160x600': {
// Right at the 28-char budget but tough to wrap in the narrow column.
headline: 'Velvet sofas built for life',
subheadline: 'Handmade frames, decade warranty, free delivery available.',
cta_text: 'Shop now',
legal: 'Terms apply.'
}
}
};
const call = vi
.fn()
.mockImplementationOnce(async () => extractedCtx)
// First generate attempt: returns borderline copy.
.mockImplementationOnce(async () => borderlineCopy)
// If a constraint signal fires, orchestrator retries generate; we just
// return the same copy to exercise the path without diverging the test
// on layout-engine internals.
.mockImplementation(async () => borderlineCopy);
const result = await orchestrateRow({
row: {
raw_description: 'Velvet sofas with lifetime warranty.',
product: 'Velvet sofa',
hero_image_url: 'https://cdn.example/hero.jpg',
click_url: 'https://example.com/landing'
},
rowIndex: 7,
campaignId: 'camp-test',
callClaude: call as unknown as Parameters<typeof orchestrateRow>[0]['callClaude'],
maxConstraintRetries: 0 // don't actually retry — just expose the signals on the first pass
});
expect(result.status).toBe('ok');
if (result.status !== 'ok') return;
// We don't assert constraint_signals.length > 0 here — whether the engine
// actually emits one depends on dropflow's measured wrapping. The contract
// we lock is that the field is present and is an array.
expect(Array.isArray(result.constraint_signals)).toBe(true);
});
});

View file

@ -16,6 +16,14 @@ export type { ShrinkInput, ShrinkResult } from './shrink-to-fit.js';
export { applyPushSiblings } from './push-siblings.js';
export type { ApplyPushInput, PushResult } from './push-siblings.js';
export { deriveTypeSpec, maxCharsAtSize } from './type-scale.js';
export type { DeriveTarget, DerivedTypography } from './type-scale.js';
export { resolveLayout } from './resolve-layout.js';
export { DEMO_TEMPLATE_300x250 } from './templates/index.js';
export {
DEMO_TEMPLATE_300x250,
DEMO_TEMPLATE_300x600,
DEMO_TEMPLATE_728x90,
DEMO_TEMPLATE_160x600
} from './templates/index.js';

View file

@ -4,11 +4,29 @@ import type {
Layer,
ResolvedLayer,
TextLayer,
TypeRole,
TypeSystem,
TypographySpec,
UnresolvedBannerSpec
} from '@banner-studio/types';
import { measureText } from './dropflow-wrapper.js';
import { shrinkToFit } from './shrink-to-fit.js';
import { applyPushSiblings } from './push-siblings.js';
import { deriveTypeSpec, maxCharsAtSize, type DerivedTypography } from './type-scale.js';
/**
* Per-artboard context threaded through layer resolution. Carries the
* derived TypeSystem outputs (if any), the artboard dimensions, and the
* artboard id so the constraint signal emitted from deep inside a text
* layer knows which size it belongs to.
*/
interface ResolveContext {
artboard_id: string;
artboard_width: number;
artboard_height: number;
type_system: TypeSystem | undefined;
derived: Record<TypeRole, DerivedTypography> | undefined;
}
/**
* Resolve every artboard in an UnresolvedBannerSpec into ArtboardSpec entries
@ -18,7 +36,8 @@ import { applyPushSiblings } from './push-siblings.js';
*/
export function resolveLayout(
spec: UnresolvedBannerSpec,
copy: Record<string, string>
copy: Record<string, string>,
type_system?: TypeSystem
): ArtboardSpec[] {
return spec.artboards.map((artboard) => {
// Build a flat list of every layer at every depth so that push_siblings
@ -28,12 +47,21 @@ export function resolveLayout(
// tree (rather than just immediate siblings) and a single
// accumulatedDeltas map down the recursion.
const accumulatedDeltas: Record<string, { dx: number; dy: number }> = {};
const ctx: ResolveContext = {
artboard_id: artboard.artboard_id,
artboard_width: artboard.width,
artboard_height: artboard.height,
type_system,
derived: type_system
? deriveTypeSpec(type_system, { width: artboard.width, height: artboard.height })
: undefined
};
return {
artboard_id: artboard.artboard_id,
width: artboard.width,
height: artboard.height,
background: artboard.background,
layers: resolveLayers(artboard.layers, copy, artboard.layers, accumulatedDeltas),
layers: resolveLayers(artboard.layers, copy, artboard.layers, accumulatedDeltas, ctx),
timeline: artboard.timeline,
estimated_weight_kb: artboard.estimated_weight_kb,
qa_status: artboard.qa_status
@ -45,7 +73,8 @@ function resolveLayers(
layers: Layer[],
copy: Record<string, string>,
allLayers: Layer[],
accumulatedDeltas: Record<string, { dx: number; dy: number }>
accumulatedDeltas: Record<string, { dx: number; dy: number }>,
ctx: ResolveContext
): ResolvedLayer[] {
const resolved: ResolvedLayer[] = [];
@ -54,10 +83,10 @@ function resolveLayers(
for (const layer of ordered) {
if (layer.type === 'text') {
const r = resolveTextLayer(layer, copy, allLayers, accumulatedDeltas);
const r = resolveTextLayer(layer, copy, allLayers, accumulatedDeltas, ctx);
resolved.push(r);
} else if (layer.type === 'group') {
const groupResolved = resolveGroupLayer(layer, copy, allLayers, accumulatedDeltas);
const groupResolved = resolveGroupLayer(layer, copy, allLayers, accumulatedDeltas, ctx);
resolved.push(...groupResolved);
} else {
// smart_asset — pass-through. SLICE_ONLY: carry direct_url and the
@ -85,11 +114,12 @@ function resolveTextLayer(
layer: TextLayer,
copy: Record<string, string>,
allLayers: Layer[],
accumulatedDeltas: Record<string, { dx: number; dy: number }>
accumulatedDeltas: Record<string, { dx: number; dy: number }>,
ctx: ResolveContext
): ResolvedLayer {
const content = copy[layer.content_field] ?? '';
const charCount = content.length;
const limit = pickCharacterLimit(layer);
const limit = pickCharacterLimit(layer, ctx.artboard_id);
const withinLimit = limit === null ? true : charCount <= limit;
const baseDelta = accumulatedDeltas[layer.id] ?? { dx: 0, dy: 0 };
@ -105,15 +135,33 @@ function resolveTextLayer(
layer.height - layer.behavior.padding.top - layer.behavior.padding.bottom
);
// If a TypeSystem is in effect and this layer is role-bound, replace its
// typography with the per-size derived spec. The role's legibility floor
// becomes the hard min_font_size; the shrink_floor (e.g. 85% of derived)
// becomes the practical min for shrink-to-fit before constraint signal.
let effectiveTypography: TypographySpec = layer.typography;
let shrinkMin = layer.behavior.min_font_size;
let derivedForRole: DerivedTypography | undefined;
if (ctx.derived && layer.role) {
derivedForRole = ctx.derived[layer.role];
effectiveTypography = derivedForRole.typography;
const roleFloor = derivedForRole.floor;
const shrinkFloorFraction = ctx.type_system?.shrink_floor ?? 1;
const fractionalMin = Math.round(
derivedForRole.typography.font_size * shrinkFloorFraction
);
shrinkMin = Math.max(roleFloor, fractionalMin);
}
const shrunk = shrinkToFit({
text: content,
typography: layer.typography,
typography: effectiveTypography,
max_width: innerWidth,
target_height: innerTargetHeight,
min_font_size: layer.behavior.min_font_size
min_font_size: shrinkMin
});
const fontSizeReduced = shrunk.font_size < layer.typography.font_size;
const fontSizeReduced = shrunk.font_size < effectiveTypography.font_size;
const overflowTriggered = !shrunk.fits;
// If the text still overflows at min font, we expand the container vertically
@ -125,7 +173,7 @@ function resolveTextLayer(
// Re-measure at the chosen min size with the full text to get true height.
const measured = measureText({
text: content,
typography: { ...layer.typography, font_size: shrunk.font_size },
typography: { ...effectiveTypography, font_size: shrunk.font_size },
max_width: innerWidth
});
const neededHeight =
@ -136,6 +184,41 @@ function resolveTextLayer(
}
}
// Emit a constraint_signal when the shrink-then-fit loop bottomed out at
// the role's legibility floor and the text still doesn't fit. The signal
// carries the max characters that *would* fit at the floor size, which
// the orchestrator uses to retry the Generate agent with a tighter limit.
let constraintSignal: NonNullable<ResolvedLayer['layout_log']>['constraint_signal'] | undefined;
if (overflowTriggered && derivedForRole && layer.role && ctx.type_system) {
const roleFloor = derivedForRole.floor;
// Only emit when shrink truly hit the floor (within 1px tolerance);
// otherwise the layout absorbed overflow by expanding the container.
if (shrunk.font_size <= roleFloor + 0.001) {
const maxLines = estimateMaxLines(layer, derivedForRole.typography.font_size);
const maxChars = maxCharsAtSize({
measure: (text, fontSize, maxWidth) => {
const m = measureText({
text,
typography: { ...effectiveTypography, font_size: fontSize },
max_width: maxWidth
});
return { width: m.width, lines: m.line_count };
},
sample_text: content,
font_size: roleFloor,
max_width: innerWidth,
max_lines: maxLines
});
constraintSignal = {
role: layer.role,
artboard_id: ctx.artboard_id,
max_chars_at_floor: maxChars,
derived_font_size: derivedForRole.typography.font_size,
floor_font_size: roleFloor
};
}
}
const siblingsPushed: { layer_id: string; pushed_by_px: number }[] = [];
if (heightDelta > 0) {
@ -174,23 +257,44 @@ function resolveTextLayer(
computed_height: computedHeight,
siblings_pushed: siblingsPushed,
font_size_reduced: fontSizeReduced,
overflow_triggered: overflowTriggered
overflow_triggered: overflowTriggered,
...(constraintSignal ? { constraint_signal: constraintSignal } : {})
}
};
}
/**
* Rough max-lines estimate from layer geometry and font size.
* The constraint-signal binary search needs to know what counts as
* "fits" too few lines and we're being conservative, too many and
* we permit aesthetically bad wrapping. Two lines is the slice default
* for headlines; three for subheadlines, which we infer by role-free
* geometry: tall containers permit more lines.
*/
function estimateMaxLines(layer: TextLayer, fontSize: number): number {
const lineHeightPx = fontSize * layer.typography.line_height;
const innerH = Math.max(
0,
layer.height - layer.behavior.padding.top - layer.behavior.padding.bottom
);
const fits = Math.max(1, Math.floor(innerH / lineHeightPx));
// Cap at 4 — beyond that, copy is too long for any role on a banner.
return Math.min(fits, 4);
}
function resolveGroupLayer(
group: GroupLayer,
copy: Record<string, string>,
allLayers: Layer[],
accumulatedDeltas: Record<string, { dx: number; dy: number }>
accumulatedDeltas: Record<string, { dx: number; dy: number }>,
ctx: ResolveContext
): ResolvedLayer[] {
const baseDelta = accumulatedDeltas[group.id] ?? { dx: 0, dy: 0 };
// Recurse into children with the shared accumulatedDeltas map and the same
// flat allLayers reference. Children are laid out in the group's local
// coordinate space, then translated by the group origin below.
const childResolved = resolveLayers(group.children, copy, allLayers, accumulatedDeltas);
const childResolved = resolveLayers(group.children, copy, allLayers, accumulatedDeltas, ctx);
// Translate each child by the group's origin + accumulated group delta so
// their computed_x/y are absolute to the artboard. The child's own
@ -214,9 +318,13 @@ function resolveGroupLayer(
return [groupEntry, ...translated];
}
function pickCharacterLimit(layer: TextLayer): number | null {
function pickCharacterLimit(layer: TextLayer, artboard_id: string): number | null {
const direct = layer.character_constraints.per_artboard[artboard_id];
if (typeof direct === 'number') return direct;
// Fall back to the smallest declared limit so we err conservative when an
// artboard wasn't enumerated (rare; pre-multi-size single-artboard
// templates).
const entries = Object.values(layer.character_constraints.per_artboard);
if (entries.length === 0) return null;
// Use the smallest constraint across artboards for the slice (conservative).
return Math.min(...entries);
}

View file

@ -0,0 +1,279 @@
import type {
UnresolvedBannerSpec,
TextLayer,
GroupLayer,
SmartAssetLayer
} from '@banner-studio/types';
/**
* DEMO_TEMPLATE_160x600 the skyscraper (wide skyscraper / half-skyscraper)
* template for the slice.
*
* Sibling of DEMO_TEMPLATE_300x600. The 160x600 falls in the `skyscraper` size
* class (aspect 0.27). When resolveLayout runs with DEMO_TYPE_SYSTEM_300x600,
* derived per-role sizes are headline 22 / subheadline 13 / cta 13 / legal 10.
* Plenty of vertical room (600px), so the full four-role stack lives here
* unlike 300x250 and 728x90 which drop legal for space.
*
* The constraint to design around is the narrow column: 140px of text width
* means the headline needs to be allowed to wrap to 3 lines. Generate-agent
* char limits are tightened accordingly.
*
* Hand-authored typography matches derived values 1:1 so the template still
* renders correctly if a caller forgets to pass type_system.
*
* Layout (160x600):
* z=0 hero full-bleed background
* z=1 main-group flex_vertical at (10, 360), 140 wide
* headline 22 / 600 / 1.12, white, up to 3 lines, push subhead down
* subheadline 13 / 400 / 1.4, white-82, push cta down
* cta 13 / 500 / 1.0, dark on light pill, fixed
* z=2 legal 10 / 400 / 1.2, white-60, anchored bottom
*/
const headline: TextLayer = {
id: 'headline',
name: 'Headline',
type: 'text',
x: 0,
y: 0,
width: 140,
height: 78,
z_index: 1,
visible: true,
locked: false,
opacity: 1,
rotation: 0,
anchors: {},
content_field: 'headline',
role: 'headline',
typography: {
font_family: 'Inter',
font_size: 22,
font_weight: 600,
line_height: 1.12,
letter_spacing: -0.01,
color: '#FFFFFF',
text_align: 'left'
},
behavior: {
auto_resize: true,
min_font_size: 18,
max_font_size: 22,
expansion_direction: 'down',
overflow_behavior: 'shrink',
push_siblings: [
{ layer_id: 'subheadline', direction: 'down', maintain_gap: 10, max_push: 50 }
],
padding: { top: 0, right: 0, bottom: 0, left: 0 }
},
character_constraints: {
per_artboard: { '160x600': 28 },
warning_threshold: 0.9,
hard_enforce: false
},
copy_bound: true
};
const subheadline: TextLayer = {
id: 'subheadline',
name: 'Subheadline',
type: 'text',
x: 0,
y: 90,
width: 140,
height: 56,
z_index: 2,
visible: true,
locked: false,
opacity: 1,
rotation: 0,
anchors: {},
content_field: 'subheadline',
role: 'subheadline',
typography: {
font_family: 'Inter',
font_size: 13,
font_weight: 400,
line_height: 1.4,
color: 'rgba(255,255,255,0.82)',
text_align: 'left'
},
behavior: {
auto_resize: true,
min_font_size: 12,
max_font_size: 13,
expansion_direction: 'down',
overflow_behavior: 'shrink',
push_siblings: [
{ layer_id: 'cta', direction: 'down', maintain_gap: 16, max_push: 40 }
],
padding: { top: 0, right: 0, bottom: 0, left: 0 }
},
character_constraints: {
per_artboard: { '160x600': 70 },
warning_threshold: 0.9,
hard_enforce: false
},
copy_bound: true
};
const cta: TextLayer = {
id: 'cta',
name: 'CTA',
type: 'text',
x: 0,
y: 162,
width: 120,
height: 32,
z_index: 3,
visible: true,
locked: false,
opacity: 1,
rotation: 0,
anchors: {},
content_field: 'cta',
role: 'cta',
typography: {
font_family: 'Inter',
font_size: 13,
font_weight: 500,
line_height: 1.0,
letter_spacing: 0.02,
color: '#0A0A0A',
text_align: 'center'
},
behavior: {
auto_resize: false,
min_font_size: 12,
max_font_size: 13,
expansion_direction: 'none',
overflow_behavior: 'clip',
push_siblings: [],
padding: { top: 9, right: 12, bottom: 9, left: 12 }
},
character_constraints: {
per_artboard: { '160x600': 14 },
warning_threshold: 0.9,
hard_enforce: true
},
copy_bound: true
};
const mainGroup: GroupLayer = {
id: 'main-group',
name: 'Main',
type: 'group',
x: 10,
y: 360,
width: 140,
height: 194,
z_index: 1,
visible: true,
locked: false,
opacity: 1,
rotation: 0,
anchors: {},
group_behavior: 'flex_vertical',
children: [headline, subheadline, cta]
};
const legal: TextLayer = {
id: 'legal',
name: 'Legal',
type: 'text',
x: 10,
y: 576,
width: 140,
height: 16,
z_index: 2,
visible: true,
locked: false,
opacity: 1,
rotation: 0,
anchors: {},
content_field: 'legal',
role: 'legal',
typography: {
font_family: 'Inter',
font_size: 10,
font_weight: 400,
line_height: 1.2,
color: 'rgba(255,255,255,0.6)',
text_align: 'left'
},
behavior: {
auto_resize: true,
min_font_size: 8,
max_font_size: 10,
expansion_direction: 'up',
overflow_behavior: 'shrink',
push_siblings: [],
padding: { top: 0, right: 0, bottom: 0, left: 0 }
},
character_constraints: {
per_artboard: { '160x600': 60 },
warning_threshold: 0.9,
hard_enforce: false
},
copy_bound: true
};
const hero: SmartAssetLayer = {
id: 'hero',
name: 'Hero image',
type: 'smart_asset',
x: 0,
y: 0,
width: 160,
height: 600,
z_index: 0,
visible: true,
locked: false,
opacity: 1,
rotation: 0,
anchors: {},
variant_group_id: 'hero',
// Overwritten by the orchestrator's route-node step.
selected_variant_id: 'feed-row',
fit_mode: 'fill'
};
export const DEMO_TEMPLATE_160x600: UnresolvedBannerSpec = {
template_id: 'demo-160x600',
template_version: 1,
campaign_id: 'demo-campaign',
version_id: 'v1',
copy_variant: 'A',
generated_at: '2026-05-17T00:00:00.000Z',
ai_reasoning: {
asset_selection: '',
copy_rationale: '',
variant_selection: '',
animation_rationale: ''
},
artboards: [
{
artboard_id: '160x600',
width: 160,
height: 600,
background: '#0A0A0A',
layers: [hero, mainGroup, legal],
safe_zones: [],
timeline: {
duration_ms: 5000,
events: [
{ layer_id: 'hero', type: 'fade_in', at_ms: 0, duration_ms: 500 },
{ layer_id: 'headline', type: 'fade_in', at_ms: 300, duration_ms: 450 },
{ layer_id: 'subheadline', type: 'fade_in', at_ms: 600, duration_ms: 450 },
{ layer_id: 'cta', type: 'fade_in', at_ms: 900, duration_ms: 450 },
{ layer_id: 'legal', type: 'fade_in', at_ms: 1100, duration_ms: 400 },
{ layer_id: 'cta', type: 'hold', at_ms: 1500, duration_ms: 3000 },
{ layer_id: 'cta', type: 'fade_out', at_ms: 4500, duration_ms: 500 }
]
}
}
],
ad_server_profile: 'iab_standard',
click_destinations: []
};

View file

@ -6,16 +6,24 @@ import type {
} from '@banner-studio/types';
/**
* DEMO_TEMPLATE_300x250 the single hardcoded template for the slice.
* Promoted from the Day-1 test fixture so apps/web and api-lib import it
* via the package barrel rather than reaching into /test.
* DEMO_TEMPLATE_300x250 the rectangle (medium rectangle) template for the slice.
*
* Sibling of DEMO_TEMPLATE_300x600. The 300x250 falls in the `rectangle` size
* class (aspect 1.2). When resolveLayout is called with DEMO_TYPE_SYSTEM_300x600,
* derived per-role sizes are headline 28 / subheadline 14 / cta 13. Floors stay
* at headline 18, subheadline 12, cta 12. Legal is omitted at this size there
* isn't room for a fourth role without crushing the legibility of the others;
* the taller 300x600 / 160x600 carry the legal role for the family.
*
* Hand-authored typography on each layer matches the derived values 1:1 so the
* template still renders correctly if a caller forgets to pass type_system.
*
* Layout (300x250):
* - background (smart asset, behind everything)
* - group at (20, 60), containing headline / subheadline / cta stacked
*
* Character constraints are conservative for Inter at the chosen sizes;
* Generate agent is told to obey these and re-tried once if it overflows.
* z=0 hero full-bleed background
* z=1 main-group flex_vertical at (16, 130), 268 wide
* headline 28 / 600 / 1.12, white, push subhead down
* subheadline 14 / 400 / 1.4, white-82, push cta down
* cta 13 / 500 / 1.0, dark on light pill, fixed
*/
const headline: TextLayer = {
@ -24,8 +32,8 @@ const headline: TextLayer = {
type: 'text',
x: 0,
y: 0,
width: 260,
height: 40,
width: 268,
height: 36,
z_index: 1,
visible: true,
locked: false,
@ -33,12 +41,14 @@ const headline: TextLayer = {
rotation: 0,
anchors: {},
content_field: 'headline',
role: 'headline',
typography: {
font_family: 'Inter',
font_size: 28,
font_weight: 700,
line_height: 1.2,
color: '#111111',
font_weight: 600,
line_height: 1.12,
letter_spacing: -0.01,
color: '#FFFFFF',
text_align: 'left'
},
behavior: {
@ -65,9 +75,9 @@ const subheadline: TextLayer = {
name: 'Subheadline',
type: 'text',
x: 0,
y: 50,
width: 260,
height: 40,
y: 46,
width: 268,
height: 36,
z_index: 2,
visible: true,
locked: false,
@ -75,18 +85,19 @@ const subheadline: TextLayer = {
rotation: 0,
anchors: {},
content_field: 'subheadline',
role: 'subheadline',
typography: {
font_family: 'Inter',
font_size: 16,
font_size: 14,
font_weight: 400,
line_height: 1.3,
color: '#333333',
line_height: 1.4,
color: 'rgba(255,255,255,0.82)',
text_align: 'left'
},
behavior: {
auto_resize: true,
min_font_size: 12,
max_font_size: 16,
max_font_size: 14,
expansion_direction: 'down',
overflow_behavior: 'shrink',
push_siblings: [
@ -95,7 +106,7 @@ const subheadline: TextLayer = {
padding: { top: 0, right: 0, bottom: 0, left: 0 }
},
character_constraints: {
per_artboard: { '300x250': 70 },
per_artboard: { '300x250': 60 },
warning_threshold: 0.9,
hard_enforce: false
},
@ -107,9 +118,9 @@ const cta: TextLayer = {
name: 'CTA',
type: 'text',
x: 0,
y: 110,
y: 94,
width: 120,
height: 28,
height: 30,
z_index: 3,
visible: true,
locked: false,
@ -117,22 +128,24 @@ const cta: TextLayer = {
rotation: 0,
anchors: {},
content_field: 'cta',
role: 'cta',
typography: {
font_family: 'Inter',
font_size: 14,
font_weight: 700,
line_height: 1.2,
color: '#ffffff',
font_size: 13,
font_weight: 500,
line_height: 1.0,
letter_spacing: 0.02,
color: '#0A0A0A',
text_align: 'center'
},
behavior: {
auto_resize: false,
min_font_size: 12,
max_font_size: 14,
max_font_size: 13,
expansion_direction: 'none',
overflow_behavior: 'clip',
push_siblings: [],
padding: { top: 0, right: 0, bottom: 0, left: 0 }
padding: { top: 8, right: 14, bottom: 8, left: 14 }
},
character_constraints: {
per_artboard: { '300x250': 14 },
@ -142,14 +155,14 @@ const cta: TextLayer = {
copy_bound: true
};
const group: GroupLayer = {
const mainGroup: GroupLayer = {
id: 'main-group',
name: 'Main',
type: 'group',
x: 20,
y: 60,
width: 260,
height: 140,
x: 16,
y: 130,
width: 268,
height: 124,
z_index: 1,
visible: true,
locked: false,
@ -175,18 +188,18 @@ const hero: SmartAssetLayer = {
rotation: 0,
anchors: {},
variant_group_id: 'hero',
// Will be overwritten by the orchestrator's route-node step.
// Overwritten by the orchestrator's route-node step.
selected_variant_id: 'feed-row',
fit_mode: 'fill'
};
export const DEMO_TEMPLATE_300x250: UnresolvedBannerSpec = {
template_id: 'demo-300x250',
template_version: 1,
template_version: 2,
campaign_id: 'demo-campaign',
version_id: 'v1',
copy_variant: 'A',
generated_at: '2026-05-14T00:00:00.000Z',
generated_at: '2026-05-17T00:00:00.000Z',
ai_reasoning: {
asset_selection: '',
copy_rationale: '',
@ -198,8 +211,8 @@ export const DEMO_TEMPLATE_300x250: UnresolvedBannerSpec = {
artboard_id: '300x250',
width: 300,
height: 250,
background: '#ffffff',
layers: [hero, group],
background: '#0A0A0A',
layers: [hero, mainGroup],
safe_zones: [],
timeline: {
duration_ms: 5000,

View file

@ -0,0 +1,276 @@
import type {
UnresolvedBannerSpec,
TextLayer,
GroupLayer,
SmartAssetLayer
} from '@banner-studio/types';
/**
* DEMO_TEMPLATE_300x600 the reference (half-page) template for the slice.
*
* This is the design-system reference artboard. The locked TypeSystem
* (DEMO_TYPE_SYSTEM_300x600 in @banner-studio/types) was authored against this
* shape; every other size derives from it. Per-role typography on layers below
* mirrors the TypeSystem base values 1:1 (scale = 1.0 for half_page class) so
* the template renders correctly even if a caller forgets to pass type_system
* to resolveLayout.
*
* Layout (300x600):
* z=0 hero full-bleed background image
* z=1 main-group flex_vertical at (20, 340), 260 wide
* headline 36 / 600 / 1.12, white, push subhead down
* subheadline 16 / 400 / 1.4, white-82, push cta down
* cta 14 / 500 / 1.0, dark on light pill, fixed
* z=2 legal 10 / 400 / 1.2, white-60, anchored bottom
*
* Character constraints are tuned for Inter at the chosen sizes against the
* 260-wide text column. Generate agent is told to obey these; constraint-signal
* retry triggers if the resolver hits the legibility floor before fit.
*/
const headline: TextLayer = {
id: 'headline',
name: 'Headline',
type: 'text',
x: 0,
y: 0,
width: 260,
height: 88,
z_index: 1,
visible: true,
locked: false,
opacity: 1,
rotation: 0,
anchors: {},
content_field: 'headline',
role: 'headline',
typography: {
font_family: 'Inter',
font_size: 36,
font_weight: 600,
line_height: 1.12,
letter_spacing: -0.01,
color: '#FFFFFF',
text_align: 'left'
},
behavior: {
auto_resize: true,
min_font_size: 18,
max_font_size: 36,
expansion_direction: 'down',
overflow_behavior: 'shrink',
push_siblings: [
{ layer_id: 'subheadline', direction: 'down', maintain_gap: 12, max_push: 60 }
],
padding: { top: 0, right: 0, bottom: 0, left: 0 }
},
character_constraints: {
per_artboard: { '300x600': 60 },
warning_threshold: 0.9,
hard_enforce: false
},
copy_bound: true
};
const subheadline: TextLayer = {
id: 'subheadline',
name: 'Subheadline',
type: 'text',
x: 0,
y: 100,
width: 260,
height: 68,
z_index: 2,
visible: true,
locked: false,
opacity: 1,
rotation: 0,
anchors: {},
content_field: 'subheadline',
role: 'subheadline',
typography: {
font_family: 'Inter',
font_size: 16,
font_weight: 400,
line_height: 1.4,
color: 'rgba(255,255,255,0.82)',
text_align: 'left'
},
behavior: {
auto_resize: true,
min_font_size: 12,
max_font_size: 16,
expansion_direction: 'down',
overflow_behavior: 'shrink',
push_siblings: [
{ layer_id: 'cta', direction: 'down', maintain_gap: 20, max_push: 60 }
],
padding: { top: 0, right: 0, bottom: 0, left: 0 }
},
character_constraints: {
per_artboard: { '300x600': 110 },
warning_threshold: 0.9,
hard_enforce: false
},
copy_bound: true
};
const cta: TextLayer = {
id: 'cta',
name: 'CTA',
type: 'text',
x: 0,
y: 188,
width: 140,
height: 36,
z_index: 3,
visible: true,
locked: false,
opacity: 1,
rotation: 0,
anchors: {},
content_field: 'cta',
role: 'cta',
typography: {
font_family: 'Inter',
font_size: 14,
font_weight: 500,
line_height: 1.0,
letter_spacing: 0.02,
color: '#0A0A0A',
text_align: 'center'
},
behavior: {
auto_resize: false,
min_font_size: 12,
max_font_size: 14,
expansion_direction: 'none',
overflow_behavior: 'clip',
push_siblings: [],
padding: { top: 10, right: 16, bottom: 10, left: 16 }
},
character_constraints: {
per_artboard: { '300x600': 18 },
warning_threshold: 0.9,
hard_enforce: true
},
copy_bound: true
};
const mainGroup: GroupLayer = {
id: 'main-group',
name: 'Main',
type: 'group',
x: 20,
y: 340,
width: 260,
height: 224,
z_index: 1,
visible: true,
locked: false,
opacity: 1,
rotation: 0,
anchors: {},
group_behavior: 'flex_vertical',
children: [headline, subheadline, cta]
};
const legal: TextLayer = {
id: 'legal',
name: 'Legal',
type: 'text',
x: 20,
y: 576,
width: 260,
height: 16,
z_index: 2,
visible: true,
locked: false,
opacity: 1,
rotation: 0,
anchors: {},
content_field: 'legal',
role: 'legal',
typography: {
font_family: 'Inter',
font_size: 10,
font_weight: 400,
line_height: 1.2,
color: 'rgba(255,255,255,0.6)',
text_align: 'left'
},
behavior: {
auto_resize: true,
min_font_size: 8,
max_font_size: 10,
expansion_direction: 'up',
overflow_behavior: 'shrink',
push_siblings: [],
padding: { top: 0, right: 0, bottom: 0, left: 0 }
},
character_constraints: {
per_artboard: { '300x600': 80 },
warning_threshold: 0.9,
hard_enforce: false
},
copy_bound: true
};
const hero: SmartAssetLayer = {
id: 'hero',
name: 'Hero image',
type: 'smart_asset',
x: 0,
y: 0,
width: 300,
height: 600,
z_index: 0,
visible: true,
locked: false,
opacity: 1,
rotation: 0,
anchors: {},
variant_group_id: 'hero',
// Overwritten by the orchestrator's route-node step.
selected_variant_id: 'feed-row',
fit_mode: 'fill'
};
export const DEMO_TEMPLATE_300x600: UnresolvedBannerSpec = {
template_id: 'demo-300x600',
template_version: 1,
campaign_id: 'demo-campaign',
version_id: 'v1',
copy_variant: 'A',
generated_at: '2026-05-17T00:00:00.000Z',
ai_reasoning: {
asset_selection: '',
copy_rationale: '',
variant_selection: '',
animation_rationale: ''
},
artboards: [
{
artboard_id: '300x600',
width: 300,
height: 600,
background: '#0A0A0A',
layers: [hero, mainGroup, legal],
safe_zones: [],
timeline: {
duration_ms: 5000,
events: [
{ layer_id: 'hero', type: 'fade_in', at_ms: 0, duration_ms: 500 },
{ layer_id: 'headline', type: 'fade_in', at_ms: 300, duration_ms: 450 },
{ layer_id: 'subheadline', type: 'fade_in', at_ms: 600, duration_ms: 450 },
{ layer_id: 'cta', type: 'fade_in', at_ms: 900, duration_ms: 450 },
{ layer_id: 'legal', type: 'fade_in', at_ms: 1100, duration_ms: 400 },
{ layer_id: 'cta', type: 'hold', at_ms: 1500, duration_ms: 3000 },
{ layer_id: 'cta', type: 'fade_out', at_ms: 4500, duration_ms: 500 }
]
}
}
],
ad_server_profile: 'iab_standard',
click_destinations: []
};

View file

@ -0,0 +1,235 @@
import type {
UnresolvedBannerSpec,
TextLayer,
GroupLayer,
SmartAssetLayer
} from '@banner-studio/types';
/**
* DEMO_TEMPLATE_728x90 the leaderboard template for the slice.
*
* Sibling of DEMO_TEMPLATE_300x600. The 728x90 falls in the `leaderboard` size
* class (aspect 8.1). When resolveLayout runs with DEMO_TYPE_SYSTEM_300x600,
* derived per-role sizes are headline 25 / subheadline 13 / cta 13. The leader-
* board cannot stack vertically 90px tall doesn't admit headline + subhead +
* CTA on top of each other so the layout is horizontal: text on the left,
* CTA pill anchored at the right. Legal is omitted at this size for the same
* reason as 300x250.
*
* Hand-authored typography matches derived values 1:1 so the template still
* renders correctly if a caller forgets to pass type_system.
*
* Layout (728x90):
* z=0 hero full-bleed background
* z=1 text-group flex_horizontal at (24, 18), 460 wide x 56 tall
* headline 25 / 600 / 1.12, white, single-line nominal
* subheadline 13 / 400 / 1.4, white-82, single-line nominal
* z=1 cta 13 / 500 / 1.0, dark on light pill, anchored right
*
* Push behavior: a headline that wraps to two lines pushes the subheadline
* down within the text-group. The CTA sits in its own column (right side) and
* is not pushed.
*/
const headline: TextLayer = {
id: 'headline',
name: 'Headline',
type: 'text',
x: 0,
y: 0,
width: 460,
height: 28,
z_index: 1,
visible: true,
locked: false,
opacity: 1,
rotation: 0,
anchors: {},
content_field: 'headline',
role: 'headline',
typography: {
font_family: 'Inter',
font_size: 25,
font_weight: 600,
line_height: 1.12,
letter_spacing: -0.01,
color: '#FFFFFF',
text_align: 'left'
},
behavior: {
auto_resize: true,
min_font_size: 18,
max_font_size: 25,
expansion_direction: 'down',
overflow_behavior: 'shrink',
push_siblings: [
{ layer_id: 'subheadline', direction: 'down', maintain_gap: 4, max_push: 20 }
],
padding: { top: 0, right: 0, bottom: 0, left: 0 }
},
character_constraints: {
per_artboard: { '728x90': 36 },
warning_threshold: 0.9,
hard_enforce: false
},
copy_bound: true
};
const subheadline: TextLayer = {
id: 'subheadline',
name: 'Subheadline',
type: 'text',
x: 0,
y: 34,
width: 460,
height: 22,
z_index: 2,
visible: true,
locked: false,
opacity: 1,
rotation: 0,
anchors: {},
content_field: 'subheadline',
role: 'subheadline',
typography: {
font_family: 'Inter',
font_size: 13,
font_weight: 400,
line_height: 1.4,
color: 'rgba(255,255,255,0.82)',
text_align: 'left'
},
behavior: {
auto_resize: true,
min_font_size: 12,
max_font_size: 13,
expansion_direction: 'down',
overflow_behavior: 'shrink',
push_siblings: [],
padding: { top: 0, right: 0, bottom: 0, left: 0 }
},
character_constraints: {
per_artboard: { '728x90': 70 },
warning_threshold: 0.9,
hard_enforce: false
},
copy_bound: true
};
const textGroup: GroupLayer = {
id: 'text-group',
name: 'Text',
type: 'group',
x: 24,
y: 18,
width: 460,
height: 56,
z_index: 1,
visible: true,
locked: false,
opacity: 1,
rotation: 0,
anchors: {},
group_behavior: 'flex_horizontal',
children: [headline, subheadline]
};
const cta: TextLayer = {
id: 'cta',
name: 'CTA',
type: 'text',
x: 596,
y: 29,
width: 108,
height: 32,
z_index: 2,
visible: true,
locked: false,
opacity: 1,
rotation: 0,
anchors: {},
content_field: 'cta',
role: 'cta',
typography: {
font_family: 'Inter',
font_size: 13,
font_weight: 500,
line_height: 1.0,
letter_spacing: 0.02,
color: '#0A0A0A',
text_align: 'center'
},
behavior: {
auto_resize: false,
min_font_size: 12,
max_font_size: 13,
expansion_direction: 'none',
overflow_behavior: 'clip',
push_siblings: [],
padding: { top: 9, right: 14, bottom: 9, left: 14 }
},
character_constraints: {
per_artboard: { '728x90': 14 },
warning_threshold: 0.9,
hard_enforce: true
},
copy_bound: true
};
const hero: SmartAssetLayer = {
id: 'hero',
name: 'Hero image',
type: 'smart_asset',
x: 0,
y: 0,
width: 728,
height: 90,
z_index: 0,
visible: true,
locked: false,
opacity: 1,
rotation: 0,
anchors: {},
variant_group_id: 'hero',
// Overwritten by the orchestrator's route-node step.
selected_variant_id: 'feed-row',
fit_mode: 'fill'
};
export const DEMO_TEMPLATE_728x90: UnresolvedBannerSpec = {
template_id: 'demo-728x90',
template_version: 1,
campaign_id: 'demo-campaign',
version_id: 'v1',
copy_variant: 'A',
generated_at: '2026-05-17T00:00:00.000Z',
ai_reasoning: {
asset_selection: '',
copy_rationale: '',
variant_selection: '',
animation_rationale: ''
},
artboards: [
{
artboard_id: '728x90',
width: 728,
height: 90,
background: '#0A0A0A',
layers: [hero, textGroup, cta],
safe_zones: [],
timeline: {
duration_ms: 5000,
events: [
{ layer_id: 'hero', type: 'fade_in', at_ms: 0, duration_ms: 400 },
{ layer_id: 'headline', type: 'fade_in', at_ms: 200, duration_ms: 400 },
{ layer_id: 'subheadline', type: 'fade_in', at_ms: 400, duration_ms: 400 },
{ layer_id: 'cta', type: 'fade_in', at_ms: 600, duration_ms: 400 },
{ layer_id: 'cta', type: 'hold', at_ms: 1000, duration_ms: 3500 },
{ layer_id: 'cta', type: 'fade_out', at_ms: 4500, duration_ms: 500 }
]
}
}
],
ad_server_profile: 'iab_standard',
click_destinations: []
};

View file

@ -1 +1,4 @@
export { DEMO_TEMPLATE_300x250 } from './demo-300x250.js';
export { DEMO_TEMPLATE_300x600 } from './demo-300x600.js';
export { DEMO_TEMPLATE_728x90 } from './demo-728x90.js';
export { DEMO_TEMPLATE_160x600 } from './demo-160x600.js';

View file

@ -0,0 +1,145 @@
// type-scale.ts — designer-authored TypeSystem → per-size TypographySpec.
//
// The TypeSystem lives on Template (see packages/types). Authored once on the
// reference artboard. This module derives per-role typography for any target
// artboard via piecewise size-class math (half_page / rectangle / leaderboard /
// skyscraper / mobile_banner). Each class has its own per-role scale factor
// chosen so the reference artboard maps to scale = 1.0.
//
// The legibility floor declared on each RoleSpec clamps the derived size.
// Resolve-layout consumes the derived TypographySpec and shrinks further if
// needed (down to TypeSystem.shrink_floor × derived), then emits a
// constraint_signal back to the orchestrator if even the floor doesn't fit.
import type { RoleSpec, TypeRole, TypeSystem, TypographySpec } from '@banner-studio/types';
export interface DeriveTarget {
width: number;
height: number;
}
export interface DerivedTypography {
role: TypeRole;
typography: TypographySpec;
/** True if derivation clamped to the role's legibility floor. */
at_floor: boolean;
/** Unclamped formula result, kept for diagnostic logging. */
formula_size: number;
/** The role-specific floor that applied. */
floor: number;
}
type SizeClass = 'half_page' | 'rectangle' | 'leaderboard' | 'skyscraper' | 'mobile_banner';
/**
* Classify an artboard by aspect / area regime.
* - skyscraper: tall narrow (aspect < 0.5)
* - leaderboard: wide short (aspect > 3, with enough area)
* - mobile_banner: wide short but small area
* - half_page: tall-ish, generous (aspect 0.5 - 0.65) this is the reference shape
* - rectangle: everything else (square-ish 300x250-class)
*/
function classify(target: DeriveTarget): SizeClass {
const { width, height } = target;
const aspect = width / height;
if (aspect < 0.5) return 'skyscraper';
if (aspect > 3) return width * height < 30_000 ? 'mobile_banner' : 'leaderboard';
if (aspect < 0.65) return 'half_page';
return 'rectangle';
}
/**
* Per-class, per-role scale factor. Tuned so half_page (the 300x600 reference)
* returns 1.0 for every role. Other classes shrink roles at different rates:
* - Headlines shrink fastest on narrow surfaces (skyscraper) and short
* surfaces (leaderboard) large display type doesn't survive cramped space.
* - Body / subheadline shrinks more gently there's a legibility tax.
* - CTA stays near full size buttons need to be tappable.
* - Legal stays at base it's already at the floor for the smallest sizes.
*/
const CLASS_SCALES: Record<SizeClass, Record<TypeRole, number>> = {
half_page: { headline: 1.0, subheadline: 1.0, cta: 1.0, legal: 1.0 },
rectangle: { headline: 0.78, subheadline: 0.88, cta: 0.93, legal: 1.0 },
leaderboard: { headline: 0.70, subheadline: 0.81, cta: 0.93, legal: 1.0 },
skyscraper: { headline: 0.62, subheadline: 0.81, cta: 0.93, legal: 1.0 },
mobile_banner: { headline: 0.55, subheadline: 0.75, cta: 0.86, legal: 1.0 }
};
function deriveOneRole(
role: TypeRole,
spec: RoleSpec,
font_family: string,
target: DeriveTarget
): DerivedTypography {
const cls = classify(target);
const scale = CLASS_SCALES[cls][role];
const formula_size_raw = spec.base_font_size * scale;
const clamped = Math.max(formula_size_raw, spec.floor);
const at_floor = clamped > formula_size_raw;
const typography: TypographySpec = {
font_family,
font_size: Math.round(clamped),
font_weight: spec.font_weight,
line_height: spec.line_height,
letter_spacing: spec.letter_spacing,
color: spec.color
};
return {
role,
typography,
at_floor,
formula_size: Math.round(formula_size_raw * 10) / 10,
floor: spec.floor
};
}
/**
* Derive per-role TypographySpec values for a target artboard.
* Returns a role-keyed map; callers pick the role each text layer needs.
*/
export function deriveTypeSpec(
system: TypeSystem,
target: DeriveTarget
): Record<TypeRole, DerivedTypography> {
return {
headline: deriveOneRole('headline', system.roles.headline, system.font_family, target),
subheadline: deriveOneRole('subheadline', system.roles.subheadline, system.font_family, target),
cta: deriveOneRole('cta', system.roles.cta, system.font_family, target),
legal: deriveOneRole('legal', system.roles.legal, system.font_family, target)
};
}
/**
* Compute the max-characters-that-fit at a given font size for a given
* container width. Used by the constraint-signal emitter when shrink-to-fit
* cannot satisfy the layout at the floor.
*
* The caller passes a `measure` function so this module stays free of any
* dropflow dependency (and easier to unit-test against a stub).
*/
export function maxCharsAtSize(opts: {
measure: (text: string, fontSize: number, maxWidth: number) => { width: number; lines: number };
sample_text: string; // the overflowing copy; acts as the character corpus
font_size: number;
max_width: number;
max_lines: number;
}): number {
const { measure, sample_text, font_size, max_width, max_lines } = opts;
if (sample_text.length === 0) return 0;
// Quick check: does the full string fit? If so, return its length.
const full = measure(sample_text, font_size, max_width);
if (full.lines <= max_lines) return sample_text.length;
// Binary search the prefix length that fits at max_lines.
let lo = 0;
let hi = sample_text.length;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
const trial = sample_text.slice(0, mid);
const m = measure(trial, font_size, max_width);
if (m.lines <= max_lines) lo = mid;
else hi = mid - 1;
}
return lo;
}

View file

@ -16,8 +16,8 @@ exports[`resolveLayout — integration against demo fixture > parity baseline: s
},
{
"character_count": undefined,
"computed_x": 20,
"computed_y": 60,
"computed_x": 16,
"computed_y": 130,
"font_size_reduced": null,
"layer_id": "main-group",
"overflow_triggered": null,
@ -25,8 +25,8 @@ exports[`resolveLayout — integration against demo fixture > parity baseline: s
},
{
"character_count": 11,
"computed_x": 20,
"computed_y": 60,
"computed_x": 16,
"computed_y": 130,
"font_size_reduced": false,
"layer_id": "headline",
"overflow_triggered": false,
@ -34,8 +34,8 @@ exports[`resolveLayout — integration against demo fixture > parity baseline: s
},
{
"character_count": 13,
"computed_x": 20,
"computed_y": 110,
"computed_x": 16,
"computed_y": 176,
"font_size_reduced": false,
"layer_id": "subheadline",
"overflow_triggered": false,
@ -43,8 +43,8 @@ exports[`resolveLayout — integration against demo fixture > parity baseline: s
},
{
"character_count": 8,
"computed_x": 20,
"computed_y": 170,
"computed_x": 16,
"computed_y": 224,
"font_size_reduced": false,
"layer_id": "cta",
"overflow_triggered": false,

View file

@ -0,0 +1,142 @@
import { describe, expect, it } from 'vitest';
import { deriveTypeSpec, maxCharsAtSize } from '../src/index.js';
import { DEMO_TYPE_SYSTEM_300x600 } from '@banner-studio/types';
describe('deriveTypeSpec', () => {
it('returns reference base sizes unchanged at 300x600 (half_page)', () => {
const r = deriveTypeSpec(DEMO_TYPE_SYSTEM_300x600, { width: 300, height: 600 });
expect(r.headline.typography.font_size).toBe(36);
expect(r.subheadline.typography.font_size).toBe(16);
expect(r.cta.typography.font_size).toBe(14);
expect(r.legal.typography.font_size).toBe(10);
expect(r.headline.at_floor).toBe(false);
});
it('carries role typography metadata through derivation', () => {
const r = deriveTypeSpec(DEMO_TYPE_SYSTEM_300x600, { width: 300, height: 600 });
expect(r.headline.typography.font_family).toBe('Inter');
expect(r.headline.typography.font_weight).toBe(600);
expect(r.headline.typography.line_height).toBeCloseTo(1.12);
expect(r.headline.typography.letter_spacing).toBeCloseTo(-0.01);
expect(r.headline.typography.color).toBe('#FFFFFF');
});
it('shrinks headline more aggressively than CTA for leaderboard (728x90)', () => {
const r = deriveTypeSpec(DEMO_TYPE_SYSTEM_300x600, { width: 728, height: 90 });
// headline: 36 * 0.70 = 25.2 → 25
expect(r.headline.typography.font_size).toBe(25);
// cta: 14 * 0.93 = 13.02 → 13
expect(r.cta.typography.font_size).toBe(13);
// legal stays at base (scale 1.0)
expect(r.legal.typography.font_size).toBe(10);
});
it('applies skyscraper-class scales for 160x600', () => {
const r = deriveTypeSpec(DEMO_TYPE_SYSTEM_300x600, { width: 160, height: 600 });
// headline: 36 * 0.62 = 22.32 → 22
expect(r.headline.typography.font_size).toBe(22);
// subheadline: 16 * 0.81 = 12.96 → 13
expect(r.subheadline.typography.font_size).toBe(13);
});
it('applies rectangle-class scales for 300x250', () => {
const r = deriveTypeSpec(DEMO_TYPE_SYSTEM_300x600, { width: 300, height: 250 });
// headline: 36 * 0.78 = 28.08 → 28
expect(r.headline.typography.font_size).toBe(28);
// subheadline: 16 * 0.88 = 14.08 → 14
expect(r.subheadline.typography.font_size).toBe(14);
});
it('classifies a small wide artboard as mobile_banner', () => {
// 320x50: aspect 6.4, area 16,000 < 30,000 → mobile_banner
const r = deriveTypeSpec(DEMO_TYPE_SYSTEM_300x600, { width: 320, height: 50 });
// headline: 36 * 0.55 = 19.8 → 20
expect(r.headline.typography.font_size).toBe(20);
});
it('clamps to floor and sets at_floor=true when class scale would dip below floor', () => {
// Construct a system where headline would underflow on skyscraper.
const system = {
...DEMO_TYPE_SYSTEM_300x600,
roles: {
...DEMO_TYPE_SYSTEM_300x600.roles,
// base 24, floor 20. skyscraper scale 0.62 → 14.88, below floor.
headline: {
base_font_size: 24,
font_weight: 600,
line_height: 1.12,
letter_spacing: -0.01,
color: '#FFFFFF',
floor: 20
}
}
};
const r = deriveTypeSpec(system, { width: 160, height: 600 });
expect(r.headline.typography.font_size).toBe(20);
expect(r.headline.at_floor).toBe(true);
expect(r.headline.floor).toBe(20);
expect(r.headline.formula_size).toBeCloseTo(14.9, 1);
});
});
describe('maxCharsAtSize', () => {
// A stub measure function: every character is `cw` units wide, lines wrap at
// floor(maxWidth / cw). This lets us assert exact prefix lengths without
// pulling in the dropflow WASM engine.
function stubMeasure(cw: number) {
return (text: string, _fontSize: number, maxWidth: number) => {
const perLine = Math.max(1, Math.floor(maxWidth / cw));
const lines = Math.max(1, Math.ceil(text.length / perLine));
const width = Math.min(text.length, perLine) * cw;
return { width, lines };
};
}
it('returns full length when sample text already fits in max_lines', () => {
const n = maxCharsAtSize({
measure: stubMeasure(10),
sample_text: 'short',
font_size: 20,
max_width: 200, // 20 chars/line
max_lines: 1
});
expect(n).toBe(5);
});
it('returns 0 for empty sample text', () => {
const n = maxCharsAtSize({
measure: stubMeasure(10),
sample_text: '',
font_size: 20,
max_width: 100,
max_lines: 1
});
expect(n).toBe(0);
});
it('binary-searches the prefix length that fits at max_lines', () => {
// 10 chars/line, 2 lines allowed → 20 chars max.
const text = 'a'.repeat(50);
const n = maxCharsAtSize({
measure: stubMeasure(10),
sample_text: text,
font_size: 20,
max_width: 100,
max_lines: 2
});
expect(n).toBe(20);
});
it('handles single-line bound correctly', () => {
// 15 chars/line, 1 line.
const text = 'a'.repeat(40);
const n = maxCharsAtSize({
measure: stubMeasure(10),
sample_text: text,
font_size: 20,
max_width: 150,
max_lines: 1
});
expect(n).toBe(15);
});
});

View file

@ -1,10 +1,21 @@
// Generate agent prompt + tool schema. Takes an ExtractedContext + per-field
// character limits and returns headline/subheadline/cta_text + rationales.
// Generate agent prompt + tool schema (Shape B — per-row, per-size).
//
// One call per feed row. The agent takes a single ExtractedContext + a
// per-size character-limit table and returns per-size copy keyed by
// artboard_id, plus one shared rationale block. This is the multi-size
// upgrade described in VERTICAL_SLICE.md and RESOLVED_FEED.md.
//
// Retry mode: when resolveLayout emits a constraint_signal on a layer
// (shrink hit floor before fit), the orchestrator re-invokes this prompt
// with `tightened` carrying the new per-size, per-field max-char ceilings.
import { GenerateOutputSchema } from '@banner-studio/types';
import type { ExtractedContext, GenerateOutput } from '@banner-studio/types';
import { GenerateOutputV2Schema } from '@banner-studio/types';
import type {
ExtractedContext,
GenerateOutputV2
} from '@banner-studio/types';
export const VERSION = '1.0.0';
export const VERSION = '2.0.0';
export const BRAND_VOICE = `Brand voice: confident, modern, plain-spoken. Avoid:
- exclamation marks
@ -14,39 +25,50 @@ export const BRAND_VOICE = `Brand voice: confident, modern, plain-spoken. Avoid:
- second-person commands stacked back-to-back ("Buy now. Save now. Click now.")
Prefer concrete benefits and specific language.`;
export const system = `You are a banner ad copywriter. Write headline, subheadline, and CTA for a single 300x250 banner from a structured product brief.
export const system = `You are a banner ad copywriter. Write headline, subheadline, and CTA (plus legal where required) for several banner sizes derived from one product brief. Each size has its own character budget — you must respect every budget.
${BRAND_VOICE}
Hard rules:
- Stay within the character limit for each field. Spaces count. Count carefully exceeding a limit by even one character is a failure.
- Headline: punchy, single line, one idea.
- Subheadline: one short supporting sentence, not a list.
- CTA: 13 words, verb-led ("Shop velvet sofas", "Start free trial"). Title case unless the brand context suggests otherwise.
- The rationale fields are short. One sentence each, present tense, explaining the choice.
- Stay within the character limit for each (size, field) pair. Spaces count. Counting carefully matters even one character over a limit is a failure.
- Across sizes, preserve the same core idea. The headline at 300x600 (taller, more room) can be more descriptive; the same idea at 728x90 (leaderboard, very short) must be a tighter restatement. Do not write four unrelated headlines.
- Headlines: punchy, one idea. Single line on leaderboard and skyscraper; can be two lines on rectangle; up to three lines on half-page.
- Subheadlines: one short supporting sentence, not a list. Some sizes omit subheadline entirely (you'll see no limit for those leave that field empty).
- CTA: 13 words, verb-led ("Shop velvet sofas", "Start free trial"). Title case unless the brand context suggests otherwise. Keep the CTA consistent across sizes when budgets allow.
- Legal: include only when a limit is given for that size. Plain language, no marketing.
- The rationale block is shared across sizes. One sentence per field, present tense, explaining the editorial choice that applies to all sizes.
Always call the submit_copy tool. Never reply with prose.`;
/** One overflow per (artboard_id, field). */
export interface Overflow {
field: 'headline' | 'subheadline' | 'cta_text';
artboard_id: string;
field: 'headline' | 'subheadline' | 'cta_text' | 'legal';
limit: number;
actual: number;
}
/** Per-(size, field) char limit. Missing field means the size omits that role. */
export interface PerSizeConstraints {
[artboard_id: string]: {
headline: number;
subheadline?: number;
cta_text: number;
legal?: number;
};
}
export interface GenerateUserInput {
context: ExtractedContext;
constraints: {
headline: number;
subheadline: number;
cta_text: number;
};
prevAttempt?: GenerateOutput | null;
/** Per-size character budgets. */
constraints: PerSizeConstraints;
/** Previous overflowing attempt, if this is a retry. */
prevAttempt?: GenerateOutputV2 | null;
prevOverflows?: Overflow[];
}
export function userTemplate(input: GenerateUserInput): string {
const ctx = input.context;
const c = input.constraints;
const lines: string[] = [
'Product brief:',
`- subject: ${ctx.subject}`,
@ -55,49 +77,83 @@ export function userTemplate(input: GenerateUserInput): string {
`- benefits: ${ctx.key_benefits.join('; ')}`,
`- desired action: ${ctx.cta_intent}`,
'',
'Character limits (spaces count):',
`- headline: ${c.headline}`,
`- subheadline: ${c.subheadline}`,
`- cta_text: ${c.cta_text}`
'Per-size character limits (spaces count). Sizes without a subheadline or legal entry omit that role entirely — leave it empty:'
];
for (const [artboardId, c] of Object.entries(input.constraints)) {
const parts = [
`headline ${c.headline}`,
c.subheadline !== undefined ? `subheadline ${c.subheadline}` : 'subheadline (omit)',
`cta_text ${c.cta_text}`,
c.legal !== undefined ? `legal ${c.legal}` : 'legal (omit)'
];
lines.push(`- ${artboardId}: ${parts.join(', ')}`);
}
if (input.prevAttempt && input.prevOverflows && input.prevOverflows.length > 0) {
lines.push('');
lines.push('Your previous attempt was rejected for being too long:');
lines.push(`- headline ("${input.prevAttempt.headline}")`);
lines.push(`- subheadline ("${input.prevAttempt.subheadline}")`);
lines.push(`- cta_text ("${input.prevAttempt.cta_text}")`);
lines.push('');
lines.push('Overflows:');
for (const o of input.prevOverflows) {
lines.push(`- ${o.field}: ${o.actual} chars, limit ${o.limit} (over by ${o.actual - o.limit})`);
lines.push('Your previous attempt was rejected because at least one field overflowed its budget:');
for (const [aid, copy] of Object.entries(input.prevAttempt.per_size)) {
lines.push(`- ${aid}:`);
lines.push(` headline "${copy.headline}"`);
if (copy.subheadline !== undefined) {
lines.push(` subheadline "${copy.subheadline}"`);
}
lines.push(` cta_text "${copy.cta_text}"`);
if (copy.legal !== undefined) {
lines.push(` legal "${copy.legal}"`);
}
}
lines.push('');
lines.push(
'Rewrite the failing fields shorter. Cut adjectives and articles first. Preserve the meaning.'
);
lines.push('Overflows (rewrite only the failing fields shorter; preserve meaning):');
for (const o of input.prevOverflows) {
lines.push(
`- ${o.artboard_id}.${o.field}: ${o.actual} chars, limit ${o.limit} (over by ${o.actual - o.limit})`
);
}
}
return lines.join('\n');
}
export const outputSchema = GenerateOutputSchema;
export const outputSchema = GenerateOutputV2Schema;
/**
* Tool schema. `per_size` is an open-keyed object the agent fills in entries
* matching the artboard_ids in the constraint table. We can't enumerate the
* keys statically because templates may vary per campaign in V1; for the slice
* the orchestrator always passes the four known sizes.
*/
export const tool = {
name: 'submit_copy',
description: 'Submit the banner copy and the rationales for downstream use.',
description:
'Submit per-size banner copy and the shared rationale. The per_size object maps artboard_id (e.g. "300x600") to its copy fields.',
input_schema: {
type: 'object',
properties: {
headline: { type: 'string' },
subheadline: { type: 'string' },
cta_text: { type: 'string' },
per_size: {
type: 'object',
description:
'Keys are artboard_ids matching the per-size constraints in the user message. Each value contains headline, optional subheadline, cta_text, and optional legal.',
additionalProperties: {
type: 'object',
properties: {
headline: { type: 'string' },
subheadline: { type: 'string' },
cta_text: { type: 'string' },
legal: { type: 'string' }
},
required: ['headline', 'cta_text'],
additionalProperties: false
}
},
rationale: {
type: 'object',
properties: {
copy: {
type: 'string',
description: 'One sentence: why this copy was chosen for this audience and tone.'
description:
'One sentence: why this copy was chosen for this audience and tone (shared across sizes).'
},
asset: {
type: 'string',
@ -106,7 +162,7 @@ export const tool = {
},
variant: {
type: 'string',
description: 'One sentence: which variant of the template was used.'
description: 'One sentence: which variant of the template family was used.'
},
animation: {
type: 'string',
@ -117,7 +173,7 @@ export const tool = {
additionalProperties: false
}
},
required: ['headline', 'subheadline', 'cta_text', 'rationale'],
required: ['per_size', 'rationale'],
additionalProperties: false
}
} as const;

View file

@ -1,4 +1,4 @@
export * as extract from './extract.js';
export * as generate from './generate.js';
export type { ExtractInput } from './extract.js';
export type { GenerateUserInput, Overflow } from './generate.js';
export type { GenerateUserInput, Overflow, PerSizeConstraints } from './generate.js';

View file

@ -22,6 +22,15 @@ export interface RenderManyArgs {
* Defaults to the caller's CWD if omitted.
*/
repoRoot?: string;
/**
* Optional callback to override the per-spec output path. Returns
* `{ subdir, filename }` (filename without `.zip`). If omitted, falls
* back to the renderToZip default (`<campaign>/<version>.zip`).
*
* Multi-size exports use this to group by feed row:
* `({ spec }) => ({ subdir: \`row-\${i+1}\`, filename: spec.artboards[0]!.artboard_id })`
*/
pathFor?: (spec: BannerSpec, index: number) => { subdir: string; filename: string };
}
export interface ExportedItem {
@ -83,7 +92,8 @@ export async function renderMany({
specs,
outputDir,
concurrency,
repoRoot
repoRoot,
pathFor
}: RenderManyArgs): Promise<RenderManyResult> {
const exported: ExportedItem[] = [];
const errors: ExportError[] = [];
@ -117,15 +127,17 @@ export async function renderMany({
const browser = await chromium.launch({ headless: true });
try {
const limit = concurrency ?? Math.min(specs.length, 3);
await runWithConcurrency(specs, limit, async (spec) => {
await runWithConcurrency(specs, limit, async (spec, idx) => {
try {
const override = pathFor ? pathFor(spec, idx) : undefined;
const result = await renderToZip({
spec,
outputDir,
browser,
gsapSource,
interRegular,
interBold
interBold,
...(override ? { subdir: override.subdir, filename: override.filename } : {})
});
exported.push({
version_id: spec.version_id,

View file

@ -31,6 +31,17 @@ export interface RenderToZipArgs {
gsapSource: string;
interRegular: Buffer;
interBold: Buffer;
/**
* Optional override for the per-spec subdirectory under outputDir. If
* omitted, falls back to `safeSlug(spec.campaign_id)`. Multi-size exports
* use this to group by row: subdir = `row-N`, filename = `artboard_id`.
*/
subdir?: string;
/**
* Optional override for the zip filename (without `.zip` extension). If
* omitted, falls back to `safeSlug(spec.version_id)`.
*/
filename?: string;
}
export interface RenderToZipResult {
@ -41,7 +52,7 @@ export interface RenderToZipResult {
export async function renderToZip(
args: RenderToZipArgs
): Promise<RenderToZipResult> {
const { spec, outputDir, browser, gsapSource, interRegular, interBold } = args;
const { spec, outputDir, browser, gsapSource, interRegular, interBold, subdir, filename } = args;
const artboard = spec.artboards[0];
if (!artboard) {
throw new Error(`renderToZip: spec ${spec.version_id} has no artboards`);
@ -112,11 +123,11 @@ export async function renderToZip(
compressionOptions: { level: 6 }
});
const campaignSlug = safeSlug(spec.campaign_id);
const versionSlug = safeSlug(spec.version_id);
const campaignDir = path.join(outputDir, campaignSlug);
fs.mkdirSync(campaignDir, { recursive: true });
const zipPath = path.join(campaignDir, `${versionSlug}.zip`);
const subdirSlug = subdir ? safeSlug(subdir) : safeSlug(spec.campaign_id);
const fileSlug = filename ? safeSlug(filename) : safeSlug(spec.version_id);
const targetDir = path.join(outputDir, subdirSlug);
fs.mkdirSync(targetDir, { recursive: true });
const zipPath = path.join(targetDir, `${fileSlug}.zip`);
fs.writeFileSync(zipPath, zipBuf);
return { zip_path: zipPath, bytes: zipBuf.length };

View file

@ -26,6 +26,49 @@ export interface TypographySpec {
text_align?: 'left' | 'center' | 'right' | 'justify';
}
// ─── Type System (slice-originated; V1 proposal in RESOLVED_FEED.md kin) ─────
/**
* TypeRole the four typographic roles the slice TypeSystem manages.
* Every text layer that wants formula-derived typography names its role.
*/
export type TypeRole = 'headline' | 'subheadline' | 'cta' | 'legal';
/**
* RoleSpec the per-role declaration in a TypeSystem.
* Base values are authored on the reference artboard. `floor` enforces the
* legibility minimum per role; the formula will not derive a size below it.
*/
export interface RoleSpec {
base_font_size: number; // px, on the reference artboard
font_weight: number;
line_height: number;
letter_spacing?: number;
color: string; // CSS color, default; templates may override per layer
floor: number; // px — legibility minimum, never derived below
}
/**
* TypeSystem designer-authored, size-agnostic typography contract.
* Authored once on the reference artboard. The type-scale formula derives
* per-size TypographySpec values for any target artboard.
*
* Owned by Template (Template.type_system). Consumed by
* packages/layout-engine/src/type-scale.ts::deriveTypeSpec.
*/
export interface TypeSystem {
font_family: string;
reference: { width: number; height: number };
roles: {
headline: RoleSpec;
subheadline: RoleSpec;
cta: RoleSpec;
legal: RoleSpec;
};
/** Shrink to this fraction of derived size before emitting a constraint signal. */
shrink_floor: number; // e.g. 0.85
}
// ─── Behavior + Constraints ──────────────────────────────────────────────────
export interface PushSiblingRule {
@ -94,6 +137,13 @@ export interface BaseLayer {
export interface TextLayer extends BaseLayer {
type: 'text';
content_field: string;
/**
* When set, the layer's effective typography is derived from
* Template.type_system at resolve time via deriveTypeSpec(). The raw
* `typography` field is still required (it acts as the fallback when
* the template has no type_system) but its font_size is overridden.
*/
role?: TypeRole;
typography: TypographySpec;
behavior: TextBehaviorRules;
character_constraints: CharacterConstraints;
@ -163,6 +213,12 @@ export interface Template {
updated_at: string;
status: 'draft' | 'approved' | 'archived';
artboards: Artboard[];
/**
* Designer-authored TypeSystem. When present, text layers with `role` set
* derive their typography from this system per target artboard via
* deriveTypeSpec(). Authored on the reference artboard.
*/
type_system?: TypeSystem;
}
// ─── Click destinations ──────────────────────────────────────────────────────
@ -214,6 +270,19 @@ export interface ResolvedLayer {
siblings_pushed: { layer_id: string; pushed_by_px: number }[];
font_size_reduced: boolean;
overflow_triggered: boolean;
/**
* Emitted when shrink-to-fit hit the role's legibility floor and the
* text still overflows. Carries the max characters that *would* fit at
* the floor size. The orchestrator reads this to retry Generate with a
* tighter per-size character limit.
*/
constraint_signal?: {
role: TypeRole;
artboard_id: string;
max_chars_at_floor: number;
derived_font_size: number;
floor_font_size: number;
};
};
}
@ -277,6 +346,21 @@ export interface BannerSpec {
copy_rationale: string;
variant_selection: string;
animation_rationale: string;
/**
* Per-size editorial decisions. Slice-only; V1 migrates these into
* the resolved_cells table per RESOLVED_FEED.md.
*/
per_size_decisions?: Array<{
artboard_id: string;
role: TypeRole;
reason: 'baseline' | 'constraint_emitted';
constraint_signal?: {
max_chars_at_floor: number;
derived_font_size: number;
floor_font_size: number;
};
decided_at: string;
}>;
};
artboards: ArtboardSpec[];
ad_server_profile: 'iab_standard';
@ -299,8 +383,69 @@ export interface UnresolvedBannerSpec {
copy_rationale: string;
variant_selection: string;
animation_rationale: string;
/**
* Per-size editorial decisions. Slice-only; V1 migrates these into
* the resolved_cells table per RESOLVED_FEED.md.
*/
per_size_decisions?: Array<{
artboard_id: string;
role: TypeRole;
reason: 'baseline' | 'constraint_emitted';
constraint_signal?: {
max_chars_at_floor: number;
derived_font_size: number;
floor_font_size: number;
};
decided_at: string;
}>;
};
artboards: UnresolvedArtboardSpec[];
ad_server_profile: 'iab_standard';
click_destinations: ClickDestination[];
}
// ─── Demo TypeSystem (slice-locked) ──────────────────────────────────────────
/**
* The locked 300x600 reference TypeSystem for the slice demo.
* Authored values for the headline / subheadline / cta / legal roles.
* Ratios preserved across sizes: headline = 2.25× subhead, cta = 0.875× subhead,
* legal = 0.625× subhead.
*/
export const DEMO_TYPE_SYSTEM_300x600: TypeSystem = {
font_family: 'Inter',
reference: { width: 300, height: 600 },
shrink_floor: 0.85,
roles: {
headline: {
base_font_size: 36,
font_weight: 600,
line_height: 1.12,
letter_spacing: -0.01,
color: '#FFFFFF',
floor: 18
},
subheadline: {
base_font_size: 16,
font_weight: 400,
line_height: 1.4,
color: 'rgba(255,255,255,0.82)',
floor: 12
},
cta: {
base_font_size: 14,
font_weight: 500,
line_height: 1.0,
letter_spacing: 0.02,
color: '#0A0A0A',
floor: 12
},
legal: {
base_font_size: 10,
font_weight: 400,
line_height: 1.2,
color: 'rgba(255,255,255,0.6)',
floor: 8
}
}
};

View file

@ -291,6 +291,9 @@ export type ExtractedContext = z.infer<typeof ExtractedContextSchema>;
/**
* What the Generate agent returns through the `submit_copy` tool. The four
* rationale fields populate ai_reasoning on the final BannerSpec.
*
* Legacy single-size shape. Kept for the assemble step's per-size unit input;
* the new multi-size shape is GenerateOutputV2Schema below.
*/
export const GenerateOutputSchema = z.object({
headline: z.string(),
@ -304,3 +307,31 @@ export const GenerateOutputSchema = z.object({
})
});
export type GenerateOutput = z.infer<typeof GenerateOutputSchema>;
// ─── Multi-size Generate output (Shape B per VERTICAL_SLICE) ─────────────────
//
// Per-row, per-size copy. The Generate agent returns one entry per artboard_id
// covered by the row. Subheadline and legal are optional because the
// rectangle (300x250) and leaderboard (728x90) templates omit those roles for
// space. The rationale block is shared across sizes — one editorial decision
// adapted to each surface, not four independent decisions.
export const SizeCopySchema = z.object({
headline: z.string(),
subheadline: z.string().optional(),
cta_text: z.string(),
legal: z.string().optional()
});
export type SizeCopy = z.infer<typeof SizeCopySchema>;
export const GenerateOutputV2Schema = z.object({
/** Keyed by artboard_id, e.g. '300x600', '300x250', '728x90', '160x600'. */
per_size: z.record(z.string(), SizeCopySchema),
rationale: z.object({
copy: z.string(),
asset: z.string(),
variant: z.string(),
animation: z.string()
})
});
export type GenerateOutputV2 = z.infer<typeof GenerateOutputV2Schema>;