# Publish flow (Hyperlocal), implementation guide (developer hand-off)

Companion to `DOCUMENTATION.md`. What to wire to make the six-step flow run on live data. Everything today
runs on deterministic sample data; nothing here changes the UI, it tells you where the seams are.

Files (self-contained):
```
publish.html                 the flow (inline <style> + one inline <script> IIFE), all six steps
select.html                  redirects to publish.html?step=3 (legacy entry)
blindspot.css                shared component CSS (next to publish.html)
design-system/tokens.css     design tokens (at design-system/ next to publish.html)
```

---

## 1. Runtime state (`ST`)

One object drives the whole flow:
```
ST = {
  step, maxStep,                       // wizard position (1..6); maxStep gates the rail
  mode:'hyperlocal'|'blinky',          // step 1 path
  cname, industry, start, budget,      // step 1 fields (budget = optional target, paced against)
  region, types{indoor,outdoor,mobile},// step 2 scope (also the step 3 filters)
  q, name, cats{}, view, sort,         // step 3 search/filters/view
  priceMax, fmt, avail, photo,         // step 3 advanced filters
  plan{ id:1 }, saved{ id:1 }, compare{ id:1 }, // picked + favorites + compare set (max 4)
  openScreen,                          // open drawer (deep-linked)
  sched:{ scope:'global'|<id>, global:{cells{'d-h':1}, pph}, per:{ id:{cells,pph} } },
  media:{ decided, mode:'upload'|'later', items:[{name,type,dur,custom,rules:[{trigger,cond}]}] },
  discount:{ code, pct }
}
```
`goStep(n)` / `continueStep()` / `backStep()` move between steps (`continueStep` validates via
`stepValid(n)` and, on step 6, calls `doPublish`). `renderFlow()` is the dispatcher: it updates the header,
shows the filter bar only on step 3, renders the step body and the flow bar.

**Paradigm (important):** Blindspot sells **location + play (time + place)**, not reach/CPM. The selling
and headline units are locations, screens, plays, hours and USD (cost per play, all-in). **Impressions are
shown only as a secondary "estimate, for orientation" figure** (overview cards, the heatmap's third toggle,
the screen drawer), never as a selling/billing unit and never in the flow-bar primary metrics. The heatmap
toggles **plays / impressions / spend**.

**Map clustering:** the Mapbox map uses a clustered GeoJSON source (`map.addSource('screens',{cluster:true})`)
with a coral `clusters` circle layer + `cluster-count` symbol layer; unclustered screens render as HTML
brand price-pins managed on `moveend`/`sourcedata` via `syncMarkers()`. `getClusterExpansionZoom` zooms on
cluster click. Wire the source `data` to live `lng/lat`.

**Compare:** `ST.compare` (max 4 via `CMP_MAX`), `toggleCompare(id)`, `compareScreens()`, the `data-cmp`
toggle on cards, the compare tray (`data-act="compare"` / `clearcompare`, `data-cmprm`), and `openCompare()`
(focus-trapped `#cmpScrim` modal). **Budget pacing:** `ST.budget` drives `budgetBarHTML(spent)` (review) and
the flow-bar "% of budget". **Creative coverage:** `fmtCovered(f)` / `uncoveredScreens()` mark each required
format Ready/Needs-a-creative and drive the review coverage banner (wire to real per-creative format data).

---

## 2. The data contract (top-level vars, swap for live equivalents)

- **`CAMP`** `{name, city, start, end, days, budget}` campaign context (window drives play estimates).
- **`SCREENS`** the inventory; each: `{id, name, addr, cat, type:'digital'|'static', media, w, h, size,
  orient, op, net, cpp (USD/play), avail:'high'|'med'|'low'|'full', plays, ipp, ind:'indoor'|'outdoor'|
  'mobile', mx, my (map world coords 0..100), rec}`. `cost`/`impr` derive on load. Two API-backed fields are
  read if present: **`photo`** (a billboard image URL; the card + drawer go photo-led, else the branded
  lit-screen mockup shows) and **`poi`** (a comma string of nearby points of interest, folded into search).
  `mx/my` feed the map; for the Mapbox map they are projected into `NYC_BBOX`, so wire **real `lng/lat`** and
  replace `screenLngLat(s)` when live.
- **Mapbox:** set **`MAPBOX_TOKEN`** (a public `pk.` token), or pass `?mbtoken=`, or `localStorage
  bs_mapbox_token`. `MAPBOX_GL_VER` pins the GL JS version; `loadMapboxGL()` lazy-loads it; with no token or
  on load failure the page falls back to `buildMapFallback()` (the offline map). Clustering is not yet wired,
  add it when inventory scales.
- **`CATS`** venue taxonomy `{key, icon, label}`. **`AVAIL`** availability to state-chip tone (no green).
  **`POI`** id-to-points map merged onto each screen at load.
- **`REGIONS` / `STYPES`** step-2 scope (STYPES carries the no-green type colour). **`INDUSTRIES`** step 1.
- **`DAYS` / `PPH_OPTS` / `defaultCells()`** schedule (default global pph is 10, a value in `PPH_OPTS`).
  **`DAYPARTS`** drives the step-6 heatmap. **`LIBRARY` / `TRIGGERS` / `TRIGLAB`** media + contextual rules.
  **`DISCOUNTS`** code to percent.

Estimates: `screenPlays(s) = hours/week (cells count) * pph * (CAMP.days/7)`; `schedTotals()` aggregates the
plan; `billCalc()` applies the discount; `requiredFormats()` groups the plan's screens into the creative
formats to supply; `heatmapData(metric)` builds the week-by-daypart matrix. Replace these with your
pricing/forecast service if needed.

---

## 3. Actions to wire (keep confirm + toast where noted)

- **Add / remove screen:** `toggleScreen(id)` (reversible, offers Undo). **Add top N by value:**
  `addTopValue(n)`. **Favorite:** the `data-save` handler.
- **Schedule:** `setCell(d,h,on)`, `setPph(v)`, `bulkCells(kind)`, `ensurePer(id)`; scope is `ST.sched.scope`.
  Calendar/Table view is `SCHEDVIEW`. Bulk: `massPph(v)` (all screens) via `data-masspph`, `applyHoursToAll()`
  via `data-sq="applyall"`, per-row pph via `data-rpph="<id>:<v>"`.
- **Media:** the dropzone `addUpload()`, `data-libadd` (from library), `data-mrule`/`data-rt`/`data-rc`/
  `data-rrm` (rule add/type/condition/remove), `data-mcustom` (custom format). Wire uploads to your media
  service and the rule triggers to your data-source integrations (weather, calendar, market, sports, etc).
- **Review:** `applydisc` (validate the code server-side), `doPublish()` (confirm-then-submit; wire to the
  booking/approval endpoint), `data-share` (CSV / PDF / link). **Publish and Clear plan both confirm** via
  `openConfirm`. Continue between steps spends nothing.

---

## 4. Deep-link + persistence

`syncURL()` writes `step`, `plan` (csv of ids) and the step-3 filters (`view, sort, q, name, region, cats,
fmt, price, avail, off, screen`). `readState()` restores from localStorage (`bs_pub_plan`, `bs_pub_theme`,
`bs_pub_density`, `bs_pub_view`) then the URL (URL wins); a `plan` or `step>1` raises `maxStep` so the rail
unlocks. A shared URL reopens the same step, plan and filters.

---

## 5. House rules (must hold)

USD only; **average cost per play** (never CPM); **no green** (positive = `--red-deep`; map pins indoor
amber / outdoor purple / mobile blue; picked + scheduled = coral); **no em dashes**; Blindspot platform
voice. Namespacing: progress rail `.pubstep*`, plan classes `.plan-*` (the `blindspot.css`
`.steps`/`.step`/`.plan` collisions). Grep before naming a class.

## 6. Build, QA, archive

Ships in `rollout/build_global_archive.py` (platform-pages included; `WORK IN PROGRESS` and `*.zip` skipped,
so the per-page handoff zip is excluded). QA with jsdom (0 errors, walk all six steps) and Playwright
Chromium (sandbox needs an `libXdamage.so.1` symbol stub via `LD_LIBRARY_PATH`). Re-check 0 em dashes and no
green in copy.

*Blindspot platform · publish flow · implementation guide · 2026-06-19 · TPS Engage, LLC.*
