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

- {status.kind === 'ready' && okCount > 0 && ( + {status.kind === 'ready' && okRows.length > 0 && (
)} @@ -231,7 +249,7 @@ export default function ReviewClient() {
    {exportState.exported.map((e) => ( -
  • +
  • {e.zip_path}{' '} ({Math.round(e.bytes / 1024)} kB) @@ -273,14 +291,14 @@ export default function ReviewClient() { {status.kind === 'ready' && ( setSelected(spec)} + onSelect={(row) => setSelectedRow(row)} registerTimeline={registerTimeline} /> )} setSelected(null)} + row={selectedRow} + onClose={() => setSelectedRow(null)} />
); diff --git a/apps/web/app/review/components/AiReasoningDrawer.tsx b/apps/web/app/review/components/AiReasoningDrawer.tsx index 3a19523..749b5b2 100644 --- a/apps/web/app/review/components/AiReasoningDrawer.tsx +++ b/apps/web/app/review/components/AiReasoningDrawer.tsx @@ -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" >
-

AI reasoning

+

+ {row ? `Row ${row.rowIndex + 1} — AI reasoning` : 'AI reasoning'} +

-
- {spec ? ( - <> - {SECTIONS.map(({ key, label }) => ( -
-

{label}

-

- {spec.ai_reasoning[key] || (empty)} -

-
- ))} - -
- Full BannerSpec JSON -
-                  {JSON.stringify(spec, null, 2)}
-                
-
- - ) : null} -
+
{row ? : null}
); } + +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?" */} +
+

Per-size copy

+ + + + + + + + + + + {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 ( + + + + + + + ); + })} + +
SizeHeadlineSubCTA
{ab.artboard_id}{get('headline')}{get('subheadline')}{get('cta')}
+
+ + {/* Shared rationale fields, ordered with copy_rationale first because + it's the most-asked-about field in review. */} + {rationale && + SECTIONS.map(({ key, label }) => ( +
+

{label}

+

+ {rationale[key] || (empty)} +

+
+ ))} + + {/* Constraint signals: per-size cases where the layout engine had to + shrink copy to the legibility floor. Empty in the happy path. */} +
+

Constraint signals

+ {row.constraintSignals.length === 0 ? ( +

+ None — every size resolved above the legibility floor. +

+ ) : ( + + + + + + + + + + + {row.constraintSignals.map((s, i) => ( + + + + + + + ))} + +
SizeFieldMax chars @ floorFloor fs
{s.artboard_id}{s.field}{s.max_chars_at_floor}{s.floor_font_size}px
+ )} +
+ +
+ Full row JSON ({row.specs.length} specs) +
+          {JSON.stringify(
+            {
+              rowIndex: row.rowIndex,
+              rationale,
+              constraint_signals: row.constraintSignals,
+              specs: row.specs
+            },
+            null,
+            2
+          )}
+        
+
+ + ); +} diff --git a/apps/web/app/review/components/BannerCanvas.tsx b/apps/web/app/review/components/BannerCanvas.tsx index 9363524..6003011 100644 --- a/apps/web/app/review/components/BannerCanvas.tsx +++ b/apps/web/app/review/components/BannerCanvas.tsx @@ -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(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(function Banne { if (node) layerNodes.current.set(layer.layer_id, node); else layerNodes.current.delete(layer.layer_id); @@ -144,11 +146,12 @@ export const BannerCanvas = forwardRef(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 = diff --git a/apps/web/app/review/components/BannerCard.tsx b/apps/web/app/review/components/BannerCard.tsx deleted file mode 100644 index 827ff39..0000000 --- a/apps/web/app/review/components/BannerCard.tsx +++ /dev/null @@ -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(null); - const timelineRef = useRef(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 ( -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onClick(); - } - }} - > -
- -
-
- - {productName} - - animating -
-
- ); -} diff --git a/apps/web/app/review/components/BannerGrid.tsx b/apps/web/app/review/components/BannerGrid.tsx index 05702bd..ea60310 100644 --- a/apps/web/app/review/components/BannerGrid.tsx +++ b/apps/web/app/review/components/BannerGrid.tsx @@ -1,61 +1,30 @@ 'use client'; -// Renders one card per feed row in feed-row order. ok rows get a BannerCard, -// skipped/error rows get a gray placeholder with a status pill so producers -// can see exactly which row was which. +// Renders one RowStrip per feed row in row order. Skipped/error rows still +// take a slot in the list so producers can see exactly which feed row hit a +// failure mode and why. The strip itself owns its GSAP master timeline. -import type { BannerSpec } from '@banner-studio/types'; -import { BannerCard } from './BannerCard'; +import { RowStrip, type RowItem, type RowOk } from './RowStrip'; import styles from '../styles.module.css'; -export type GridItem = - | { kind: 'ok'; rowIndex: number; spec: BannerSpec; productName: string } - | { kind: 'skipped'; rowIndex: number; reason: string; productName: string } - | { kind: 'error'; rowIndex: number; error: string; productName: string }; +export type GridItem = RowItem; interface Props { items: GridItem[]; - onSelect: (spec: BannerSpec) => void; + onSelect: (row: RowOk) => void; registerTimeline: (key: string, restart: (() => void) | null) => void; } export function BannerGrid({ items, onSelect, registerTimeline }: Props) { return ( -
- {items.map((item) => { - if (item.kind === 'ok') { - return ( - 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 ( -
-
- row {item.rowIndex + 1} — {label} -
-
- - {item.productName} - - {label} -
-
- ); - })} +
+ {items.map((item) => ( + + ))}
); } diff --git a/apps/web/app/review/components/RowStrip.tsx b/apps/web/app/review/components/RowStrip.tsx new file mode 100644 index 0000000..47e1f8a --- /dev/null +++ b/apps/web/app/review/components/RowStrip.tsx @@ -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 ; + return ; +} + +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>(new Map()); + const masterRef = useRef(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 ( +
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}`} + > +
+ Row {row.rowIndex + 1} + + {row.productName} + + {row.constraintSignals.length > 0 && ( + + {row.constraintSignals.length} signal + {row.constraintSignals.length === 1 ? '' : 's'} + + )} + + {row.specs.length} sizes + +
+
+ {row.specs.map((spec) => { + const ab = spec.artboards[0]; + if (!ab) return null; + return ( +
+ { + if (h) handles.current.set(spec.version_id, h); + else handles.current.delete(spec.version_id); + }} + artboard={ab} + /> + {ab.artboard_id} +
+ ); + })} +
+
+ ); +} + +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 ( +
+
+ Row {row.rowIndex + 1} + + {row.productName} + + {label} +
+
+ {detail} +
+
+ ); +} diff --git a/apps/web/app/review/lib/typography-lookup.ts b/apps/web/app/review/lib/typography-lookup.ts index 2c12a38..397b8de 100644 --- a/apps/web/app/review/lib/typography-lookup.ts +++ b/apps/web/app/review/lib/typography-lookup.ts @@ -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 = { + '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) { diff --git a/apps/web/app/review/styles.module.css b/apps/web/app/review/styles.module.css index 615e9f7..857be6b 100644 --- a/apps/web/app/review/styles.module.css +++ b/apps/web/app/review/styles.module.css @@ -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; diff --git a/packages/api-lib/src/ai-orchestration/assemble.ts b/packages/api-lib/src/ai-orchestration/assemble.ts index 72e19f5..72e8a26 100644 --- a/packages/api-lib/src/ai-orchestration/assemble.ts +++ b/packages/api-lib/src/ai-orchestration/assemble.ts @@ -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 = { 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); } diff --git a/packages/api-lib/src/ai-orchestration/generate-agent.ts b/packages/api-lib/src/ai-orchestration/generate-agent.ts index 7f3a680..77d25fd 100644 --- a/packages/api-lib/src/ai-orchestration/generate-agent.ts +++ b/packages/api-lib/src/ai-orchestration/generate-agent.ts @@ -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 { 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({ @@ -39,7 +51,7 @@ export async function generateAgent(args: GenerateAgentArgs): Promise 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; } diff --git a/packages/api-lib/src/ai-orchestration/orchestrator.ts b/packages/api-lib/src/ai-orchestration/orchestrator.ts index 7378490..f24d138 100644 --- a/packages/api-lib/src/ai-orchestration/orchestrator.ts +++ b/packages/api-lib/src/ai-orchestration/orchestrator.ts @@ -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 { - 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 = {}; - 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 = {}; + 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(', ')}`; +} diff --git a/packages/api-lib/test/assemble.test.ts b/packages/api-lib/test/assemble.test.ts index 1444032..83c3e10 100644 --- a/packages/api-lib/test/assemble.test.ts +++ b/packages/api-lib/test/assemble.test.ts @@ -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(); + }); }); diff --git a/packages/api-lib/test/generate-agent.test.ts b/packages/api-lib/test/generate-agent.test.ts index ae0ed94..d43bba4 100644 --- a/packages/api-lib/test/generate-agent.test.ts +++ b/packages/api-lib/test/generate-agent.test.ts @@ -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([]); + }); +}); diff --git a/packages/api-lib/test/orchestrator.test.ts b/packages/api-lib/test/orchestrator.test.ts index cdba9e9..eebcf0b 100644 --- a/packages/api-lib/test/orchestrator.test.ts +++ b/packages/api-lib/test/orchestrator.test.ts @@ -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[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); + }); }); diff --git a/packages/layout-engine/src/index.ts b/packages/layout-engine/src/index.ts index f99a05e..8a14c4d 100644 --- a/packages/layout-engine/src/index.ts +++ b/packages/layout-engine/src/index.ts @@ -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'; diff --git a/packages/layout-engine/src/resolve-layout.ts b/packages/layout-engine/src/resolve-layout.ts index 654b0e8..2cfb41e 100644 --- a/packages/layout-engine/src/resolve-layout.ts +++ b/packages/layout-engine/src/resolve-layout.ts @@ -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 | 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 + copy: Record, + 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 = {}; + 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, allLayers: Layer[], - accumulatedDeltas: Record + accumulatedDeltas: Record, + 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, allLayers: Layer[], - accumulatedDeltas: Record + accumulatedDeltas: Record, + 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['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, allLayers: Layer[], - accumulatedDeltas: Record + accumulatedDeltas: Record, + 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); } diff --git a/packages/layout-engine/src/templates/demo-160x600.ts b/packages/layout-engine/src/templates/demo-160x600.ts new file mode 100644 index 0000000..e5009fb --- /dev/null +++ b/packages/layout-engine/src/templates/demo-160x600.ts @@ -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: [] +}; diff --git a/packages/layout-engine/src/templates/demo-300x250.ts b/packages/layout-engine/src/templates/demo-300x250.ts index 3cca476..98b70d5 100644 --- a/packages/layout-engine/src/templates/demo-300x250.ts +++ b/packages/layout-engine/src/templates/demo-300x250.ts @@ -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, diff --git a/packages/layout-engine/src/templates/demo-300x600.ts b/packages/layout-engine/src/templates/demo-300x600.ts new file mode 100644 index 0000000..00c1692 --- /dev/null +++ b/packages/layout-engine/src/templates/demo-300x600.ts @@ -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: [] +}; diff --git a/packages/layout-engine/src/templates/demo-728x90.ts b/packages/layout-engine/src/templates/demo-728x90.ts new file mode 100644 index 0000000..6b61e55 --- /dev/null +++ b/packages/layout-engine/src/templates/demo-728x90.ts @@ -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: [] +}; diff --git a/packages/layout-engine/src/templates/index.ts b/packages/layout-engine/src/templates/index.ts index d05d728..6a9f395 100644 --- a/packages/layout-engine/src/templates/index.ts +++ b/packages/layout-engine/src/templates/index.ts @@ -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'; diff --git a/packages/layout-engine/src/type-scale.ts b/packages/layout-engine/src/type-scale.ts new file mode 100644 index 0000000..f611c70 --- /dev/null +++ b/packages/layout-engine/src/type-scale.ts @@ -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> = { + 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 { + 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; +} diff --git a/packages/layout-engine/test/__snapshots__/resolve-layout.test.ts.snap b/packages/layout-engine/test/__snapshots__/resolve-layout.test.ts.snap index 3a4252f..e2f1018 100644 --- a/packages/layout-engine/test/__snapshots__/resolve-layout.test.ts.snap +++ b/packages/layout-engine/test/__snapshots__/resolve-layout.test.ts.snap @@ -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, diff --git a/packages/layout-engine/test/type-scale.test.ts b/packages/layout-engine/test/type-scale.test.ts new file mode 100644 index 0000000..89b832b --- /dev/null +++ b/packages/layout-engine/test/type-scale.test.ts @@ -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); + }); +}); diff --git a/packages/prompts/src/generate.ts b/packages/prompts/src/generate.ts index 9502643..8f027ed 100644 --- a/packages/prompts/src/generate.ts +++ b/packages/prompts/src/generate.ts @@ -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; diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts index 8ecaa96..840900d 100644 --- a/packages/prompts/src/index.ts +++ b/packages/prompts/src/index.ts @@ -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'; diff --git a/packages/render-worker/src/render-many.ts b/packages/render-worker/src/render-many.ts index 5970b37..cf2413d 100644 --- a/packages/render-worker/src/render-many.ts +++ b/packages/render-worker/src/render-many.ts @@ -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 (`/.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 { 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, diff --git a/packages/render-worker/src/render-to-zip.ts b/packages/render-worker/src/render-to-zip.ts index 7981b36..f03379a 100644 --- a/packages/render-worker/src/render-to-zip.ts +++ b/packages/render-worker/src/render-to-zip.ts @@ -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 { - 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 }; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 5ba8b1e..d2c90ec 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -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 + } + } +}; diff --git a/packages/types/src/schemas.ts b/packages/types/src/schemas.ts index bb347ed..0eaf3e6 100644 --- a/packages/types/src/schemas.ts +++ b/packages/types/src/schemas.ts @@ -291,6 +291,9 @@ export type ExtractedContext = z.infer; /** * 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; + +// ─── 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; + +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;