# Campaign details, implementation guide (developer hand-off)

This guide is for the engineers wiring the page to live data. It is the companion to
`DOCUMENTATION.md` (the design + component reference). Read this one to understand **what data the page
needs, where it goes, and which functions to call**. Everything in the page today runs on deterministic
sample data; nothing here changes the UI, it only tells you where the seams are.

The page is two self-contained HTML files plus shared CSS and image assets:

```
campaign-details.html            the page (inline <style> + one inline <script> IIFE)
contextual/contextual-changes.html   the "Contextual changes" full experience (loaded in an iframe modal)
contextual/creatives/*.jpg       the 5 creative backdrops
assets/day0/*.jpg                the day-0 empty-state illustrations
assets/campaign-map-eu.jpg       the Leaflet offline fallback basemap
blindspot.css                    shared component CSS (must sit next to campaign-details.html)
design-system/tokens.css         design tokens (must sit at design-system/ next to the page)
```

To open with no server: double-click `campaign-details.html`. Fonts (Google Fonts) and the interactive map
(Leaflet) load from CDN when online and fall back to system fonts + a static basemap offline.

---

## 1. The lifecycle state model

The page renders three states from one codebase, chosen by a `?state=` query param:

| State | URL | Flag in code | What it shows |
|-------|-----|--------------|----------------|
| **Booked (day-0)** | `?state=day0` | `DAY0` | Campaign booked, nothing served. Zeroed actuals, booked plan, empty-state illustrations. |
| **Live** (default) | _(no param)_ | both false | The live monitor + controls. |
| **Wrapped (report)** | `?state=ended` or `?state=report` | `ENDED` | The final report: locked outcomes vs plan, retrospective reads, no live framing. |

Both flags are read once at the top of the IIFE from `location.search`. **Wire the real state by setting
these from the campaign record** (e.g. `status === 'draft' -> DAY0`, `status === 'ended' -> ENDED`), then
let the existing branches do the rest. Every section, drawer, modal, status chip and action already checks
`DAY0` / `ENDED`; there is no third place to touch.

The contextual iframe is told the state through its own `?state=` param (the parent appends it when it opens
the modal), so the modal stays in sync.

---

## 2. The data contract

All sample data lives as top-level `var`s in the `<script>` IIFE of `campaign-details.html`. Replace each
with the live equivalent (same shape) and the renderers pick it up. Shapes below.

### `WIN`: range-windowed campaign totals (Overview, charts, tables scale off this)
```
WIN['30d'] = {
  lab:'last 30 days', el:0.79,          // el = elapsed fraction of the window (drives "expected by now")
  plays:339000, playsB:410000,          // actual, booked
  impr:31.6, imprB:38.0,                // millions
  spend:142380, cap:180000,
  win:30,                               // window length in days
  prevPlays:297000, prevImpr:28.1       // previous period, for deltas (null on flight)
}
```
Keys: `7d`, `30d`, `flight`, `custom`. The header range control switches `RANGEKEY`.

### `WRAP`: locked final figures (report/ended state only)
```
WRAP = { plays, playsB, impr, imprB, spend, cap, cpp, cppPlan, ctxSave, reach,
         topHub, topHubPlays, topCreative, swaps, flightLab, days }
```
Drives the **Campaign wrap panel**, the ended stat widgets, and the ops console's outcome figures.

### `HUBS`: the 6 airport hubs (map, where-it-ran, per-hub drawer, linked board)
```
{ n:'Frankfurt', code:'FRA', short, venue, screens:34,
  plays, impr, imprM, spend, spendNum, cpp, cppNum,   // strings for display + Num for math
  share, rank, seed,                                   // seed drives the deterministic chart shapes
  pace, bdg,                                           // pacing % vs plan, budget pace %
  trend:[6 weekly values] }
```
`pace` is the single source for "ahead/behind/on pace" everywhere (`>=2` ahead, `<=-2` behind).

### `HUBLL`: hub map coordinates (Leaflet pins)
```
{ code:'FRA', n:'Frankfurt', ll:[lat,lng], big:1 }   // big = flagship pin size
```

### `FORMATS`: format breakdown (the "by format" linked board)
```
{ n:'Flagship spectacular', plays, share, trend:[6] }
```

### `SCHED`: the booked schedule patterns (booked-schedule card, per-hub drawer)
```
{ name:'Always-on daytime', c:'base', col:'var(--ink)', screens:96,
  on:[6,22], when:'Daily, 06:00 to 22:00', desc, hubs, codes:[...], scr:[...],
  vary:'dow', dow:{wd:[7,21],we:[10,24]}, winddown:[16,22] }   // vary/dow/winddown optional
```
`AUD24` is a 24-length array (0..1) of audience opportunity by hour, used as the dashed overlay.

### `TOPSCR`: ranked screens (top-performing list, where-by-screen pivot)
```
{ name, loc, code, plays, imprM, tr:[6] }
```

### `CTXV` / `SWAPS`: contextual variants + the swap record (contextual section + modal)
```
CTXV = { k, kick, h, cond, c, share, cpp, live:0|1, tick }     // the 5 creative variants
SWAPS = { c, h, where, trig, tc, d, when }                     // each contextual swap event
```
The full contextual modal regenerates its own deterministic dataset per time window (`genData(W)` in
`contextual-changes.html`); wire that to the real swap log.

### `MEDIA`: creatives (Media hub + creative drawer)
```
{ file, kick, head, trig, tc, plays, share, rank, mode:'now'|'trigger',
  status:'live'|'paused'|'pending', cpp, vtr, len, where, tr:[6], up, ops }
```
`status` drives the card state; `ops` is how many of 6 operators approved (`>=6` = all).

---

## 3. Status & state library (wire your statuses here)

`BS_STATE` is the single registry; `stateChip(id)` renders the pill anywhere.
```
BS_STATE[id] = { label, tone, icon, desc }
tone -> colour + behaviour: live (pulse) · positive (red-deep) · info · warn · pending · critical · paused · neutral
```
23 ids across **campaign** (`draft, pending_approval, partially_approved, approved, scheduled, live, paused,
rejected, ended`), **creative** (`cr_review, cr_approved, cr_live, cr_paused, cr_rejected, cr_scheduled`),
**screen** (`online, offline, reconnecting, maintenance`), **pacing** (`ahead, on_pace, behind, at_risk`).

To show a status: `el.innerHTML = stateChip('offline')`. To add one: add an entry to `BS_STATE` (and an
icon path to `ST_ICON`). The Ops console renders the whole set as a live legend, so new states appear there
automatically. **No green anywhere**: "positive" is `--red-deep` by design.

---

## 4. Notifications & alerts

- **Notification feed**: `BS_NOTES = [{ id, tone, state, title, body, time, read, act:{label, kind, arg} }]`.
  Replace with the live event stream. `kind` routes the action: `hub` (open a hub drawer), `scroll` (to a
  section id), `fix` (open the Blinky chooser for that hub). `noteAct(act)` is the single router; extend it
  for new kinds. `paintBell()` repaints the unread count.
- **Alert banners**: `alertPush(tone, title, body, act)` pushes a dismissible banner into `#alertStack`
  (`critical | positive | warn`). High-severity events should call this in addition to adding a note.
- **Toasts**: `toast(msg)` for transient confirmations.

---

## 5. Blinky actions (what to call on a real backend)

Every Blinky problem opens a chooser via `blinkyChooser(btn, kick, title, body, options, onApply)`.
Option sets: `fixOptions(hub)` (underpacing recovery), `aheadOptions(hub)`, `steadyOptions(hub)`. **Every set
ends with `flagOption(hub)` ("Flag to Blindspot AdOps")**, which calls `flagToAdops(hub)` (drops a
notification + logs + toasts). `onApply(sel)` receives the chosen option; today it calls `applyFix(sel, hub)`
which updates the cap/log/toast locally. **Wire `applyFix` to the real budget/rebalance/escalation
endpoints**, keyed on `sel.k` (`shift, cap, swap, freq, send, pullin, hold, alert, digest, flag`).

Other side-effecting controls and their hooks (all currently confirm-then-simulate):
`setDelivery(live)` (pause/resume), the budget `capSave` handler (set 30-day cap), the `.switch` context-rule
toggles, `data-confirm="extend"` (extend flight), `actDup` (duplicate), `actShare` (read-only link),
`opsMessage(who)` (message an operator), and the ops console quick actions. Each writes to the change log
(`logChange(text)`) and toasts; replace the bodies with API calls and keep the confirm + log + toast pattern.

Per the platform safety model, anything that spends money, pauses delivery, sends a message, or changes a
standing rule asks the user to confirm first (the modal). Keep that.

---

## 6. Rendering surface (call after data loads)

After you set the data objects, these (already wired on load) repaint the page:
`buildWrap()`, `buildOverviewStats()`, `buildBookedRow()`, `buildInsights()`, `buildSummary()`,
`renderCampaignCharts(gran)`, `rankedScreens(host)`, `linkedBoard(host, data, kind)`, `paintTable()`,
`mapCard()`, `scheduleCard()`, the media `render()`, `buildOps()`, `renderNotes()` / `paintBell()`. The range
control calls `setRange(key)` which re-runs the relevant builders. The contextual modal calls `renderAll()`
in `contextual-changes.html`.

Charts use one canonical harness lifted from `design-system/charts.html` (`flagship`, `daypart`, `calendar`,
`gauge`, `linkedBoard`, plus `mk/add/txt/svg`, `tipShow/Move/Hide`, `clipWipe`, `livePulse`, `countUp`,
`onFocus/focusHub`). Feeding real series into those builders is the only chart work.

---

## 7. Theming, motion, accessibility, house rules

- **Tokens**: `design-system/tokens.css`. The page never hardcodes a hex; everything reads `var(--token)`.
- **Dark mode**: `body.theme-dark` flips tokens. Targeted "keeps" force intentionally-dark blocks to stay
  dark. The contextual iframe is themed via `postMessage` from the parent.
- **Motion**: section/card reveal (staggered), modal/scrim scale-in, count-ups, chart wipes, hovers, the live
  pulse, drawer slide. **All behind `@media (prefers-reduced-motion: reduce)`**: keep new motion there too.
- **Accessibility**: focus traps + return on modal/drawer, `aria-current`, labelled controls, keyboard paths
  on charts/timelines, status chips carry `title`. Run a screen-reader pass when wiring live data.
- **House rules (must hold):** USD only; the metric is **average cost per play** (never CPM); **no green**
  (positive = `--red-deep`); **no em dashes anywhere** (comma/period/semicolon/parentheses/colon); the
  Blindspot voice (literal labels, buttons say exactly what happens). The build verifies 0 em dashes.

---

## 8. Build, QA, archive

- The page is shipped by `rollout/build_global_archive.py` (sets `BS_CHANGELOG`, bumps the version, writes a
  dated release + master zip, and guards against a nested-duplicate folder).
- QA each change with jsdom (0 runtime errors; the page's `setInterval` keeps Node alive, so call
  `process.exit`) and Playwright Chromium for the visuals (the sandbox needs an `libXdamage.so.1` stub via
  `LD_LIBRARY_PATH`). Re-check the em-dash count is 0 in every file (the build runs this).

---

## 9. Integration checklist

1. Map the campaign record `status` to `DAY0` / `ENDED` (default = live).
2. Feed the data objects in section 2 from your API (same shapes).
3. Point `applyFix` and the control handlers (section 5) at real endpoints; keep confirm + log + toast.
4. Replace `BS_NOTES` with the live event stream; wire `noteAct` kinds.
5. Wire `BS_STATE` ids to the live system statuses (and the operator/connection feed).
6. Pass the theme + state into the contextual iframe (already done via param + postMessage).
7. Keep the house rules (USD, cost-per-play, no green, no em dashes, voice).
8. Run the jsdom + Playwright + em-dash checks, then build the archive.

*Blindspot platform · campaign details · implementation guide · 2026-06-18 · TPS Engage, LLC.*
