# Campaign details + live monitor, full documentation

Platform page for the Blindspot DOOH product. It rebuilds the two legacy portal pages
("Campaign | Blindspot" + "Campaign Performance | Blindspot") into one screen: a live monitor **and**
the campaign report, advertiser-first with ops detail underneath. The reference campaign is the flagship
**Binance · Always-On EU** (6 EU airport hubs, 148 screens, 5 creative variants).

- **File:** `campaign-details.html` (single, self-contained HTML file).
- **Modal sub-page:** `contextual/contextual-changes.html` (the full "Contextual changes" experience,
  loaded in an in-page iframe modal).
- **Status:** built and approved 2026-06-17. This is the canonical home (moved out of the repo root /
  WORK IN PROGRESS during finalization).

> **Two companion docs.** This file is the design + component reference (what every section, rule and
> element is and how it behaves). For the engineering hand-off (what data the page needs, where it goes,
> and which functions to call to wire it to a real backend), see **`IMPLEMENTATION-GUIDE.md`** in this
> folder. The review landing page is **`index.html`** (links every state, the modal, and both docs).

---

## 1. How to run / review

Double-click `campaign-details.html` (or serve the folder). It is self-contained:

- Local: `blindspot.css`, `design-system/tokens.css`, `assets/campaign-map-eu.jpg`, and
  `contextual/contextual-changes.html` + `contextual/creatives/*.jpg` (see §12c on the JPG re-encode).
- CDN (online only, graceful offline fallback): **Google Fonts** (Anton, Archivo, IBM Plex Mono,
  Instrument Serif) and **Leaflet 1.9.4** (the interactive map). Offline, fonts fall back to system
  faces and the map renders the static `assets/campaign-map-eu.jpg` basemap with positioned pins.

To review every state: toggle **Light/Dark** and **Comfortable/Compact** in the header gear menu;
change **Range**; open a **hub row** (drawer); open **Contextual changes** (modal); resize the window
down to a phone width. Deep-link state is in the URL (`?range=`, `?hub=`, `?ctx=1`).

> All sample data is simulated and deterministic (seeded PRNG). No backend. "Review build, sample data."

---

## 2. Architecture & dependencies

- **One self-contained HTML file** with an inline `<style>` and one inline `<script>` IIFE.
- Links the **shared design system**: `design-system/tokens.css` (design tokens + the `.bs-*` semantic
  classes and `.bs-nav` site nav) then `blindspot.css` (components). The page never hardcodes a hex
  colour; everything reads `var(--token)`.
- The **canonical chart harness** is lifted verbatim from `design-system/charts.html` (see §4), so every
  chart on this page is the same code as the design-system reference.
- The **contextual modal** loads `contextual/contextual-changes.html` in an `<iframe>` so that page renders
  exactly as built (every chart, table, interaction), with no risk of class collisions. Theme is passed in
  (see §7).

### Fonts (roles, never mixed)
| Face | Weight | Role |
|------|--------|------|
| Anton | 400 | Display / big numbers / section H2 / stat values (UPPERCASE only) |
| Archivo | 400/500/600/700 | Body, sub-labels, button-ish prose |
| IBM Plex Mono | 400/500 | Labels, kickers, eyebrows, axis text, chips, table cells |
| Instrument Serif | italic | The one accent clause in a headline (`.serif-acc`), red on light / coral on dark |

---

## 3. Design rules (must hold on every change)

**Tokens (from `tokens.css`).** Colour: `--ink` (near-black text/ground), `--paper` (card surface),
`--mist` (page tint / hover), `--coral` (salmon-red accent), `--red`, `--red-deep` (deepest red =
the "positive" colour here), `--red-soft` (light pink fill), `--line` / `--line-strong` (hairlines),
`--muted` (secondary text), `--w-line` / `--w-muted` (on-dark hairline/secondary), `--heat-0..5` (the
heat ramp), `--status-warn` / `--status-warn-soft` (amber), `--bs-radius-*`, `--bs-shadow-*`, `--ease`,
`--dur-*`.

**Colour discipline.**
- **No green, anywhere.** Positive deltas, "ahead of pace", "under plan", up-arrows: all `--red-deep`.
- **One accent per view** (red/coral). The heat ramp (`--heat-0..5`) appears **only** in the daypart matrix.
- Variant condition colours (contextual): surge `--red`, night `#6E6A9B`, heat `#C99A4B`, rain `#6B8BB0`,
  base `--ink`. Muted, never neon.

**Currency & metrics.** USD only. The performance metric is **average cost per play** (`$0.42` actual vs
`$0.47` plan; floor `$0.23`). **Never** CPM, never cost-per-install. (The header "Objective: App installs"
is the campaign *goal* label, not a cost metric, and is allowed.)

**Voice (platform-quiet, per VOICE.md).** Sentence case in prose; UPPERCASE only in Anton display + mono
labels. Labels are literal ("Daypart", "Screens", "Budget"). Buttons say exactly what happens
("Launch campaign" → toast "Campaign live."). Never editorialize inside a workflow. **No em dashes** (use
comma/period/semicolon/parentheses/colon). Banned words: unlock, leverage, synergy, seamless, robust,
best-in-class, ecosystem, journey, solution (noun), elevate, empower, revolutionize, game-changing,
at scale (filler).

**Dark theme = token flip.** `body.theme-dark` remaps `--paper/--mist`→dark, `--ink`→light,
`--line(-strong)/--muted`→light-on-dark, and sets the page background dark. Most components recolour
automatically because they read tokens. **Targeted keeps** force intentionally-dark blocks to stay dark
(header, footer, Blinky card, contextual left panel, drawer head, menus, tooltips, the contextual modal
bar). **Dark-mode landmine (recurring):** any element that sets a *filled background* to `var(--ink)`,
or any `:hover` that goes `#fff` bg + `var(--ink)` text (or `var(--ink)` bg + `#fff` text), flips when
`--ink` inverts and becomes invisible. Fix with a `body.theme-dark` override using fixed `#0a100f` /
`#fff`. Cover child elements with their own hardcoded colour too (e.g. the range button's `<b>` value).

**Responsive.** Stat grids use `auto-fit minmax`; cells clip their own overflow and chips wrap so widgets
never bleed into neighbours. The media row scrolls horizontally with arrows in side gutters (not over
cards). The in-header section menu hides under 1040px and hands off to the navbar dropdown.

**Reduced motion.** `@media (prefers-reduced-motion: reduce)` disables the live pulse, count-ups,
shimmer, spinners, reveal transforms, and chart wipes.

---

## 4. The chart harness (canonical, from charts.html)

Helpers: `mk/add/txt/svg` (SVG builders), `tipShow/tipMove/tipHide` (the shared `#chTip` tooltip),
`wire` (hover+focus tooltip + keyboard), `ease`, `countUp`, `HEAT/heat`, `sx/syf/grid` (scales/gridlines),
`clipWipe` (left-to-right reveal, unique clip id per instance), `livePulse` (the breathing live dot),
`onFocus/focusHub` (cross-component pub/sub), `skel` (skeleton), `emptyState(host,msg,retry)` (graceful
empty/error).

Builders:
- **`flagship(cv, opts)`**: the time-series explorer (brush-to-zoom context strip, hover-scrub readout,
  history area + 21-day forecast confidence band + target line + "today"/"now" marker + live pulse + a
  data table). `opts`: `seed, scale, unit, fmt, aria, target, gran('day'|'hour'), win`. Unique clip id
  per instance. `gran:'hour'` switches to HIST 168 / HF 24 / 24h-72h-7d ranges. The history area path is
  `.fl-hist` (fill `--red-soft`; dark = translucent coral) and the data line `.ln.fl-line` (dark = white)
  so it reads in both themes. maxY includes `target*1.08` so the target line never clips.
- **`daypart(host, seed)`**: 7×24 heat matrix (the only place the heat ramp is used).
- **`calendar(host, seed)`**: plays-by-day calendar heat (per-cell tooltip; the for-loop closure bug is
  fixed by passing the cell into an IIFE).
- **`gauge(...)`**: radial progress (campaign pacing).
- **`linkedBoard(host, data, kind)`**: a cross-filter board: bars + 6-week trend lines + share donut,
  all three panels light up together on hover (`setA`).

---

## 5. Data model (simulated)

- **`HUBS`** (6): `{n, code, short, venue, screens, plays, impr, imprM, spend, spendNum, cpp, cppNum,
  share, rank, seed, pace, bdg, trend[6]}`. FRA, AMS, CDG, MXP, BCN, ZRH. `pace`/`bdg` drive the pace
  chips; `seed` makes every hub's charts deterministic and distinct.
- **`FORMATS`** (5): departures / hall / transit / plaza / gate, with `plays/share/rank/trend`.
- **`WIN`** (range datasets): `7d / 30d / flight / custom`, each `{lab, el (elapsed fraction), plays,
  playsB (booked), impr, imprB, spend, cap, win (days), prevPlays, prevImpr}`. `RANGEKEY` selects one;
  changing it rebuilds the overview stats, forecast, booked row, table (scaled by `plays/339000`),
  campaign flagships' default window, and the gantt interval.
- **Contextual page** (`contextual/`): its own `V` (5 variants with col/tint/cond/trig/rule/impf), `HUBS`
  (with screen counts), `RAIN/HEAT/SURGE` hour ranges across the flight, `dayShape/tempShape`, and
  `genData(W)` that builds hours/segments/events/aggregates per time window (24h/72h/7d).

---

## 6. Page structure, section by section

### Navbar (`.bs-nav`, sticky, always dark)
Home **icon** button (`.bs-nav__homebtn`, replaced the wordmark; a master nav + side menu come later),
breadcrumb (`Campaigns / Binance · Always-On EU`), spacer, the **section menu** (see handoff below) when
scrolled, then `My campaigns` / `Help` (`.bs-nav__btn`). Hover states forced dark-on-white in dark mode.

### Campaign header (`.cd-head`, dark ground)
- Eyebrow "Campaign · Live monitor", Anton H1 "Binance · Always-On EU".
- **Meta row** (`.cd-meta`): live status pill; `Auto-refreshing · synced HH:MM CET` with an inline
  **retry** icon (`#monRetry`, spins on click); launched date; `6 hubs · 148 screens`; `Objective App
  installs`. The synced clock updates on each refresh; a reconnect "blip" every ~47s shows
  "reconnecting…" then restores the clock.
- **Action row** (`.cd-actions`, sits **below** the meta): **Range** dropdown (`#rangeDrop`, 7d / 30d /
  flight / custom with date inputs), **Refresh**, **Report** dropdown (Download CSV / Print / Copy share
  link), **gear** (View settings: Density + Theme), **Manage** (jumps to controls). All dropdown menus
  are `position:fixed` and JS-anchored to the button (they were being clipped by the header's
  `overflow:hidden`).
- **Section menu** (`#cdJump`, `.cd-jump`): the 7-section quick-jump, rendered as an always-open panel
  pinned to the **right** of the header (mono section name + uppercase sublabel, coral active). A 1px
  sentinel (`#cdHdrEnd`) + IntersectionObserver hands off: while the header is in view the panel shows
  and the navbar dropdown is stowed; once scrolled past, the panel stows and the navbar **Jump to**
  dropdown takes over. Scroll-spy sets `.active` + `aria-current` on the active link in both menus.

### Data-delay banner (`.cd-banner`)
Soft amber band with an inset accent bar, a rounded warning-icon tile, "Reporting delay …", and a round
dismiss. Auto-shows ~1.5s then auto-hides ~9s (or on dismiss).

### 1 · Overview (`#ov-h`, "How it is going.")
- **Live KPI bar** (`statsBar` → `.dwstats`): 5 widgets, Plays / Impressions / Budget / Locations /
  Screens. Each pacing widget has a **pace chip** (▲ "5% ahead" / ▼ "behind" / ● "on pace"; budget uses
  "over/under plan"), the big Anton value, a **bullet bar** (actual fill + expected-by-now tick vs booked
  total), and a sub line. Plays/impressions tick live on the refresh cycle. Widgets reflow with
  `auto-fit minmax(178px,1fr)`; cells clip and chips wrap so nothing bleeds at small widths.
- **Forecast/benchmark strip** (`.ov-forecast`, 3 cells): Projected delivery (≈ plays/elapsed, % of
  plan), Cost per play ($ + % under plan), and vs-previous-period delta (or "Flight to date" cumulative).
- **"What you booked"** static row (`#ovBooked`): the 5 booked figures for the current range.
- **Needs attention** card (`.insights`): severity-coded items (attn amber / alert red / good coral) with
  a one-line why and **two controls each: a red primary action that acts on the insight inline, and a
  ghost "View hub"**. The primary actions are real, not links: Barcelona-behind opens the **Blinky fix
  chooser** (see below), Milan-offline is an **Acknowledge** (logs + marks done), Frankfurt-ahead is **Lift
  the cap** (raises the 30-day cap by $15k, updates the budget control + meter, scrolls to and flashes it).
  After acting, the row flips to a done state ("✓ Cap lifted to $195,000") with an **Undo**, and every
  action writes a change-log entry + toast. Under-pacing table rows also get a "behind pace" chip.
- **Blinky fix chooser** (`fixUnderpacingModal`): because Blinky is an agent, fixing an under-pacing hub is
  never a single forced action. It opens a **wide modal with four radio option cards** (`.fixopt`), each
  with a title, a plain-English description and a projected effect: **shift budget from the over-performers
  until it catches up**, **increase the total campaign budget** (+$15k, also updates the live cap),
  **replace the weakest screens** with higher-traffic equivalents (operator approval), or **raise frequency
  on screens with spare capacity**. Picking one + Apply logs the specific choice and toasts. Shared by the
  Barcelona insight, the overview Blinky card, and the per-hub drawer's behind-pace action.
- **Blinky live summary** (`.dwai`, dark ground + coral glow): a dynamic plain-English paragraph from the
  current range, 3 signal pills, "generated just now", and a **"Blinky can do this for you" action row**
  with a coral button (`.dwai-act`). On the overview it is **Fix Barcelona** (opens the chooser); the
  per-hub drawer card carries a **pace-aware** action (ahead → "Send more budget here" raises the cap
  weighted to the hub; behind → "Fix <code>" opens the chooser; steady → "Alert me if it slips").
- **Booked schedule** (`scheduleCard`, `#ovSched`): the adaptive, summarize-to-the-level-of-variation
  view. (1) A plain-language headline that adapts to complexity ("On 06:00 to 22:00, all 148 screens"
  when uniform, "N schedule patterns" when not). (2) A **rhythm heatmap** (`.bskr`, 7 day-rows x 24
  hour-cols, coral density = share of the 148 screens live in that hour) that shows the daily cadence and
  any day-to-day variation at a glance. (3) **Schedule-pattern cards** (`.bspat`): the booking grouped
  into its few distinct schedules, each with a screen count, a share bar, and an expand into a mini
  daypart strip (`.bspat-hours`) or, for day-varying patterns, a Mon-Fri / Sat-Sun micro-grid
  (`.bspat-dow`), plus the screen list. A `.bsk-toggle` [Patterns | Per-screen timeline] swaps the body
  to the zoomable `ganttCard` (the per-screen drill-down). Data: `SCHED` (the distinct patterns +
  per-pattern dayparts, day-of-week variation, screen counts and lists). The Gantt (see below) is now
  the drill-down, not the primary view. Power features: **cross-highlight** (hover/focus a pattern to
  isolate its footprint in the rhythm heatmap); a **now marker** (current day+hour cell outlined); a
  **Booked / Delivered** toggle (Delivered recolours by actual share, rings under-delivered cells, and
  shows a delivery-% note; the morning band reflects the 2 delayed hubs); a **week selector** (typical vs
  final wind-down week, `weekPatterns()`); a per-pattern **daily-shape sparkline** + plain-English
  descriptor; **Find a screen** + per-pattern screen filter + **clickable chips** (open the hub drawer);
  **click a heatmap hour** to filter the patterns live then (clearable chip); **Expand/Collapse all**;
  smooth expand; **CSV export**; arrow-key navigation + ARIA on every cell.

  **Masterpiece pass (the information-design layer).** Above the rhythm heatmap the component now opens
  with three things. (a) A **composition + audience chart** (`compChart()` -> `.bskc`): a typical-day,
  24-hour **stacked column** where each hour is built from the schedule patterns that are live then
  (constant always-on base, daytime, evening, day-of-week split), with a **halo-outlined dashed audience
  line** (`AUD24`, airport footfall) overlaid on the same screen axis. Reading the bars against the line
  is the whole point: where bars are tall but the line is low (overnight) you are paying for cover with
  little audience; where the line outruns the bars you are under-covered. Per-hour tooltip breaks down
  screens by pattern + the audience index + a one-word match read. (b) A **Blinky read** (`.bsk-read`,
  `buildReadHTML()`): a single plain-English sentence computed from the selected week, naming the peak
  coverage hour and the overnight cover-vs-audience gap, with a **Copy** button. (c) A **13-week flight
  ribbon** (`.bsk-ribbon`, `FLIGHT`): the real calendar (Apr 1 to Jun 24), the current week ringed, and
  holiday / wind-down / maintenance weeks dot-marked; clicking a week (`selectWeek()`) repaints the
  composition, the heatmap, the read and the note for that week's `variant` (`weekPatterns()` handles the
  holiday and final-week wind-down reshapes; `weekNote()` writes the caption + any maintenance note). The
  heatmap metric toggle gained a third option, **Audience** (recolours the grid by `AUD24` so you can flip
  Booked vs Audience on the weekly grid too). Hovering a pattern now also lights **its hubs across the map
  and the table** (multi-hub `focusHub([codes])` via the `hubHit()` membership helper). Exports: the
  composition chart is **Download .svg** (`exportComp()` resolves CSS vars to concrete colours + inlines a
  minimal stylesheet so the file is self-contained). Motion: a **staggered cell reveal** on mount and a
  gentle **now-cell pulse**, both disabled under `prefers-reduced-motion`. Light + dark verified
  (the audience line's halo casing flips with the theme so it stays legible over the inverted bars).

  **Styling refinements (latest).** The composition chart now carries a **live now-ticker**
  (`compChart(v, live)`): a red dashed vertical line at the current time-of-day with a pulsing dot and a
  red pill reading `HH:MM · N live` (N = screens scheduled live this instant on today's day-of-week),
  ticking every second via `compTimer` (`setInterval`). It only renders on the **current** flight week
  (`nowOK()`); switching to any other week or to the per-screen timeline clears the interval (so there is
  no detached-node updating). The audience line was refined to match the page's chart lines: a **smooth
  Catmull-Rom curve** (`smoothPath()`), a slim 1.6px dash (5/5) with round caps, a thinner halo casing,
  and the per-point dots removed. The section labels (`Flight · 13 weeks`, `A typical day, by schedule`,
  `Weekly rhythm`, `N distinct schedules, grouped`) are now `var(--red-deep)` to match the `.cklab`
  section-label convention.

  **Live across the component + interaction.** The live tick is now a single `compTimer` interval
  driving `paintLive()`, which moves the chart now-line/pill AND rewrites the Blinky read's opening
  **live clause** ("Right now HH:MM · N of 148 screens live, audience at X% of peak.") every second on
  the current week (the heatmap now-cell already pulses, so "right now" is felt across chart, read and
  grid). The composition chart has a **hover crosshair** (`.bskc-cross`, a thin dashed vertical guide
  that snaps to the hovered hour alongside the tooltip, hides on leave). A collapsible **what-if** panel
  (`.bsk-wif`) lets you toggle any schedule pattern off or **Trim 00:00 to 06:00** and see modelled
  **screen-hours, audience reach and spend** vs the booked plan, with a plain-English takeaway
  (`wifCalc()` scales spend by screen-hours and reach by audience-weighted hours, so trimming low-audience
  overnight time shows a large spend cut for a tiny reach loss). **Per-hub drawer:** above the per-screen
  Gantt, each hub drawer now opens with a compact **"When this hub runs"** block (`hubScheduleBlock()`):
  a one-line summary plus a small stacked-by-pattern chart with the audience line, scoped to that hub via
  `hubAlloc()` (the hub's screens split across the patterns it belongs to). The Gantt remains the
  per-screen drilldown. **This chart is fully interactive (`hubCompMini`):** 24 full-height hit columns
  drive a **crosshair**, a **column highlight** (the hovered hour stays full-opacity, the rest dim), an
  **audience dot** that rides the opportunity curve, and a rich tooltip (hour range, total screens live of
  the hub total, the **by-pattern breakdown** with colour swatches, and **audience opportunity as % of
  peak**). A faint now-line marks the current hour. Each column is focusable (`tabindex`, `role=img`,
  `aria-label`) so the breakdown is keyboard- and screen-reader-reachable.
- **Map** (`mapCard`, `#ovMap`): see Map below.
(Schedule + map are deferred behind skeletons and wrapped in try/catch → `emptyState` + Retry.)

### 2 · What ran (`#ran-h`, "What ran, live.")
Two toggles (`.dch-toggles`): metric [Plays | Impressions] and granularity [Per day | Per hour]; only the
selected metric chart shows. Both are the **flagship** explorer (`#cFlagP` / `#cFlagI`). Subhead notes
"Times in CET/CEST".

### 3 · Where it ran (`#where-h`, "Where it ran.")
- **Per-hub table first** (`.cd-table`): Hub / Venue type / Screens / Plays / Impressions / Spend /
  Cost per play / Status / **Details** (arrow), red header text, totals footer. **Rows are clickable** →
  the hub detail drawer. Cross-highlight: hovering a row lights the matching map pin + gantt rows.
- **Cross-filter boards** (`linkedBoard` ×2) with a [By hub | By format] toggle, one shown at a time.
- **Hub detail drawer** (`.drawer`, slides from right, z above the sticky nav): per-hub real-time stats
  bar (plays/impr/budget pace + bullets, live ticking while open), a Blinky per-hub summary, the hub's
  gantt, plays + impressions flagships (Per day/Per hour), and daypart + calendar heatmaps. Focus-trapped,
  Esc to close, returns focus to the trigger.
- **Explorable table (masterclass).** The table is now JS-built (`paintTable`, `whereState`): a toolbar
  **pivots** the whole table By hub / By format / By screen; **every column header is sortable** (click to
  sort, caret + `aria-sort`); a **search** box filters rows; a **Needs attention** quick-filter (hub view)
  isolates behind-pace hubs; **Export CSV** downloads the current pivot+filter. Pacing is a **visual**
  (`paceCell`: ▲/▼ + a small bar) instead of a status word, each row carries a labeled **Trend · 30d**
  sparkline, and the footer shows the **campaign average** (not just totals). A **compact Europe locator**
  (`.where-map`, `WCOORD` pins) sits beside the table on wide screens and cross-highlights with row hover
  via `focusHub` (hidden in format/screen pivots, where `.where-grid.no-map` goes full width).

### 4 · Media hub (`#media-h`, "Media hub.")
Single horizontal **carousel** (`.media-grid`, `overflow-x:auto`) of 5 creative cards (`.mtile`):
lit-screen billboard motif (orb glow + scanlines + sheen), Playing/On-trigger + rank, Anton headline,
hollow spec chips, share-of-plays bar, trigger pill, "default rotation". Hover = lift + **soft red glow**
(no rectangle: the grid has generous vertical padding so the glow is not clipped). Prev/next **arrows sit
in side gutters, outside the cards** (the grid has `margin:0 52px`; arrows at the wrap edges). The arrows
appear only when the row is scrollable (`.can-l/.can-r`). The grid is now **JS-rendered from a `MEDIA`
array** (one IIFE) so it supports states + actions. **Pause a creative:** every card has a Pause/Resume
control; pausing asks a confirm ("the other creatives keep running, Blinky reweights toward them"), then
the card greys out with a "Paused" badge and the live chip flips to Paused (status `live`/`paused`).
**Upload a creative:** a dashed "Upload creative" tile opens a modal (file picker + name) that states **new
creatives must be approved by the screen owner/operator before they run**; submitting adds a card in a
`pending` state ("In review" badge, amber "Awaiting approval", no plays yet).
**Masterclass pass:** the section is renamed **Media hub** and the **Upload creative** tile sits **first**;
each card now has a **simulated player** (centre play button -> progress bar + ticking timecode, one plays
at a time, reduced-motion safe), shows **plays + cost-per-play + completion** (`.mkv`) and an **approval
chip**, and a **tools row** above the grid (approval summary + **Sort** by Plays / Cost-per-play / Completion
+ **Show** filter by All / Live / Paused / Pending, with an empty state). All overlaid text sits over a
**legibility scrim** (`.mscreen::before`, top+bottom dark gradient).

**Creative detail drawer (10/10).** Clicking a card opens a drawer (reuses the drawer chrome) built as a
real DOOH playout view: a **bezel frame** (`.mdrawer-frame`) with a **brand bar** (BINANCE + live/paused/
pending pill) and a **player** (`.mdp-play`: centre play -> progress bar + ticking timecode on `dwPlayT`,
cleared on reopen and on close); a **3-up stat row** (`.msd` = Plays, Cost-per-play vs base, Completion with
a `.msbar` completion bar); an **interactive plays-trend chart** (`drawerTrend(m)`): area + line + 6 week
dots, W1-W6 axis labels, a header delta chip ("▲ +40% over the flight"), and **best-in-class hover**: a
**crosshair**, a **growing active marker** that lands on the hovered week (its static dot hides under it),
and a rich tooltip showing **plays, share of flight, week-over-week delta, and a peak-week flag**; every
week is focusable (`tabindex`/`role`/`aria-label`) so it works by keyboard. A **per-hub "where it ran"** set
of bars (`.mwh`, splits the creative's plays across its hubs by screen count); and an **approval timeline**
(Uploaded -> Approved by N operators -> Live, or Uploaded -> Operator review -> Goes live for pending).
**In day-0** the creative drawer reframes: status Scheduled, "Booked · not serving" meta, a day-0 panel
replaces the stats/trend/where, and the approval timeline shows "Approved · ready to serve" then "Goes live
at the next cycle".

### Top performing screens (`#cRank`, in "What ran")
The ranked list is now driven by `TOPSCR` (8 screens) and `rankedScreens()`. A **metric toggle**
(Plays / Impressions / Reach) re-ranks and re-animates the bars; values are **range-aware** (scaled by the
header range). Each row shows **movement vs last period** (`▲n` red / `▼n` muted, computed from the
previous-period ranking, shown as `▲n`/`▼n`/`±0` under the rank number) and a **labeled % change vs last
period** next to the value (`▲9%`/`▼5%`, the old ambiguous sparkline was removed for these explicit signals),
plus the value and a bar. Hovering a row **cross-highlights**
that screen's hub on the map + table (`focusHub`). A **Show all / Show top 5** control reveals the tail
(including the laggards). The figure note rewrites per metric, including a plain-English definition of the
reach index. `rankRepaint` is wired into `setRange` so the list follows the global range.

### 5 · Contextual changes (`#ctx-h`, "What changed, where, and why.")
Condensed teaser, then a button into the full modal.
- **Left panel** (`.ctxx-left`, dark): a live "now playing" mini billboard (orb + scanlines, kicker /
  headline / trigger) + a horizontal **variant rail** of 5 chips; clicking a chip previews it in the
  screen.
- **Right:** a 3-cell **stat bar** (5 Variants active / 9 Swaps · 30 days / 11% Lower cost per play, the
  11% in red), and the **swap feed** (`.ctxx-feed`): a clean table (When / Variant / Where / Trigger) with
  colour-coded dots and outlined trigger pills (the active "price > $90k" in red, others gray). CTA button
  "Open contextual changes".

  **Masterclass pass (proof + linkage + cadence + life).** The whole card is driven from `CTXV` (now with
  a per-variant `cpp`) and a `SWAPS` log, all in one IIFE. (a) **Outcomes, not just share:** each variant
  rail chip shows its **cost-per-play and lift vs the base spot** (`liftTxt()`), and a dedicated
  **proof bar** (`.ctxx-proof`, `#ctxProof`) renders **base spot $0.47 vs blended contextual $0.42** with a
  `-11%` delta, computed live from the variant CPPs weighted by share, so the headline 11% is shown, not
  asserted. (b) **One linked system:** a single `show(v)` updates the preview screen, lights the matching
  rail chip, and marks the matching **swap-feed row** (`.ctxx-frow.sel`); hovering/focusing/clicking a feed
  row OR a timeline tick previews that variant in-card. The full modal is now reserved for the deliberate
  **CTA** (rows no longer jump to it). (c) **Cadence + logic:** a 30-day **swap timeline**
  (`.ctxx-tl`, `#ctxTL`) plots all 9 swaps as trigger-coloured ticks (hover = tooltip + preview), and a
  **"What Blinky is watching"** rule strip (`.ctxx-rules`) lists the active triggers with the currently
  firing one lit (`.firing` -> "live"). (d) **Life:** the variant rail has an edge fade-mask + scroll-snap;
  the live "since" ticks ("live for Xh Ym"); and on load the preview does a gentle **base -> live replay**
  (skipped under `prefers-reduced-motion`). The deep tools (replay scrubber, variant library, per-hub log,
  per-variant performance) still live in the modal.

  **Latest:** the live preview is now a believable **DOOH frame mockup** (`.ctxx-frame`): a device bezel,
  a brand bar with the Binance lockup + a context **ticker** (`#ctxTick`, e.g. "BTC $92,140" / "Barcelona
  31°C", set per variant in `show()`), screen gloss, and an "on air" footer caption. The variant rail is
  now a **centered, structured 2-column grid** (wrap + `justify-content:center`) instead of a clipped
  scroll. (Header / Manage) **Download CSV** now exports a **full multi-section campaign report**
  (summary KPIs, by hub, by format, top screens, schedule patterns, contextual variants + the 9-swap log;
  `CTXV`/`SWAPS` are now module-scope so the export can read them). **Extend the flight** opens an options
  modal (`+1 / +7 / +30 / Custom` with a day-count input; the confirm button reflects the choice) via the
  generalised `openModal({html, onOpen})` with a full focus trap.

### 6 · Manage (`#controls`, controls)
Live/Paused segmented (with a **confirm modal**), inline budget cap field (validation + a `.capmeter`
fill bar), context-rule switches, action buttons, and toasts. Anything that spends money or pauses asks
to confirm first. The confirm modal is focus-trapped and returns focus.
**Masterclass pass:** a one-line **status** pill leads the section (`#ctrlStatus`, `paintStatus`: Live/Paused
· spend of cap (%) · N rules active · ends Jun 24, recomputed on every change). The cap field shows a live
**impact preview** as you type (`#capPreview`: money left, ~more days, ~more plays at $0.42). Each context
rule carries a **value line** (`.tv`, e.g. "Fired 4× · cheapest spot at $0.40 / play") and **toggling one
asks a confirm** (it changes live delivery). A full-width **Recent changes** audit log (`#changeLog`,
`logChange`) records every pause/resume, cap change, rule toggle, duplicate and share, newest first.
**Duplicate** opens a draft-name modal; **Share link** opens a copyable read-only link field, both via the
generalised `openModal`.

### 7 · Ops console (`#ops-h`)
Reworked from a tucked-away disclosure into a full **ad ops console** (`buildOps()`), built for an ad ops
manager who needs status fast and fixes faster:

- **Campaign health** (`#opsHealth`): an overall state chip (Live / Scheduled) + the campaign name, then a
  4-up health grid (Screens online, Creatives live/in-review, Pacing, Approvals), each with a real
  `stateChip` from the state library.
- **Needs your attention** (`#opsQueue`): the live ops queue, each row a state chip + headline + one-line
  context + one-click actions: the offline screen offers View hub / Message operator / Acknowledge; the
  rejected creative offers Replace / Appeal; the behind hub offers **Fix it** (the Blinky chooser); the
  in-review creative offers Nudge operator / Open media. Acknowledge marks the row done inline.
- **Quick actions** (`#opsActions`): Pause all delivery, Reallocate budget (Blinky chooser), Message an
  operator, Escalate to the account team (confirm), Export ops report.
- **Status & state reference** (`#opsStates`): the full state library rendered as a legend grouped by
  Campaign / Creative / Screens / Pacing, so the dev team can see every state, its copy and its colour.
- **Internal record**: the original taxonomy + IDs, kept in a second `<details>`.

In day-0 the console reframes (status Scheduled, screens 0/148, approvals 2 of 6 pending, the queue shows
"Media plan awaiting 2 operators" with a Nudge action).

### Footer (`.cd-foot`)
Minimal: legal row + "© 2026 TPS Engage". Kept dark in both themes.

---

## 7. The Contextual changes modal (the iframe overlay)

Opened from the section-5 CTA or any swap-feed row. It is a near-fullscreen overlay (`.cf-overlay`,
z 9980) holding a panel (`.cf-panel`): a slim dark bar (`.cf-bar`) with a **"Back to campaign"** pill
(`#cfClose`), a breadcrumb, and a round close (`#cfCloseX`), over an `<iframe id="cfFrame">` that loads
`contextual/contextual-changes.html`. Lazy-loaded on first open with a spinner (`.cf-load`).
Back / × / scrim-click / Esc close it; body scroll-locks while open; focus returns to the opener; state
is reflected in the URL (`?ctx=1`).

**Theme sync.** The master passes its theme to the iframe: the `src` carries `#theme=dark|light` on first
open (captured in the contextual page's `<head>` into `window.__bsThemeInit` *before* its own
`syncURL()` can strip the hash), and `postMessage({bsTheme})` is sent on load and whenever the theme is
toggled while the modal is open. The contextual page listens and toggles `body.theme-dark`.

### Inside the iframe (the contextual page), component by component
- **Tiles** (`.tiles .perf-card` ×4): Contextual swaps / Plays via context (+ sparkline) / Active rules /
  Impressions, count-up animated.
- **Context replay** (`.replay`): the timeline `#tl` (SVG, lanes CREATIVE / WEATHER / DAYPART / MARKET,
  colour segments, the BTC line + "+5% trigger", surge/heat/rain bands, a draggable playhead, Play to
  scrub, keyboard arrows). A **legend** isolates a creative. The right side is the **now-playing screen**
  (`.replay-side`, dark gradient): a creative billboard (`.bb-screen`, real 16:9 DOOH frame with a
  legibility scrim; **"Live" pulsing dot pinned top-left**, creative + brand anchored bottom), a
  why-paragraph, 3 readouts (weather / local time / BTC), and "live on these hubs" chips. The billboard,
  the rule strip, the context readouts and the hub chips all **update together** as the playhead scrubs.
- **Creative variants & rules** (`.rail` of `.vcard`): the 5 creatives, each with its trigger rule, plays
  / impressions / activations, and a share bar; click a card to replay when it ran. Prev/next rail buttons.
- **Swap log** (`.log` of `.ev`): every contextual replacement, newest first, with a signal filter
  (All / Weather / Daypart / Market); click a row to expand the per-hub delivery table + narrative;
  "view on timeline" jumps the playhead.
- **How each variant performed** (`.vperf`): ranked bars by plays or impressions (`.psort` toggle).
- **Window** control 24h / 72h / 7d (`.seg`), and **export** (Copy link / CSV / PDF print). Reduced-motion
  + keyboard safe; deep-link via URL params + localStorage.

The contextual page has its **own dark theme** (token flip + keeps for its hardcoded light surfaces:
`.replay/.log/.vperf/.vcard/.ro/.xbtn/.rail-btn/.bs-foot`→ dark, `.replay-side` gradient → dark, the
`.seg`/`.psort` active pills → white+dark-text, the `.ev-row`/`.perf-card` hovers → dark) and a theme-sync
script.

---

## 8. Shared elements catalogue

| Element | Class | Notes |
|---|---|---|
| Button | `.pb` | Pill; `.pb-primary` (coral; hover = white fill + **red border + red text**, visible on light), `.pb-onink` (on dark; hover white + dark text, dark-mode fixed), `.pb-sm` |
| Segmented control | `.seg` / `.seg.sm` | Active pill = ink (dark-mode override: white pill + `#0a100f` text) |
| Header dropdown | `.hdrdrop` + `.hdrdrop-menu` | `position:fixed`, JS-anchored, scroll/resize aware, mutually exclusive |
| Pace chip | `.pacechip` (`.up/.down/.flat`) | "5% ahead", "on pace", "X% over plan"; up = `--red-deep` |
| Bullet bar | `.bullet` | Actual fill `i` + expected-by-now tick `b` over booked total |
| Trigger pill | `.fw-trig` / `.cf` pills | Outlined; active (surge) = red border + red-deep text |
| Condition dot | `.cdot.surge/.night/.heat/.rain/.base` | Muted variant colours |
| Tooltip | `#chTip` | Shared SVG-chart tooltip; offsets below cursor, flips near viewport bottom; kept dark |
| Map tooltip | `.leaflet-tooltip.gtip` | Plays / Impressions / Budget; kept dark in dark mode |
| Toast | `.toast` | Result-stated ("Campaign live."), bottom stack |
| Banner | `.cd-banner` | Amber reporting-delay status |
| Modal | `.scrim` + `.modal` | Confirm dialog, focus-trapped |
| Drawer | `.dwscrim` + `.drawer` | Per-hub detail, z above nav, focus-trapped |
| Skeleton / empty | `.sk` / `.cd-emptystate` | Shimmer load + graceful empty/error + Retry |
| Stat label | `.cklab` | Card kickers ("Booked schedule", "Where it ran · map"); renamed off `.sk` to avoid the skeleton collision |

---

## 9. Interactions & state

- **Global range** rebuilds every overview surface + flagship default window + gantt interval; persisted
  to localStorage (`bs_cd_range`) and the URL (`?range=`).
- **Deep-linkable state:** `cdSyncURL()` writes `?range=&hub=&ctx=1` (history.replaceState) on range
  change / drawer open+close / modal open+close; `cdReadURL()` (run after the localStorage restore, so
  the URL wins) reopens range + hub drawer + contextual modal. "Copy share link" copies the live URL.
- **Export:** CSV of the per-hub table for the active range (header carries range + "times CET" +
  generated time); Print/PDF; Copy link.
- **Cross-highlight:** `onFocus/focusHub` pub/sub links table rows ↔ map pins ↔ gantt rows.
- **Density + theme** toggles (gear menu) flip `body.compact` / `body.theme-dark`, persisted
  (`bs_cd_density` / `bs_cd_theme`); the theme also syncs into the contextual iframe.
- **Live monitor (simulated):** `synced HH:MM CET`, a 30s auto-refresh, a 47s reconnect blip, and
  live-ticking plays/impressions in the overview + open drawer.
- **Onboarding:** one-time welcome toast + a pulse-hint, gated by `bs_cd_onboard`.

---

## 10. Accessibility

Sentence-case prose; mono labels. Focus-visible outlines. Focus **trap + return** in the confirm modal,
the hub drawer, and the contextual modal (shared `trapTab`). `aria-current` on the active section link.
Tables use proper headers; clickable rows are `role="button"` + `tabindex=0` with Enter/Space and an
aria-label. Charts expose `aria-label` and keyboard scrubbing. `prefers-reduced-motion` fully covered.
Dark mode keeps every label/value/▸ legible (the white-hover and ink-fill landmines are all overridden).

---

## 11. CSS class catalogue & known collisions

`blindspot.css` already owns several names; platform classes were renamed to avoid clobbering them.
**Known collisions (grep before naming a new class):** `.progress` (fixed scroll bar) → use `.capmeter`;
`.spec` (ink chip) → `.mspec`; `.lead` (bordered) → `.mlead`; `.sk` (skeleton shimmer) → label class is
`.cklab`; plus `.filters/.perf/.crumb/.status/.sw` are taken. **Dark-mode landmines (override in
`body.theme-dark`):** any filled `background:var(--ink)`; any `:hover{background:#fff;color:var(--ink)}`
or `{background:var(--ink);color:#fff}`; and child elements with their own hardcoded colour.

---

## 12. How it was verified

jsdom (runScripts dangerously + matchMedia/rAF/IO/RO polyfills + a chainable Leaflet stub; `process.exit`
because the page's setInterval keeps Node alive) for zero JS errors, and Playwright Chromium for the
visuals (sandbox needs an `libXdamage.so.1` stub via `LD_LIBRARY_PATH=/tmp/libstage`). Verified across
~16 QA rounds: every section builds; light + dark; responsive down to ~520px; deep-links; the contextual
modal loads and renders (19 timeline segments, 11 swap rows, per-hub table, performance bars); zero
em-dash / € / CPM / "all-in" anywhere; copy passes the VOICE.md banned-words list.

In the sandbox the interactive Leaflet map and Google Fonts cannot load (no external network), so those
fall back to the static basemap and system fonts; the live interactive map is verified by code, not
screenshot.

---

## 12a. Day-0 / empty state (campaign booked, nothing served yet)

Append **`?state=day0`** to the URL (or click **Manage → Preview day-0 state**) to see the page the moment
the campaign is booked but no screen has served. A `DAY0` flag (read from the query string) is checked by
every builder, on **every surface, section, drawer and modal**:

- **Overview**: live widgets read **0 plays / 0 impressions / $0** against the booked target with a "not
  started" sub; the forecast shows **Booked · starts at the next cycle**; insights collapse to one
  **"Campaign is booked and ready"** card with **Notify me at launch**; the Blinky summary is reframed.
- **What ran / Top performing / both linked boards / where-it-ran table**: each renders the day-0 panel.
- **Map**: pins stay (booked locations), the summary reads "serving begins at the next cycle", and pin
  tooltips show **screens booked / not serving yet / $0.47 plan** instead of delivery.
- **Media hub**: every creative card flips to a **"Scheduled / Booked"** state (no plays metrics, a "Booked
  and ready" note, no pause control); the tools row reads "5 creatives booked · ready, not yet serving".
- **Per-hub drawer**: opens with a zeroed booked stat bar, a reframed Blinky card, the day-0 delivery panel,
  and **keeps the booked "When this hub runs" schedule + Gantt** (the plan is valid at day 0).
- **Contextual modal** (the iframe receives `?state=day0`): header reads **Booked · not live / Not started ·
  0%**; tiles read **0 swaps / 0 plays via context / 5 variants booked / 0 impressions**; the replay, swap
  log and performance board each show a day-0 panel; the variant rail keeps the 5 booked creatives with a
  **Booked** badge instead of Live.

**The day-0 visual system** (`day0Note` on the main page, `cxDay0` in the contextual page) is a deliberate
empty-state, not a bare line: a rounded **icon badge** (context-specific glyph: chart / table / map / swaps)
sitting in a soft **coral glow**, over a **fading dotted-grid backdrop**, with a headline, a one-line
explanation, and a **"Starts at the next cycle"** pill. It has a full dark-mode treatment. A red
`cd-banner--info` "Day-0 preview" banner sits on top with **Exit preview**. Normal (live) rendering is
completely unaffected.

## 12b. Responsive / phone

Both files ship a phone pass. On the master page a `@media(max-width:600px)` block tightens section + header
chrome, makes header + control action buttons full width, stacks the stat bar to 2-up (then 1-up under
400px), wraps the Blinky action row to a full-width button, and the wide tables keep their existing
`.cd-tablewrap{overflow-x:auto}` horizontal scroll. The drawer is `min(720px,96vw)` and the modal
`max-width:400px;width:100%`, so both already fit a phone. The contextual page reflows its grids
(tiles → 1-up, replay → stacked, perf rows compacted) at its own breakpoints.

## 12c. Assets

The Gemini basemap and the 5 contextual creatives were re-encoded **PNG → JPG** (the dark gradient overlays
hide any artifacts): the set dropped from **~6.9 MB to ~288 KB**. The basemap (`assets/campaign-map-eu.jpg`)
is only applied as the Leaflet **fallback** (`.map-static`), so it is not fetched on the happy path. The
contextual creatives are **warmed two-at-a-time on load and the rest lazy-loaded on idle**
(`requestIdleCallback`), so nothing over ~55 KB blocks first paint.

The **day-0 empty states** use 5 bespoke **Gemini-generated illustrations** (`assets/day0/*.jpg`: ready,
chart, swaps, map, table) in the house style (warm-paper ink linework, a single coral-glowing screen). The
split is deliberate: **raster illustrations for the large empty-state art** (evocative, on-brand) and
**crisp inline SVG for the small status pills** (themeable at 16px, dark-mode safe). The art is downscaled
to 320px and JPG-compressed (~52 KB for the set), framed in a rounded tile with a coral glow behind it so it
reads well on both light and dark.

## 12d. Status & state library (dev-wireable)

`BS_STATE` is a single registry the dev team wires to live values; `stateChip(id)` renders a consistent
pill. Each entry is `{label, tone, icon, desc}`. **Tones** (and their colour, no green anywhere): `live`
(coral, pulsing dot), `positive` (red-deep), `info` (ink), `warn`/`pending` (amber), `critical` (solid
red), `paused`/`neutral` (muted). **23 states across four domains:** 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`), Screens
(`online`, `offline`, `reconnecting`, `maintenance`), Pacing (`ahead`, `on_pace`, `behind`, `at_risk`).
Copy is written for the operator (e.g. `offline` = "Dropped connection. No plays until it returns."). The
Ops console renders the whole set as a legend; the dev team swaps the hard-coded ids for live system values.

## 12e. Notifications & alerts

- **Notification center**: a bell in the nav (`#bellBtn`) with an unread badge (`#bellBadge`) opens a dark
  panel (`#notifPanel`) listing `BS_NOTES`. Each note has a severity icon (from the state library), title,
  time, body and an action button that routes to the right place (open a hub drawer, scroll to a section,
  or open the Blinky fix chooser). "Mark all read" clears the badge. The dev team replaces `BS_NOTES` with
  the live event feed; `noteAct(kind,arg)` is the single router.
- **Alert banners**: `alertPush(tone,title,body,act)` pushes a dismissible, severity-tiered banner into
  `#alertStack` under the header (critical / positive / warn tones). High-severity events (a screen
  dropping connection) surface here as well as in the bell. Banners animate out on dismiss.
- **Toasts** (`toast()`) remain for transient confirmations.

## 12f. Design + scaling polish (v2.41)

- **Day-0 reaches the contextual section's left panel** too: the variant rail shows **Booked** (no
  delivery cost), the rules strip reads **"What Blinky will watch"** with nothing firing, and the billboard
  chrome reads **Scheduled** instead of "on air".
- **One alert at the top, never two.** The critical alert banner (`alertPush`) is the single priority; the
  generic reporting-delay banner is retired (its `hidden` attribute now actually works: `.cd-banner[hidden]
  {display:none}` was added, since `.cd-banner{display:flex}` had been overriding it).
- **Wide tables signal scrollability.** `.cd-tablewrap` uses a pure-CSS **scroll-shadow** (layered
  gradients with `background-attachment:local`) so when the table is wider than the viewport, a soft shadow
  appears on the scrollable edge and fades at the ends, with a dark-mode variant. The clipped column now
  reads as "scroll for more," not broken.
- **Status language unified.** The where-table "behind pace" chip (`.attn-chip`) now matches the state
  library's warn tone (border + colour), so every status across the page speaks the same visual language.

## 12g. v2.42: schedule day-0, Blinky escalation, flat art, motion

- **Booked schedule day-0**: `nowOK()` is gated by `DAY0`, so a single switch removes every "now/live"
  marker; the rhythm "Delivered" toggle is hidden, the sub reads "not yet serving", and the Blinky read
  opens with "Nothing has served yet, this is the rhythm you booked." The plan view itself stays (that is
  the point of a booked schedule).
- **Media status chips**: `.mstat` is now a proper info chip (dot + border, with a warn `.pend` variant),
  so the day-0 "Scheduled" and the pending states are styled and aligned in the card footer.
- **Blinky is always a chooser, and can always escalate to humans.** `blinkyChooser()` powers every Blinky
  action; `hubAction(h)` opens it for **all** pace states (ahead / behind / steady) with context-specific
  options, and **every option set ends with "⚑ Flag to Blindspot AdOps"** (`flagOption`). Flagging calls
  `flagToAdops()` which drops an unread notification, logs it and toasts, so the human ad-ops hand-off is a
  first-class path from anywhere Blinky surfaces a problem.
- **Day-0 illustrations** were rebuilt as friendly **flat-vector** art (Google-Workspace empty-state style:
  clean shapes, floating geometric accents) in the Blindspot palette, on white, framed in the tile.
- **Motion**: modal/scrim scale-and-fade entrance, a **staggered** section reveal (IntersectionObserver
  applies an incremental `transition-delay` so content cascades in), and notification hover, all behind
  `prefers-reduced-motion`.

## 12h. v2.43: report / wrap mode (the third lifecycle pillar)

Append **`?state=ended`** (or `?state=report`, or Manage → Preview report state) for the finished-campaign
retrospective, the sibling of day-0. An `ENDED` flag (parallel to `DAY0`) drives it; `RANGEKEY` is forced to
the full flight and a `WRAP` object holds the locked final figures.

- **Campaign wrap panel** (`buildWrap()` -> `.cd-wrap` at the top of the overview): a "Final report" kicker
  with the flight dates, an Anton verdict ("cleared its plan."), a plain-English lede, a 6-tile **outcome
  grid** (plays/impressions/cost-per-play/spend/contextual saving/top hub, each vs the booked plan), and
  **Download the full report** + **Share recap** actions. The overview heading flips to "How it went."
- **Stat widgets** show final delivered vs booked (% of plan, % of cap, cost under plan); the forecast row
  is dropped; **insights become "Flight highlights"** (what worked, what to carry forward); the Blinky
  summary gives a **closing read** ("the flight is complete and it landed well...").
- **Header**: status reads **Ended**, the live ticker becomes "Flight complete · <dates>", and
  auto-refresh, the now-markers (`nowOK()`), and the critical alert are all suppressed.
- **Ops console** reframes to a closed campaign: all-ran health, a "nothing is open" queue, and
  retrospective quick actions (Download wrap report / Duplicate for next flight / Brief the account team).
- A `cd-banner--info` "Final report" banner sits on top with **Back to live view**.

Together with day-0 and the live default, the page now spans the full lifecycle: **booked
(`?state=day0`) -> live (default) -> wrapped (`?state=ended`).**

## 12i. v2.44: ENDED status sweep (nothing reads "live" once wrapped)

The companion to the wrap panel: in `ENDED` state, **every status, badge, ticker and verb across the whole
surface stops saying live / delivering** and reads as finished. This is enforced in four places so none is
missed:

- **Main page**: the header status pill, the overview stat chips, the booked-schedule "now" markers, the
  map and table pacing chips, and the "What ran, live." heading all branch on `ENDED` to read "Ended" /
  "Ran" / final figures instead of pulsing live states.
- **Per-hub drawer**: the hub status line, the schedule "now" marker, and the pacing read switch to the
  retrospective (`Ran`, final pace vs plan), never "live delivering".
- **Media hub + creative drawer**: creative cards show **`Ran`** (not `Live`) via the `cr_*` status set,
  the pulse is removed, and the drawer's status block reads the closed state.
- **Contextual modal (iframe)**: the camera status reads **"Ended"**, pacing reads **"Complete · 102% of
  plan"**, the big-board chip and rail badges read **"Ran"**, and the live replay framing is retired.

The rule for any future surface: if it shows a status, badge, or verb, it must check `ENDED` and read the
finished equivalent. The status library (`BS_STATE` + `stateChip`) already carries the ended/`cr_*` ids for
this, so prefer wiring through it rather than ad-hoc strings.

## 13. Notes / future

- True **live data wiring** (a real backend / data contract) is the next integration step (the dev team
  connects it once the UI is signed off); every observable behaviour here (synced clock, retry, graceful
  empty/error, day-0 state, pace-based projection, the inline actions writing to a change log) is built
  against that contract.
- A **master platform navbar + side menu** will replace the minimal home-icon nav in a later stage.
- The contextual page lives in this folder (`contextual/`) and its dark theme + theme-sync travel with it.

*Blindspot platform · campaign details · documentation · 2026-06-18 · Operated by TPS Engage, LLC.*
