# Demand Gen test — static image assets (Ad Group #1: Re-engage Travelers & Searchers)

> **Status (Oct 2026):** these statics were never uploaded to the Google Ads account.
> The Demand Gen creatives actually running live are separate assets that exist only on
> the account — verify against the account, not this folder, before reasoning about
> what's live.

**36 assets across two concepts.** The price pack (24) argues one thing — this route,
this fare. The scene pack (12) argues it with no words at all. Running both gives Google
genuinely different assets to test rather than one idea in four sizes.

## The scene pack — "The Journey" (zero text)

12 assets, 3 scenes x 4 ratios, rendered from `card-scene.html`:
`python3 render.py --scenes`

**No headline, no price, no wordmark, no fonts loaded.** Demand Gen renders the headline,
description, business name and logo as separate assets, so the image carries none of it —
which also takes the 20% copy question off the table, because this measures 0%. `--scenes`
asserts the template declares no `@font-face` rule, so a stray glyph fails the build.

Three times of day (`S1_day`, `S2_dusk`, `S3_night`) built from one Wanderblu ramp: sky,
two skyline bands at different values, sidewalk, kerb, road, street lamps, and BusChiku in
front. **Depth is occlusion** — the bus tops out below the towers so the city reads behind
it. A first pass sized the bus at 880 of 1200px, it hid the skyline entirely, and the scene
collapsed back into a figure on an empty field, which is the exact failure this pack has
been corrected for once already.

Skyline profiles, cloud placements, star fields and window lattices are **fixed arrays,
never `Math.random()`** — an ad asset has to rebuild byte-identical. The only non-ramp
colour is `#fcdf68` (sun, lit windows, stars), sampled from the Chiku illustration library.

---

## The price pack — route + fare

24 image assets for the $30k/mo Demand Gen test: **6 creative variants × 4 aspect ratios**,
every price measured from Snowflake rather than asserted. Companion to the campaign plan
and to [`google-ads-audience-strategy.md`](../../../.docs/knowledge/google-ads-audience-strategy.md),
whose "Deferred — Demand Gen remarketing" section this work directly answers: it named
*"creative is the binding constraint, not targeting"* and *"a creative library ready to ship"*
as the thing that would unblock the channel.

Text assets (headlines/descriptions) are **not** in this pack — statics first, by request.
The on-image copy inventory below is what the text pool has to avoid repeating.

Render everything: `python3 render.py` · verify: `python3 render.py --check` ·
measure the copy budgets: `python3 render.py --audit`

## The pack

| Variant | Layout | Corridor | On-image price | Illustration |
|---|---|---|---|---|
| `A1_nyc_bos` | A · corridor recall | New York → Boston | bus from $26 | ticket bubble |
| `A2_nyc_dc`  | A · corridor recall | New York → Washington DC | bus from $28 | suitcase of cash |
| `A3_nyc_phl` | A · corridor recall | New York → Philadelphia | bus from $17 | ticket bubble |
| `A4_nyc_bwi` | A · corridor recall | New York → Baltimore | bus from $27 | suitcase of cash |
| `B1_nyc_phl` | B · bus vs train | New York → Philadelphia | bus $17 · train $13 | piggy bank |
| `B2_nyc_bos` | B · bus vs train | New York → Boston | bus $26 · train $31 | piggy bank |

Files are `out/<variant>_<ratio>.jpg`; `out/_contact-sheet.png` is the visual index.

| Ratio | Rendered | Google recommends | Google minimum |
|---|---|---|---|
| 1.91:1 landscape | 1200×628 | 1200×628 | 600×314 |
| 1:1 square | 1200×1200 | 1200×1200 | 300×300 |
| 4:5 portrait | 1200×1500 | 960×1200 | 480×600 |
| 9:16 vertical | 1080×1920 | 1080×1920 | 600×1067 |

Portrait renders larger than recommended — same 4:5, more pixels, and it matches the July 4th
App pack so crops stay comparable. **9:16 is beyond the brief**, which lists 9:16 for video
only; Google recommends a 9:16 *image* for the Shorts and vertical-feed placements this ad
group targets, so it's included. Drop it if the media plan doesn't want the placement.

All 24 files are 51–109 KB against a 5 MB cap, so there is plenty of headroom if anyone
wants PNG instead.

## Why these six variants

**Corridors are chosen by booking volume, not by margin.** Demand Gen remarketing cannot
target a route — you attach an audience, not a city pair. So corridor-specific creative only
"matches" for the share of the pool that searched that corridor, and the way to maximise
that share is to name the busiest routes. The four here are the top US city pairs in the
account by trailing-12mo bookings (56,388 / 45,478 / 34,869 / 17,315), all Northeast.

Conveniently they are the same four cities the July 4th App set was built for, so its 12
backgrounds are available if anyone wants a photographic direction later.

**Layout A** is the re-engagement workhorse: one route, one price, one focal point.
**Layout B** shows bus and train side by side — one of the plan's named showcase points
("side-by-side carrier price comparisons"), and it needs no placeholder because both numbers
are ours. The two B variants are deliberately chosen so the answer *flips*: on Philadelphia
the train is cheaper ($13 vs $17), on Boston the bus is ($26 vs $31). That flip is the pitch —
it is why you'd check rather than assume.

Kickers and illustrations alternate across the pool because asset diversity is the lever
Google's algorithm optimises on; four cards identical but for a number give it nothing to test.

## A number that would have been wrong

Worth recording because it nearly shipped. Scoping a "compare N carriers" line, the raw
`carrier` column returns **22** for New York → Washington DC. Nine of those are Amtrak
*service names* — Acela, Northeast Regional, Vermonter, Carolinian, Palmetto, Crescent,
Silver Service, Cardinal, and Amtrak itself. Deduped to `BUSINESS_GROUP_CODE` the honest
count is **12**. Advertising 22 would have inflated one operator into nine, which is
precisely the unverifiable-claim problem the ad-copy guidance and FTC rules exist for.

Any future carrier-count claim must dedupe to `BUSINESS_GROUP_CODE`. Honest counts:
New York → DC **12**, → Philadelphia **10**, → Baltimore **8**, → Boston **7**.

The query is kept at [`queries/operators.sql`](queries/operators.sql) even though nothing
in the pack currently makes a carrier claim — it exists so the next person who wants to
write "compare N carriers" starts from the right number. It also lists which codes collapse
into which operator.

## Data provenance

Every price is the **p10 per-seat amount actually paid**, trailing 12 months, rounded **up**
to the next whole dollar. Query: [`queries/fares.sql`](queries/fares.sql). Pulled **2026-08-13**.

Three decisions worth knowing:

- **p10, not min.** 10% of riders paid at or below it, so it is cheap but real. Same basis
  the `bus-vs-flight-nyc-dc` video creative validated ("$29 sits at the cheap-but-real 10th
  percentile"). The absolute minimum would be an unbookable outlier.
- **Rounded up.** Rounding down would put the claim *below* the measured p10 — a more
  aggressive number than the data supports.
- **All-in, not fare.** `transaction_amount_usd` equals `gross_amount_usd` on all 216,420
  trailing-90d bus bookings, and averages $47.55/seat against `list_price_amount_usd` $40.81 —
  the delta is the Wanderu booking fee (~$4.61 avg) plus other charges. So the advertised
  number is **what the traveler was charged**, not a fare that grows at checkout. Note the
  side effect: site "from" prices are list-based and will read *lower* than the ad. That is
  the safe direction (under-promise), and it keeps the fee out of the copy per house rule.

**Prices go stale.** Cheap inventory sells out and fares drift seasonally. `render.py --check`
flags the pull date past 21 days. Re-run the query, edit `variants.py`, re-render — the price
is a swappable layer by design, never hand-edited into an image.

## Policy position on text-on-image

These are designed cards where the text *is* the composition, which is a different case from
pasting text onto a photo. Verified against Google's docs:

- The hard prohibition on text overlay applies to **Demand Gen *product* ads** — the
  Merchant Center feed-based format. We have no product feed, so it does not bind these.
  (This is also why the plan's "Dynamic Product Ads" line can't be executed literally;
  these hand-built corridor variants are the pseudo-dynamic substitute.)
- For standard Demand Gen image assets, avoiding text pasted on photos is **best-practice
  guidance**, not an auto-disapproval.
- The rule that *does* bind: **cropped text is a documented image-quality failure.** Handled
  structurally — all text sits inside per-ratio padding, and `fit()` in `card.html` shrinks
  the route label against a `--textw` budget so a longer city pair can never clip. The budget
  is explicit rather than measured from the content box, because the illustration is
  absolutely positioned and a naive measurement would let the route run underneath Chiku.

If review does push back, the fallback is a text-light variant: keep the wordmark and the
illustration, move the route and price into the text assets. Worth agreeing on before
uploading 24 files.

Sources: [asset specs](https://support.google.com/google-ads/answer/13704860) ·
[product-ad requirements](https://support.google.com/merchants/answer/13389785)

## Copy budget and composition

Two constraints from the Google reps — under 100 characters of on-image copy, and copy
occupying under 20% of the image — plus one from the art itself. All three are measured
by `render.py --audit` rather than eyeballed; it renders each card twice more, once with
only the copy and once with only the illustration, and measures the real pixels.

| | Budget | Measured |
|---|---|---|
| On-image characters | < 100 | **39–67** |
| Copy area | < 20% | **8.5–17.5%** |
| Copy overlapping the art | none | **none** |

**Size the copy to use the budget, not to hide from it.** An earlier pass shrank the type
until it measured 3–7% — two thirds of the allowance unspent, illegible at feed size, and
the cards read as an illustration dropped on an empty field. Because area scales with the
square of type size, 3% to 17% is only about a 2× linear change, so there was far more room
than it looked. If you revise these, check the headroom before shrinking anything:
`(20 / current_pct) ** 0.5` is the linear scale still available.

**The ceiling case is `A2_nyc_dc` at 17.5%** — the longest route label in the
shortest canvas. It passes, and the type is deliberately not reduced for margin, because
`--audit` is a hard gate: add a corridor with a longer name and it fails the build rather
than shipping quietly. If that happens, shorten the label ("New York → DC") before
shrinking the type.

Meeting the budget cost the support line — "Compare bus & train in one search" and
"We show you both. You pick.". That was the right thing to drop: Google renders a
headline, description and CTA beneath the image, so the line was duplicated furniture,
and at full width it was the single biggest consumer of copy area. The bus-vs-flight
video still says it, which is where it belongs.

**Composition rule: copy goes to Chiku's left.** Every illustration in the pack faces
and extends right — he walks right, and the piggy bank and open money case both sit on
his right side. So the art is right-anchored in all four ratios and the copy is never on
his right, where it would collide with the prop and sit on the side he faces away from.
`--audit` asserts the copy and art boxes never overlap (horizontally in 1.91:1, vertically
in the three tall ratios), and that the copy is the left element wherever the two share
vertical space.

**One measurement caveat.** `--audit` also reports a 5×5 grid-cell count, the classic
implementation of a 20% text rule. It is **advisory only**. On a block this small it
measures alignment, not density: the landscape copy is 6.7% of the image but touches 6 of
25 cells, because a 343×148 block cannot avoid straddling a third 125px row unless it is
positioned to the grid. That artifact is much of why the rule was retired, so the gate is
the area fraction. If your rep means the grid method specifically, say so — it is a
layout nudge, not a redesign.

### On-image copy inventory

Google penalises repetition across assets, so the text pool should not restate these.

| Element | Copy |
|---|---|
| Kickers | "Still planning?" · "Your route is still here" · "Pick up where you left off" · "Same route, two ways" |
| Route | "New York → Boston / Washington DC / Philadelphia / Baltimore" |
| Price label | "From" (layout A) · "Bus from" / "Train from" (layout B) |
| Price | "$26 / $28 / $17 / $27" · layout B "$17 & $13", "$26 & $31" |

No superlatives ("cheapest", "best"), no fake urgency, no booking-fee mention, no carrier
names. Sentence case throughout; one all-caps element (the letter-spaced kicker).

## Deliberately not in this pack

- **Flight comparison.** The strongest angle, but the `bus-vs-flight` brief is explicit that
  the flight number is a placeholder to be screenshotted at publish and never invented. It
  needs a real screenshotted fare before it can ship. Nothing else blocks it.
- **Carrier logos or carrier-identifiable art.** `chiku-carrier-partners.png` reads as Peter
  Pan and Greyhound; using it in paid implies endorsement. Needs partner sign-off. Same
  reason no carrier is named in copy, though the campaign plan lists them.
- **Promo-code creative.** The plan floats "Save $5". No code exists yet; when one does it
  becomes a fifth variant, not an edit to these.

## Build

```
variants.py     single source of truth — corridors, prices, provenance, art assignment
build.py        variants.py -> variants.js  (file:// blocks fetch(), allows <script src>)
card.html       price pack; ?v=<variant>&r=<ratio>; 4 ratio blocks, 2 layouts
card-scene.html scene pack; zero text, declares no @font-face at all
render.py       headless-shell -> PNG -> JPG q92 + contact sheet
                --one / --check / --audit / --scenes
index.html      preview page for both packs, generated — see the generator note below
queries/        fares.sql (the prices) · operators.sql (the carrier-count guardrail)
assets/         3 Chiku illustrations + the bus + wanderu-logo.svg
fonts/          Proxima Nova (reg/sbold/bold/xbold) from .docs/knowledge/proxima-nova/
```

`index.html` is generated from `variants.py` plus the rendered file list rather than
hand-written, and the generator asserts every referenced file exists before writing. Worth
keeping that way: this repo tracks both `campaigns/` and `Campaigns/`, macOS is
case-insensitive so a split reads as fine locally, and Cloudflare Pages is case-sensitive —
a mistyped or wrong-cased path 404s only once it is deployed and shared.

Design follows [`design-principles.md`](../../../.docs/knowledge/design-principles.md): one
focal point (the price is the only element at the largest size), ~90% one tinted neutral
plus one accent, three type sizes, flat with no border-plus-shadow stacking.

**The background is flat `wanderblu-900` (#004c55) — no gradient.** An earlier pass had a
radial cyan wash behind the art. It's gone, and the reason is in the system rather than
taste: [`tokens.spec.md`](https://github.com/wanderu/strategy/blob/main/.docs/design/tokens.spec.md)
defines **no gradient token anywhere** — every surface in the design system is a flat fill —
and `design-principles.md` lists gradients among the amateur tells. `wanderblu-900` is the
spec's sanctioned "dark brand contexts" value, so the flat fill is the on-system answer.

### The colour-block layout

Earlier passes were a single dark field with the mascot floating on it and the copy
stacked at one size — legible but inert. The current build blocks the canvas into two
flat colour fields and gives the price its own object. Four decisions carry it:

- **Two fields, split on a curve.** `wanderblu-900` carries the copy, `wanderblu-500`
  (#00bfd5) carries Chiku. The dividing edge is curved rather than straight, which is what
  stops a hard two-tone split reading as a chart. 1.91:1 splits vertically (copy left,
  Chiku right); the three tall ratios split horizontally (copy above, Chiku below).
- **The field value is a measured trade-off, not a taste call.** It started at
  `wanderblu-300` (#66d9e6), sampled from the blob already baked into the Chiku artwork —
  `chiku-cart.png` returns `#66d9e6`, `#99e5ee`, `#ccf2f7`, straight off the Wanderblu
  ramp — so the illustrations sat on it as though drawn for it. It was darkened two rungs
  because that pale value measured **1.06:1 against Chiku's own green** (#95d600): near
  identical luminance, so the mascot separated from his field by hue alone. `wanderblu-500`
  lifts that to 1.26:1 while the two blocks still part cleanly at 4.35:1.

  The cost is that `chiku-cart`'s baked blob is no longer invisible against the field
  (1.00:1 → 1.34:1) and now reads as a soft skyline behind him on `A1`/`A3`. That is
  on-message for a travel ad and gives those two variants a backdrop the others don't
  have, so it is kept — but it is a consequence, not an accident.

  **`wanderblu-600` is too far.** Block separation collapses to 2.94:1 and the split turns
  muddy. 550 (3.62:1) is the practical floor.

  Whatever the value, the field **must not be green** — Chiku would vanish into his own
  background.
- **The price has its own white card.** It makes the number the focal point without
  relying on size alone, and reads as a fare tag. Layout B puts both fares in one card
  either side of a hairline, so the comparison is a single object rather than two
  competing ones. The label sits above the figure, flush with the card's inner-left edge,
  which lines up with the rule, kicker and route — fixing an earlier version where "from"
  ran inline and shoved the biggest element on the card ~90px off the margin everything
  else aligned to.
- **Chiku breaks the boundary.** In the tall ratios he crosses up through the curve into
  the dark field, which fills what was otherwise a void and gives the composition depth.
  This is why `--audit` measures copy/art collision as a **pixel** intersection rather
  than a bounding-box one: their boxes overlap by design while no glyph is ever touched,
  and a bbox test would reject a layout that is correct.

The kicker stays `wanderblu-300` (#66d9e6) on the dark field at **5.84:1**. Now that the
field is `wanderblu-500`, the two no longer share a value, so the accent reads as its own
colour rather than echoing the block behind Chiku. If it ever needs darkening, 550 is the
limit — `wanderblu-600` falls to 2.94:1 on the dark field and fails outright.

A short `chiku-500` rule sits above the kicker, anchoring the left edge and putting the
mascot's green somewhere other than the illustration.

Colour vars in `card.html` are named for their tokens (`--wanderblu-900`, not `--teal`) so a
brand change is one edit. It deliberately does **not** import
`design/artifact-brand-tokens.css`: that file is theme-aware via
`@media (prefers-color-scheme)`, which would make rendered output depend on the headless
browser's colour scheme. Ad renders have to be deterministic.

Every illustration is larger than its biggest render box (940×770 in portrait), so all art
downscales and none is upscaled.

## Pre-ship checklist

1. `python3 render.py --check` clean — all 24 present, under 5 MB, pull date fresh.
   Then `python3 render.py --audit` clean — both copy budgets met, copy clear of the art.
2. Re-run [`queries/fares.sql`](queries/fares.sql) and confirm each p10 still rounds up to
   the printed price. Re-render if not.
3. Spot-check one live search per corridor so the landing page doesn't show a price *above*
   the ad's.
4. Agree the text-on-image position above, before uploading.
5. Point each ad at the corridor's route page, not the homepage, so message match holds.
6. Confirm the **checkout-abandoner audience actually exists.** Per the audience strategy doc,
   `Exclude (NOT)` is unused and nothing in the current set reaches checkout-starters-minus-
   purchasers (~273K web users/30d). GA4 audiences populate forward only. This creative is
   ready before its audience is — that gap is the real launch blocker, not the assets.
