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:
parent
bea0392d9c
commit
ccbdb47162
39 changed files with 3647 additions and 558 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
24
CLAUDE.md
24
CLAUDE.md
|
|
@ -3,13 +3,21 @@
|
|||
You are building a vertical slice of the platform, not V1. The architecture document
|
||||
and BUILD_SEQUENCE.md describe the full V1. This slice is deliberately narrower.
|
||||
|
||||
What's in the slice: one template (hardcoded in TypeScript), one feed format (CSV),
|
||||
one AI pipeline (four agents, real Claude calls), one review grid (Konva canvas
|
||||
preview, no editing), one render path (Playwright local), one ad server profile (IAB).
|
||||
What's in the slice: one template containing four artboards (300x600 reference,
|
||||
300x250, 728x90, 160x600), authored against a designer-defined TypeSystem; one
|
||||
feed format (CSV); one AI pipeline (four agents, real Claude calls, size-aware
|
||||
copy adaptation); one review grid (per-row strip showing all four sizes at true
|
||||
pixel dimensions); one render path (Playwright local, four zips per row); one ad
|
||||
server profile (IAB).
|
||||
|
||||
What's out: template builder UI, asset library, variant groups, multi-artboard,
|
||||
version control, human overrides, conflict resolution, approval workflow, auth,
|
||||
Docker, CM360, trafficking sheet, brief mode, Figma. None of these exist in the slice.
|
||||
What's out: template builder UI, asset library, variant groups, version control,
|
||||
human overrides, conflict resolution, approval workflow, auth, Docker, CM360,
|
||||
trafficking sheet, brief mode, Figma. None of these exist in the slice.
|
||||
|
||||
The TypeSystem is a slice-originated artifact that lives on the Template and is
|
||||
consumed by packages/layout-engine/src/type-scale.ts. Per-size font sizes are
|
||||
derived, not hand-authored, for non-reference artboards. See SLICE_DEVIATIONS.md
|
||||
entry #10 for the full rationale.
|
||||
|
||||
When in doubt: build less, ship the demo, extend after. If you find yourself adding
|
||||
a feature not in VERTICAL_SLICE.md, stop and confirm.
|
||||
|
|
@ -65,7 +73,7 @@ These decisions are made. Do not re-litigate them when working on a task. If you
|
|||
4. **Validation is programmatic, not AI.** Character counts, asset rights, weight estimation, schema conformance — all checked by code after AI returns. AI output is never trusted.
|
||||
5. **Every prompt is versioned in `/prompts/`, never hardcoded.** When a prompt changes, the version is logged with the generation event. This enables A/B testing and rollback.
|
||||
6. **Event sourcing for versions.** A campaign is a sequence of versions: full snapshots on AI generation, deltas on human edits. Never destructive overwrites.
|
||||
7. **Human overrides survive regeneration.** They live in a separate table with `field_path` references. The merge algorithm is in Part 7 of the architecture doc.
|
||||
7. **Human overrides survive regeneration.** They live in a separate table with `field_path` references. The merge algorithm is in Part 7 of the architecture doc. **V1 direction (see `RESOLVED_FEED.md`):** copy edits collapse into a sparse per-product-per-size *resolved creative feed* that lives upstream of generation, eliminating the copy-override conflict-resolution problem entirely. Non-copy overrides (visual, layout, animation, asset selection) remain on a smaller patch layer per Part 7. The slice does not implement the resolved feed but persists per-size editorial decisions in `BannerSpec.ai_reasoning.per_size_decisions` as a forward-pointer (see SLICE_DEVIATIONS.md entry #11).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -82,6 +90,7 @@ These decisions are made. Do not re-litigate them when working on a task. If you
|
|||
- **Do not trust character counts from AI output.** Validate programmatically after each generation. Retry once, then flag for human.
|
||||
- **Do not deploy render workers to serverless.** Playwright requires persistent processes. ECS with dedicated containers.
|
||||
- **Do not skip the 5% internal padding on text containers.** This absorbs sub-pixel font rendering drift between browser preview and Playwright render.
|
||||
- **Do not hand-author per-layer font sizes in non-reference templates.** Sizes derive from the TypeSystem on the reference artboard via `deriveTypeSpec`. Templates declare layout, layer composition, hero treatment, and animation; typography is the system's job. Per-template overrides exist for legitimate surface reactions (color on a light vs. dark hero, for example) but base sizes are formula-derived.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -177,3 +186,4 @@ The data model has Figma hooks (`figma_node_id`, `figma_file_key`, `figma_sync_e
|
|||
- `PHASE_1_BRIEF.md` — first phase, what to build, in what order
|
||||
- `BUILD_SEQUENCE.md` — all 14 phases with acceptance criteria
|
||||
- `RESEARCH.md` — research backing the locked decisions (uploaded as `Research`)
|
||||
- `RESOLVED_FEED.md` — V1 proposal for the copy-override architecture; the resolved creative feed model
|
||||
|
|
|
|||
|
|
@ -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
477
RESOLVED_FEED.md
Normal 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 25–40 across what would be 60 dense cells (5 × 4 × 3).
|
||||
Sparseness ratio matters because it's the audit-trail signal — *every per-size
|
||||
cell represents a real editorial decision.*
|
||||
|
||||
---
|
||||
|
||||
## How Cells Are Created
|
||||
|
||||
```
|
||||
NEW INBOUND ROW
|
||||
→ 1 inbound cell per field
|
||||
|
||||
AI BASELINE PASS (Generate agent, reference-size constraints)
|
||||
→ 1 generated baseline cell per field where the AI rewrote
|
||||
→ No cell where the inbound value already passes validation
|
||||
|
||||
CONSTRAINT SIGNAL FROM LAYOUT ENGINE
|
||||
Layout engine: "728x90 headline doesn't fit at 18px floor, max 32 chars"
|
||||
Orchestrator re-invokes Generate with tightened limit for that size
|
||||
→ 1 per-size cell at (product, 728x90, headline)
|
||||
|
||||
HUMAN EDIT IN REVIEW UI
|
||||
Producer changes headline on the 728x90 banner for product 3
|
||||
→ 1 per-size cell at (product 3, 728x90, headline)
|
||||
→ Cell flagged human_authored: true (see Lock Mechanism below)
|
||||
```
|
||||
|
||||
If a producer edits at the reference size (300x600), the system has a choice:
|
||||
update the generated baseline cell, or create a per-size cell at the reference
|
||||
size. The right answer is **update the baseline cell** and mark it
|
||||
`human_authored: true`, so the edit propagates to any non-reference size that
|
||||
was reading the baseline. This is the only place the cell model gets subtle;
|
||||
the UI should make the propagation visible ("this edit will apply to all sizes
|
||||
except 728x90, where you previously customized the headline").
|
||||
|
||||
---
|
||||
|
||||
## The Override Problem, Reframed
|
||||
|
||||
The current `ARCHITECTURE.md` Part 7 model:
|
||||
|
||||
```
|
||||
AI generates → human edits → override patch stored separately
|
||||
Regenerate → new generation + patch re-applied
|
||||
↑ conflicts possible at this merge step
|
||||
```
|
||||
|
||||
The merge step is where complexity lives. The patch is a `field_path` + value;
|
||||
on regeneration, the patch is re-applied to the new spec; if the field shape
|
||||
changed (e.g., the AI removed a layer the override referenced), the merge
|
||||
algorithm has to resolve the conflict.
|
||||
|
||||
The resolved-feed model:
|
||||
|
||||
```
|
||||
AI generates → writes baseline + per-size cells
|
||||
Human edits → writes per-size cell, sets human_authored: true
|
||||
Regenerate → reads resolved feed; AI only rewrites un-locked cells
|
||||
↑ no merge — edited cells were already the canonical source
|
||||
```
|
||||
|
||||
There is no conflict because there is no after-the-fact patch application. The
|
||||
edit *is* the cell value. Regeneration reads the cell; the AI is invoked only
|
||||
for cells without `human_authored: true`.
|
||||
|
||||
**Important honest scope limit.** This collapses the override problem **for
|
||||
copy**. Non-copy overrides — visual properties on a layer, hero focal point
|
||||
adjustments, GSAP timing changes, layer visibility per artboard, asset variant
|
||||
selection — do not naturally live as cells in a copy feed. Those still require
|
||||
the existing override layer (or some shrunken version of it). The resolved
|
||||
feed eliminates the largest, highest-conflict surface of the override system;
|
||||
it does not eliminate it entirely.
|
||||
|
||||
V1 should ship with both: resolved feed for copy, smaller patch layer for
|
||||
non-copy. Future cleanup can examine whether non-copy edits can be folded
|
||||
into a generalized cell model (likely yes for layer-level properties keyed
|
||||
by `layer_id`; probably no for cross-cutting decisions like animation
|
||||
preset selection).
|
||||
|
||||
---
|
||||
|
||||
## Lock Mechanism
|
||||
|
||||
Two independent flags on every cell. Default values matter; both default to
|
||||
the producer-protective setting on human edit.
|
||||
|
||||
**`human_authored`** — true when a human authored or edited this cell.
|
||||
- Effect on regeneration: AI does not rewrite this cell.
|
||||
- Default on human edit: `true`.
|
||||
- Producer can explicitly reset to `false` ("let the AI try again").
|
||||
|
||||
**`external_pinned`** — true when this cell should resist inbound feed refresh.
|
||||
- Effect on external feed refresh: inbound value does not overwrite this cell.
|
||||
- Default on human edit: `true`.
|
||||
- Producer can relax to `false` if they want the new product price from the
|
||||
next feed pull but want to keep their headline edit.
|
||||
|
||||
The combinations:
|
||||
|
||||
| `human_authored` | `external_pinned` | Behavior |
|
||||
|---|---|---|
|
||||
| false | false | AI may rewrite; feed refresh may overwrite |
|
||||
| true | false | AI does not rewrite; feed refresh may overwrite |
|
||||
| false | true | AI may rewrite; feed refresh does not overwrite |
|
||||
| true | true | AI does not rewrite; feed refresh does not overwrite |
|
||||
|
||||
The two flags are independent because they answer different questions
|
||||
("who controls editorial decisions?" vs "is this synced with the source of
|
||||
record?"). Conflating them — as the original write-up did with a single
|
||||
"locked" flag — loses real producer leverage.
|
||||
|
||||
---
|
||||
|
||||
## Revert / Delete Semantics
|
||||
|
||||
A per-size cell exists because something deviated from the baseline. When
|
||||
a producer wants to revert ("let the AI decide again, or fall back to
|
||||
baseline"), the cell is *deleted*, not edited to match the baseline.
|
||||
|
||||
Deletion drops back through the source chain:
|
||||
|
||||
```
|
||||
delete per-size cell → reads generated baseline cell (if exists)
|
||||
delete baseline cell → reads inbound cell
|
||||
delete inbound cell → impossible (inbound feed is the floor)
|
||||
```
|
||||
|
||||
The UI shows a single "revert to system" action per cell. Producers
|
||||
think in terms of the rendered banner, not the cell tree, so the action
|
||||
is phrased "let the AI/system decide this again." Under the hood it's a
|
||||
cell deletion.
|
||||
|
||||
**Open question for V1.** Should deletion *also* clear `human_authored`
|
||||
and `external_pinned` on the underlying baseline cell if that cell exists?
|
||||
Probably yes — if the producer is reverting, they're signaling they no
|
||||
longer want the lock. Worth confirming with producers before locking the
|
||||
behavior.
|
||||
|
||||
---
|
||||
|
||||
## External Feed Refresh
|
||||
|
||||
When a new CSV is uploaded or the live feed updates, the system:
|
||||
|
||||
1. Diffs new inbound rows against existing inbound cells.
|
||||
2. For each changed cell:
|
||||
- If the resolved feed cell at this product + field is `external_pinned: true`
|
||||
anywhere (baseline or any per-size), do nothing.
|
||||
- Otherwise, update the inbound cell value.
|
||||
- If a baseline cell exists for this field, mark it stale and queue
|
||||
regeneration. (The new inbound copy may invalidate the AI's baseline
|
||||
rewrite.)
|
||||
- If per-size cells exist for this field and are not pinned, mark them stale
|
||||
and queue regeneration.
|
||||
|
||||
3. Surface the stale set in the review UI so the producer chooses when to
|
||||
trigger regeneration.
|
||||
|
||||
**Note on stale state.** Stale cells render with their current value (no banner
|
||||
breaks because of a feed refresh). They're flagged in the UI as "needs
|
||||
regeneration." Regeneration runs only the affected cells, not the whole
|
||||
campaign. This is what makes "production gets faster with every campaign" real
|
||||
— most refreshes touch a small set of cells, not all of them.
|
||||
|
||||
---
|
||||
|
||||
## Brief Mode
|
||||
|
||||
This model applies in brief mode with one structural difference: there is no
|
||||
inbound feed.
|
||||
|
||||
- No inbound cells exist.
|
||||
- Generated baseline cells are written from the brief on first pass.
|
||||
- Per-size cells are created from constraint signals or human edits, same as
|
||||
feed mode.
|
||||
- `external_pinned` is meaningless and becomes a no-op (no inbound source to
|
||||
resist). UI hides the flag in brief mode.
|
||||
- `human_authored` does the same job it does in feed mode.
|
||||
|
||||
The override-preservation backstop (the smaller patch layer mentioned above)
|
||||
is more load-bearing in brief mode for non-copy overrides, but copy edits
|
||||
still write to cells.
|
||||
|
||||
---
|
||||
|
||||
## What This Unlocks Beyond Solving the Override Problem
|
||||
|
||||
**Auditable creative history.** The resolved feed *is* the creative record.
|
||||
Every campaign leaves behind a structured document of what shipped at every
|
||||
size for every product, with sources attributed (AI baseline, constraint
|
||||
signal, human edit) and timestamps. Compliance, brand audits, and "what did
|
||||
we run last Black Friday?" all become queries against the feed.
|
||||
|
||||
**Compounding speed across campaigns.** Next campaign, same products:
|
||||
the producer starts from the previous campaign's resolved feed as a baseline.
|
||||
The brief writer marks which products are unchanged. For unchanged products,
|
||||
the system reuses cells verbatim (no AI calls). For changed products, only
|
||||
those rows regenerate. For campaign-level changes (new brand voice, new
|
||||
character constraints), all `human_authored: false` cells regenerate.
|
||||
|
||||
This speedup is a **feature to build**, not a property that emerges. The
|
||||
resolved feed makes it *possible*; the campaign-rerun flow has to be
|
||||
designed and shipped. Worth a dedicated build phase in V1.
|
||||
|
||||
**A real surface for editorial review.** Right now, "see what the AI
|
||||
decided" is a derived view assembled from logs and `ai_reasoning` fields.
|
||||
With the resolved feed, it's a table: product × size × field, with each cell
|
||||
showing source, value, timestamp, and any constraint signal that drove the
|
||||
decision. Reviewing a campaign becomes a structured pass through this table
|
||||
rather than clicking through 25 banner previews.
|
||||
|
||||
**Cleaner V2 hooks.** Multi-language: another dimension on the cell key
|
||||
(`product × size × field × locale`). Multi-market: same shape. The cell
|
||||
model generalizes more cleanly than the patch model does, because patches
|
||||
are inherently anchored to a single spec at a single point in time.
|
||||
|
||||
---
|
||||
|
||||
## What This Doesn't Solve
|
||||
|
||||
**Non-copy overrides.** As above. Visual, layout, animation, and asset
|
||||
selection edits still need the patch layer (or a generalized cell model
|
||||
keyed by `layer_id + property_path`, which is a V2 question).
|
||||
|
||||
**Cross-product editorial coherence.** A campaign may need its headlines
|
||||
to feel consistent across products in tone and structure. The cell model
|
||||
doesn't encode this constraint; the Generate agent's prompt does, and the
|
||||
producer reviews. This is fine — but worth not claiming the resolved feed
|
||||
solves something it doesn't.
|
||||
|
||||
**Inbound feed schema changes.** When the client adds, drops, or renames
|
||||
columns, the resolved feed doesn't automatically know what to do. Adds
|
||||
are easy (new field, no cells yet, AI generates on next pass). Drops are
|
||||
trickier — historical cells for the dropped column should probably be
|
||||
preserved, marked stale, and hidden from the active campaign view but
|
||||
retained for audit. Renames are the hardest and likely require explicit
|
||||
producer intent ("treat `subheadline` as the new `subhead`"). V1 should
|
||||
handle adds and drops; renames are a V2 capability.
|
||||
|
||||
---
|
||||
|
||||
## Schema Sketch
|
||||
|
||||
Not a final V1 schema — a sketch to make the model concrete.
|
||||
|
||||
```sql
|
||||
-- The two cell roots.
|
||||
|
||||
inbound_cells (
|
||||
product_id TEXT,
|
||||
field TEXT, -- 'headline', 'subheadline', 'cta_text', …
|
||||
value TEXT,
|
||||
source TEXT, -- 'csv_upload' | 'live_feed' | 'manual'
|
||||
updated_at TIMESTAMP,
|
||||
PRIMARY KEY (product_id, field)
|
||||
)
|
||||
|
||||
resolved_cells (
|
||||
campaign_id TEXT,
|
||||
product_id TEXT,
|
||||
size_id TEXT NULL, -- NULL for generated baseline; '300x600', '728x90', … for per-size
|
||||
field TEXT,
|
||||
value TEXT,
|
||||
cell_source TEXT, -- 'inbound' | 'generated_baseline' | 'constraint_emitted' | 'human_authored'
|
||||
human_authored BOOLEAN DEFAULT FALSE,
|
||||
external_pinned BOOLEAN DEFAULT FALSE,
|
||||
constraint_signal JSONB NULL, -- when source is constraint_emitted
|
||||
authored_at TIMESTAMP,
|
||||
authored_by TEXT NULL, -- user id when human, AI prompt version when AI
|
||||
stale BOOLEAN DEFAULT FALSE,
|
||||
PRIMARY KEY (campaign_id, product_id, size_id, field)
|
||||
)
|
||||
```
|
||||
|
||||
Notes on the schema:
|
||||
|
||||
- `size_id IS NULL` marks the generated baseline (or absence). Modeling
|
||||
baselines as a `size_id = NULL` row is cleaner than a separate table because
|
||||
the read query for "give me the value for (campaign, product, size, field)"
|
||||
is one query with a coalesce on `size_id`.
|
||||
- `cell_source` is the *origin* of the value; `human_authored` is a *behavior
|
||||
flag*. They're related but not the same — a human-authored cell has
|
||||
`cell_source = 'human_authored'` and `human_authored = true`, but a baseline
|
||||
cell that a human edited becomes `cell_source = 'human_authored'` (the
|
||||
previous AI baseline is overwritten) with `human_authored = true`. The flag
|
||||
is what drives regeneration logic; the source is what drives audit views.
|
||||
- `constraint_signal` carries the layout-engine emission for cells created
|
||||
by the constraint loop. This is exactly the data structure the slice's
|
||||
layout engine already produces.
|
||||
|
||||
---
|
||||
|
||||
## Relationship to the Slice
|
||||
|
||||
The slice does not implement the resolved feed. It runs end-to-end in one
|
||||
session: CSV in → BannerSpec out → render. There is no persistence, no
|
||||
campaign reruns, no human editing.
|
||||
|
||||
The slice does, however, produce data that maps directly to the resolved
|
||||
feed model:
|
||||
|
||||
- **Per-row constraint signals.** When the layout engine emits a
|
||||
`constraint_signal` and the orchestrator retries Generate with a tightened
|
||||
limit, the resulting copy is conceptually a per-size cell with
|
||||
`cell_source = 'constraint_emitted'`. In the slice, this lives in the
|
||||
resolved `BannerSpec` and the `ai_reasoning` field.
|
||||
|
||||
- **AI baseline reasoning.** The Generate agent's per-size output is the
|
||||
generated baseline + per-size cell set. The slice surfaces these in the
|
||||
AI reasoning panel.
|
||||
|
||||
For the slice's `BannerSpec.ai_reasoning`, a `per_size_decisions` field
|
||||
captures this as a forward-pointer:
|
||||
|
||||
```ts
|
||||
ai_reasoning: {
|
||||
asset_selection: string;
|
||||
copy_rationale: string;
|
||||
variant_selection: string;
|
||||
animation_rationale: string;
|
||||
per_size_decisions?: Array<{
|
||||
artboard_id: string;
|
||||
role: 'headline' | 'subheadline' | 'cta' | 'legal';
|
||||
reason: 'baseline' | 'constraint_emitted';
|
||||
constraint_signal?: {
|
||||
max_chars_at_floor: number;
|
||||
derived_font_size: number;
|
||||
floor_font_size: number;
|
||||
};
|
||||
decided_at: string;
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
This is the audit-trail data the future resolved feed would carry, captured
|
||||
in the slice as a flat array on the spec. Cheap to add, narratively powerful
|
||||
for the demo ("here are the editorial decisions the AI made per size, with
|
||||
reasons"), and a clean migration path to the V1 cell model.
|
||||
|
||||
---
|
||||
|
||||
## V1 Build Path
|
||||
|
||||
Rough sequencing for V1, not committed:
|
||||
|
||||
1. **Schema + storage.** Implement `inbound_cells` and `resolved_cells` per
|
||||
the sketch above. Postgres + JSONB columns where appropriate.
|
||||
2. **Read path.** Implement `resolveCellValue(campaign, product, size, field)`
|
||||
as the canonical read. Render worker, review UI, export pipeline all use
|
||||
it.
|
||||
3. **Write path — AI.** Generate agent writes baseline + per-size cells.
|
||||
Constraint-signal retry writes `cell_source = 'constraint_emitted'`.
|
||||
4. **Write path — human.** Review UI inline edit writes
|
||||
`cell_source = 'human_authored'` with `human_authored = true` by default.
|
||||
5. **Refresh path.** Inbound feed refresh diffs against `inbound_cells`,
|
||||
respects `external_pinned`, marks affected resolved cells stale.
|
||||
6. **Revert / delete.** UI action for "revert to system" deletes the
|
||||
appropriate cell and recomputes downstream reads.
|
||||
7. **Campaign rerun.** Brief writer flow that diffs new brief against
|
||||
previous campaign's resolved feed, marks unchanged products, regenerates
|
||||
only the changed set.
|
||||
|
||||
Steps 1–4 are the minimum to ship copy editing in V1. Steps 5–7 are the
|
||||
compounding-speed payoff and can land in subsequent V1 milestones.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions for V1
|
||||
|
||||
- **Cell deletion and lock interaction.** When a per-size cell is deleted,
|
||||
should the underlying baseline cell's `human_authored` flag be cleared
|
||||
if it was true? Probably yes; worth confirming with producers.
|
||||
- **Multi-campaign sharing.** Should the resolved feed be scoped per
|
||||
campaign or per client? Per campaign is simpler; per client enables
|
||||
cross-campaign reuse but complicates lock semantics.
|
||||
- **Audit retention.** How long are deleted cells kept? Forever (soft
|
||||
delete with `deleted_at`) is the conservative answer; cost may justify
|
||||
a TTL eventually.
|
||||
- **Conflict between concurrent edits.** Two producers editing the same
|
||||
cell. The patch model already had this problem; the cell model has it
|
||||
too. Optimistic locking with last-write-wins + audit log is probably
|
||||
enough for V1.
|
||||
- **Generalizing to non-copy properties.** Whether to extend the cell
|
||||
model to layer-level visual properties in V2, eliminating the residual
|
||||
patch layer entirely.
|
||||
|
||||
---
|
||||
|
||||
## One-Line Summary
|
||||
|
||||
Copy lives in a sparse resolved feed — one cell per product per size per
|
||||
field, only created on need. AI writes baseline and per-size deviations;
|
||||
humans edit cells directly; regeneration reads from the feed, so copy
|
||||
edits survive without an override-merge layer. Non-copy overrides — visual,
|
||||
layout, animation — remain on a smaller patch layer.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -21,35 +21,66 @@ Everything else is deliberately deferred.
|
|||
## Slice scope — what's in
|
||||
|
||||
**One template, hand-built in code, no template builder UI.**
|
||||
- A single 300x250 artboard.
|
||||
- One text group containing headline + subheadline with `push_siblings` cascade.
|
||||
- One smart asset slot for a hero image (no variant groups — just one asset).
|
||||
- Four artboards: 300x600 (reference), 300x250, 728x90, 160x600.
|
||||
- One `TypeSystem` authored on the 300x600 reference, declaring the
|
||||
four roles (headline, subheadline, cta, legal) with base sizes,
|
||||
weights, line-heights, tracking, colors, and legibility floors. The
|
||||
other three sizes derive their typography from this system via the
|
||||
type-scale formula.
|
||||
- Per-artboard layer composition: each size can declare which layers
|
||||
it includes (e.g., the 728x90 may drop the subheadline; the 160x600
|
||||
may collapse headline+subheadline into headline only).
|
||||
- One text group per artboard containing the included text layers
|
||||
with `push_siblings` cascade.
|
||||
- One smart asset slot for a hero image (no variant groups — one
|
||||
asset per row, per-size crop via focal point).
|
||||
- One static logo (PNG, no variant logic).
|
||||
- One GSAP animation preset (fade in, hold, fade out).
|
||||
- One GSAP animation preset (fade in, hold, fade out), with per-size
|
||||
timing scale.
|
||||
|
||||
**One CSV feed.**
|
||||
- 5 rows. Columns: `headline`, `subheadline`, `hero_image_url`, `cta_text`, `click_url`.
|
||||
- 3–5 rows. Columns: `headline`, `subheadline`, `hero_image_url`,
|
||||
`cta_text`, `click_url`, `hero_focal_x`, `hero_focal_y`.
|
||||
- Hand-authored, no validation rigor beyond "does it parse."
|
||||
|
||||
**One AI pipeline.**
|
||||
**One AI pipeline, size-aware.**
|
||||
- Four agents: extract → generate → route → assemble.
|
||||
- Generate agent rewrites/polishes feed copy against character limits and a hardcoded brand voice ("confident, modern, no exclamation marks").
|
||||
- Route node is trivial — just maps the `hero_image_url` field to the asset slot. No variant group logic.
|
||||
- Assemble produces a `BannerSpec`.
|
||||
- Extract runs once per row, producing an `ExtractedContext`.
|
||||
- Generate runs once per row and returns structured copy variants for
|
||||
all four sizes simultaneously (Shape B), respecting per-size
|
||||
character limits and the brand voice ("confident, modern, no
|
||||
exclamation marks").
|
||||
- Route node maps `hero_image_url` and focal point to the asset slot.
|
||||
No variant group logic.
|
||||
- Assemble produces a `BannerSpec` with four `ArtboardSpec`s, one per
|
||||
size.
|
||||
- Programmatic post-validation: per-size character count check. If a
|
||||
size overflows after shrink-to-fit hits the floor, the layout
|
||||
engine emits a `constraint_signal` and Generate is re-invoked with
|
||||
the tighter limit for that size. One retry, then flag as failed.
|
||||
|
||||
**One review UI.**
|
||||
- Grid of 5 banners (one per feed row), all rendered live in the browser using Konva + Dropflow.
|
||||
- Click a banner to see the AI reasoning panel.
|
||||
- No editing, no version control, no conflict resolution, no approval workflow.
|
||||
**One review UI — per-row strip.**
|
||||
- Each feed row is a horizontal strip showing all four sizes at true
|
||||
pixel dimensions, side by side.
|
||||
- All four sizes in a row animate together via a synchronized GSAP
|
||||
timeline ("play all" per row, or "play all" across the grid).
|
||||
- Click any banner in a row to see the AI reasoning panel — including
|
||||
the editorial decisions across sizes.
|
||||
- No editing, no version control, no conflict resolution, no
|
||||
approval workflow.
|
||||
|
||||
**One render path.**
|
||||
- Playwright runs locally (not in Docker yet).
|
||||
- Produces an HTML5 zip with: index.html, GSAP CDN script tag, the resolved spec inlined as JS, one click tag (IAB standard pattern), assets, a backup PNG.
|
||||
- Produces four HTML5 zips per row (one per size), each with:
|
||||
index.html, GSAP CDN script tag, the resolved spec inlined as JS,
|
||||
one IAB click tag, assets, a backup PNG.
|
||||
|
||||
**One ad server profile.**
|
||||
- IAB Standard. CM360 deferred.
|
||||
|
||||
That's it. Six "ones." Each one is a real implementation of the corresponding V1 component, scoped so the integration is what matters, not the breadth.
|
||||
That's the slice. Same "ones" as before, but the template is now a
|
||||
four-artboard system with a designer-authored type system, and the AI
|
||||
adapts copy per surface.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -60,7 +91,7 @@ Deliberately, to make the timeline real:
|
|||
- Template builder UI (templates are TypeScript objects in the slice)
|
||||
- Asset library (assets are URLs hardcoded in the CSV)
|
||||
- Variant groups, logo lockups, selection rules
|
||||
- Multi-artboard linked sets (one size only)
|
||||
- Variant groups, logo lockups, selection rules (multi-size hero crops use a focal point only)
|
||||
- Character limit simulator UI
|
||||
- Inline editing in the review UI
|
||||
- Version service, deltas, snapshots
|
||||
|
|
@ -167,19 +198,64 @@ The two highest-risk pieces. Build them first so if Dropflow doesn't behave the
|
|||
|
||||
**End-of-day check:** Real HTML5 zips on disk. One of them opens in a browser and animates correctly. The click tag works.
|
||||
|
||||
### Day 5 — Polish and the demo loop
|
||||
### Day 5 — Design system + 300x600 reference template
|
||||
|
||||
**Morning (3-4 hours):**
|
||||
- Wire the "Generate" button — let the user upload a different CSV and re-run the whole pipeline.
|
||||
- Add a second feed to demonstrate variety (different products, different copy lengths). The point is to show the layout engine handling a 30-character headline and a 70-character headline differently.
|
||||
- Visual polish on the review UI. It should look like something a CD would want to use, not a localhost test page. Dieter Rams adjacent. Restrained typography. Functional grid. No dashboard chrome.
|
||||
- Implement `TypeSystem` interface in `packages/types`.
|
||||
- Implement `type-scale.ts` module in `packages/layout-engine`
|
||||
with `deriveTypeSpec(system, target)` and the size-class piecewise
|
||||
formula.
|
||||
- Wire the shrink-then-constrain loop in `resolve-layout.ts`: shrink
|
||||
to 85% of derived size, then emit `constraint_signal` if still
|
||||
overflowing at the legibility floor.
|
||||
|
||||
**Afternoon (3-4 hours):**
|
||||
- Record the demo. Screen capture of: upload CSV, see banners generate live, see AI reasoning, export zips, open a zip in a browser. 90 seconds, tight cut.
|
||||
- Write the README for the repo. Honest about what's in scope and what's not.
|
||||
- Draft a short technical summary covering the thesis (humans design, AI scales) and the technical claim (Dropflow runs identically in both environments).
|
||||
- Author the 300x600 half-page template against the TypeSystem.
|
||||
Hero treatment, logo position, headline/subhead/CTA placement,
|
||||
GSAP timeline. This is the design-system reference — the
|
||||
visual standard the other three sizes inherit from.
|
||||
- First render. React, adjust, lock the design system.
|
||||
|
||||
**End-of-day check:** You have a working demo, a recording, and a written summary.
|
||||
**End-of-day check:** 300x600 banners render with type derived from
|
||||
the system. One row generates a polished half-page banner.
|
||||
|
||||
### Day 6 — The other three sizes + size-aware AI
|
||||
|
||||
**Morning (3-4 hours):**
|
||||
- Author 300x250 by adapting the 300x600 design.
|
||||
- Author 728x90 (the hard one — vertical space is tight; may drop
|
||||
the subheadline).
|
||||
- Author 160x600 (narrow; constraints stress-test the formula).
|
||||
|
||||
**Afternoon (3-4 hours):**
|
||||
- Upgrade the AI pipeline: Generate agent emits structured per-size
|
||||
copy variants. Orchestrator handles the constraint-signal retry.
|
||||
- Update the post-validation logic for per-size character counts.
|
||||
- Run the pipeline against the demo CSV. All four sizes should
|
||||
generate from each row.
|
||||
|
||||
**End-of-day check:** Four banners per row, all sizes generated from
|
||||
one Generate call per row, all fitting their constraints.
|
||||
|
||||
### Day 7 — Per-row strip review UI, multi-size render, polish, demo
|
||||
|
||||
**Morning (3-4 hours):**
|
||||
- Rebuild `/review` as per-row strips. Each feed row shows all
|
||||
four sizes at true pixel dimensions. GSAP timelines synchronized
|
||||
per row.
|
||||
- Reasoning panel reframed to show editorial decisions across sizes
|
||||
(which words survived the leaderboard cut, why).
|
||||
- Update the render worker to loop over sizes per row, organizing
|
||||
exports as `exports/row-N/SIZE.zip`.
|
||||
|
||||
**Afternoon (3-4 hours):**
|
||||
- Visual polish on the strip layout. Dieter Rams adjacent.
|
||||
- Record the demo. Voiceover: one campaign intent → four surfaces →
|
||||
AI adapts copy per size. 90 seconds, tight cut.
|
||||
- Update README and technical summary.
|
||||
|
||||
**End-of-day check:** Working demo of four-size adaptation, a
|
||||
recording, a written summary.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -277,6 +353,20 @@ The rest of `CLAUDE.md` stays as-is — locked stack decisions, what not to do,
|
|||
|
||||
**Underestimating Day 5 polish.** "Visual polish on the review UI" sounds like an afternoon. It is not. The difference between "localhost test page" and "I would show this to a CD" is real work. Give it a full day. If you have to cut, cut Day 4's zip composition polish or one of the AI agents' prompt iteration, not Day 5.
|
||||
|
||||
**Type scale calibration on the constrained sizes.** The 728x90 and
|
||||
160x600 are where the formula gets stress-tested. If the legibility
|
||||
floor pushes the system into emitting constraint signals on every row,
|
||||
the AI ends up writing very short copy and the demo feels less
|
||||
impressive. Tune the floors so they trigger on edge cases, not the
|
||||
common case. Budget time on Day 6 morning to iterate on the size-class
|
||||
math.
|
||||
|
||||
**Hero image cropping across aspect ratios.** A wide landscape hero
|
||||
shot fails in 160x600 tall format. Pick demo hero images with centered
|
||||
subjects and breathable composition that survive aggressive cropping in
|
||||
multiple directions. The focal-point hint in the CSV is the primary
|
||||
lever; use it.
|
||||
|
||||
---
|
||||
|
||||
## After the slice
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
186
apps/web/app/review/components/RowStrip.tsx
Normal file
186
apps/web/app/review/components/RowStrip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(', ')}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
279
packages/layout-engine/src/templates/demo-160x600.ts
Normal file
279
packages/layout-engine/src/templates/demo-160x600.ts
Normal 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: []
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
276
packages/layout-engine/src/templates/demo-300x600.ts
Normal file
276
packages/layout-engine/src/templates/demo-300x600.ts
Normal 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: []
|
||||
};
|
||||
235
packages/layout-engine/src/templates/demo-728x90.ts
Normal file
235
packages/layout-engine/src/templates/demo-728x90.ts
Normal 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: []
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
145
packages/layout-engine/src/type-scale.ts
Normal file
145
packages/layout-engine/src/type-scale.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
142
packages/layout-engine/test/type-scale.test.ts
Normal file
142
packages/layout-engine/test/type-scale.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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: 1–3 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: 1–3 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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue