# Forensic Audit — Sprint Additions
Date: 2026-05-12 · File: BPFmap.html (~36309 lines)
Scope: All BPF_SPRINT* + BPF_PHASE_D blocks (lines 33612–36308) + design tokens (26–135) + window.bpfVenues (5858).

---

## Critical risks (fix immediately)

### C1 — Sprint 9 `touch-action: none` on Festival Info hero + Programme header BREAKS sheet scroll/grabber UX
**Block:** `BPF_SPRINT9_UX_CSS` · Lines **35755–35766**
```
.bpf-saved-grabber, .bpf-saved-head, .grabber,
.festival-info-hero, .festival-info-kicker,
.prog-header, .prog-days {
  touch-action: none !important;
  -webkit-user-select: none !important;
  user-select: none !important;
  cursor: grab;
}
```
- `.festival-info-hero` is a tall image-bearing hero with text inside it. Forcing `touch-action: none` means a finger landing anywhere in the hero is captured by `wireDrag`'s touchstart handler — even taps that the user intends as taps on the close X or kicker (the close button at `.festival-info-close` line 1146 sits inside the hero). This very likely explains the **broken Festival Info sheet** report.
- `.prog-header` + `.prog-days` cover the entire sticky top region of the Programme list, including day-pills. `touch-action: none` on `.prog-days` interferes with horizontal scrolling of day pills AND with the user trying to tap a day.
- The `cursor: grab` rule (no `!important`) is fine, but the universal-ish multi-selector with high specificity from being grouped means it's the dominant `touch-action` declaration across many sheet headers.
**Fix:** Narrow to just the actual drag handles: `.bpf-saved-grabber, .grabber` only. Remove `.festival-info-hero`, `.festival-info-kicker`, `.prog-header`, `.prog-days`, `.bpf-saved-head`.

### C2 — Class collision `.bpf-saved-panel` between Sprint 3 and V402 (line 19962)
**Block:** `BPF_SPRINT3_SAVED_PANEL_CSS` vs `BPFMAP_V402_CSS`
- V402 line 19962–19970 defines `.bpf-saved-panel { position: fixed; inset:0; z-index:8500; pointer-events:none }` and uses `.bsp-visible` to toggle. Sprint 3 reuses the SAME class name but with different state classes (`.bpf-saved-open`). They co-exist in the DOM only because Sprint 3 always wins via `!important` + later source order.
- Risk: if V402's JS (line 20167 `panel.className='bpf-saved-panel'`) creates a panel before Sprint 3 builds its own, the OLD panel sits in the DOM with `pointer-events:none` AND inset:0 — it can intercept/swallow events. Also the Sprint 3 panel `display:none` rule (line 33914) applies to V402's panel too (since it lacks `.bpf-saved-open` or `.bpf-saved-closing`), potentially hiding V402's panel if it's still being toggled by anything.
- A search for both `panel.classList.add('bsp-visible')` at lines 20258, 21650, 21950, 22906 shows V408+ all still expect this class — but the panel itself is hidden by Sprint 3's `display:none`.
**Fix:** Rename Sprint 3 class to `.bpf-saved-panel-v3` (or `.bpf-saved-sheet`) to fully decouple from V402's namespace.

### C3 — Sprint 5 boot polling does NOT clearInterval the slow 30s loop on welcome-class observer
**Block:** `BPF_SPRINT5_TONIGHT_JS` · Lines **35478–35491**
```
var fast = setInterval(function () { render(anchor); }, 2000);
setTimeout(function () {
  clearInterval(fast);
  setInterval(function () { render(anchor); }, 30000);  // ← never stored, never cleared
}, 20000);
```
- The 30s render interval has no handle saved and runs FOREVER for the lifetime of the page. Each tick parses every event, runs `(new Function("try{return venues}"))()`, walks venues, re-renders Tonight, and re-touches DOM. After a few hours that's hundreds of unnecessary re-renders.
- Additionally, `MutationObserver` on `.app` for `class` (line 35487–35491) ALSO triggers render() on each class change. The combination means on `mode-empty` toggling the welcome render fires many times within 200ms during filter changes — likely contributes to the **filter freeze** report.
**Fix:** Store the 30s handle; clear when leaving welcome. Skip render if `!appEl.classList.contains('mode-empty')`. Debounce the class-mutation observer (currently zero debounce).

### C4 — Sprint 9 drag handler attaches `touchmove` with `passive:false` to ENTIRE `#sheet` (line 36127–36145)
**Block:** `BPF_SPRINT9_UX_JS` · Lines **36127–36145**
- `wireDrag` is called on `#sheet` (the main venue sheet root). The `touchmove` listener with `passive:false` is on the panel itself — every touchmove anywhere inside the venue sheet flows through `onMove`. While it bails when `startY === null`, the listener still pays the cost on each move event, and any nested scroll that bubbles up will hit it.
- The `scrollBlocking` guard at line 35982–35986 only checks if `.sheet-scroll.scrollTop > 0` AT touchstart. But once a venue event row is expanded and the user scrolls back up to 0 then drags down, the guard returns false → the drag handler engages and calls `preventDefault()` on touchmove → blocks native scroll for the rest of the gesture. This will read as "scrolling is broken inside expanded venue card".
**Fix:** Re-evaluate scrollBlocking on every move, or restrict grabSelectors to genuine handles only (already `.grabber`, but the touchmove still runs since `startY` is set from any grabber touch).

### C5 — `new Function("try{return venues}")` used in 3 separate IIFE blocks — fragile contract
**Blocks:** Sprint 3 (lines **34281, 34299, 34553**), Sprint 5 (line **35204**)
- This pattern relies on `venues` being a top-level `let` accessible from the global lexical environment. It works today, but if any future bundler/minifier wraps the main script in an IIFE, or if Airtable load reassigns `venues` later than expected, all four call sites silently degrade to the `window.bpfVenues` snapshot (which is set ONCE at line 5858 from DEFAULT_VENUES and never refreshed when Airtable replaces `venues`).
- Practical implication: **Saved-panel venue lookups + Tonight surface will show stale DEFAULT_VENUES data** if Airtable replaces the array after page load. Confirmed by grepping — `window.bpfVenues = venues` happens once (line 5858), not after the Airtable replace.
**Fix:** After Airtable's venue reload step, also do `window.bpfVenues = venues;`. Or expose a single `window.bpfGetVenues()` accessor.

---

## Moderate risks (review)

### M1 — Sprint 3 `setTimeout(wireSavedBtn, 200)` recursive without cap (line 34516)
- If `#savedBtn` never appears (e.g., DOM error), this polls forever. Other Sprint blocks have bounded retries (Sprint 5 uses `_setupTries`, Sprint 6 uses `_setupTries6 > 40`). This one is unbounded.
**Fix:** Add a counter `if (++_savedTries > 40) return;`.

### M2 — Sprint 2 `setTimeout(wrapRenderEvents, 100)` recursive without cap (line 33766)
- Same pattern as M1 — unbounded retry until `window.renderEvents` becomes a function. Acceptable risk since renderEvents is defined at 6540, but still worth a counter.

### M3 — Sprint 9 `setupHints` recursive `setTimeout(setupHints, 300)` (line 36158) — unbounded
- Same pattern; bounded retry would be cleaner.

### M4 — Sprint 3 MutationObserver on `#programmeList` (lines 34702–34715) — safe but watch
- Re-entrance avoided via `clearTimeout(_syncTimer)` debounce of 220ms (good).
- However, `syncFromProgrammeDOM` (line 34663) calls `refreshAllHearts()` unconditionally (line 34692), which mutates `.bpf-psave` innerHTML inside `.prog-item` — the SAME subtree being observed. The observer's mutation list will contain these heart changes on each cycle. Since debounce is on the trailing edge, this can self-trigger ~5 times per second sustained on a noisy page. Bounded retries protect setup, NOT runtime feedback loops.
**Fix:** Add `obs.disconnect()` before refreshAllHearts(), then `obs.observe(...)` after — or filter mutation targets to exclude `.bpf-psave` innerHTML.

### M5 — Sprint 6 MutationObserver self-trigger guard depends on `every()` check (lines 35714–35728)
- The guard at line 35716–35725 uses `every()`: it skips re-rendering only if EVERY mutation involves only our own nodes. A single non-own addedNode (e.g., a `.prog-item`) in the same batch makes it run rebuildGroups, which then ADDS .bpf-prog-group nodes, which triggers the observer again. Debounce of 180ms (line 35704) saves us, but the early-exit logic is a stronger safeguard worth verifying behaves as intended.
- If `n.classList` is undefined (text node, comment), the inner `.every` returns true (`undefined && ...` short-circuits), which actually accidentally HELPS — but reading the logic, that's accidental. Cleaner with explicit guard.

### M6 — Sprint 6 `touch-action: none` on `.prog-days` indirectly affects pill horizontal scrolling
- Already flagged in C1.

### M7 — Sprint 9 click delegation: `document.addEventListener("click", ...)` on Sprint 2 + Sprint 3 lines 33850 + 34213 + 34631
- Each call uses `capture: true` for venue/event buttons and uses `stopPropagation` + `preventDefault`. The Programme `.bpf-psave` click (line 34631) is bubble phase — fine. But the capture-phase listeners are global; if any future feature tries to listen on the same buttons in bubble phase, it WILL get called AFTER these (good), but the bubble listener will never see the click on `.bpf-evt-save-btn` etc. because Sprint 3 calls `stopPropagation()` at line 34216. Document this; not a bug now.

### M8 — `position: fixed !important` + `transform: translateY(...)` on saved-panel
- Sprint 3 panel uses `position: fixed` (line 33926). When `wireDrag` applies `transform: translateY(...)` on touch/drag, this creates a new stacking context. Any descendant `position: fixed` would attach to the panel instead of viewport. Sprint 3's panel has no descendant fixed elements, but if the hint chips (`#bpfSearchHints` line 36162) are ever displayed while the panel is open and being dragged, they would NOT be affected (they're appended to body). Safe today.

### M9 — Sprint 5 expected `state` global may not exist (line 35454)
- `var stateRef = window.state || (new Function("try{return state}catch(e){return null}"))();` — same fragility as C5 above.

---

## Low risks (acceptable for now)

### L1 — Sprint 1 sheet height rules with double-up specificity (`html body .app...`)
- Line 34787–34794: `html body .app.mode-empty .sheet` selector is intentionally high-specificity to beat earlier V-layer rules. Verbose but correct.

### L2 — Sprint 5 `:has(#bpfTonightHero:not([hidden]))` — graceful fallback already present
- Line 34800–34805: dual strategy (`:has()` + `.bpf-has-tonight` class added by JS). Safari ≥ 15.4 supports `:has()`, fallback works otherwise. OK.

### L3 — Sprint 5 triple-id selector `#emptyState .empty-hero > #bpfTonightHero#bpfTonightHero`
- Line 34935: intentionally chained ID to beat V369's `(2,3,0)` specificity. Documented inline. Unusual but acceptable.

### L4 — Sprint 9 universal pill rule `.bpf-evt-save-btn, .bpf-evt-cal-btn, .bpf-psave, .prog-cal-btn, .prog-show-map-btn, .bpf-prog-share`
- Line 35821–35850: large rule block with `!important` on every property. By design — unifies appearance across components. Will silently override any future inline style on these buttons. Acceptable; trade-off is visible.

### L5 — Sprint 9 `box.appendChild` of hint chips to `document.body` (line 36177)
- Hints positioned absolute, top:58px, left:16px. Will sit under topbar on iPad/larger screens because the absolute position is viewport-relative. Visual minor.

### L6 — Sprint 2 `setTimeout(injectButtons, 0)` after renderEvents (line 33772)
- One-shot 0ms defer. Safe.

### L7 — Sprint 1 `100lvh` REVERTED note (comment line 34726–34728)
- Already reverted. No risk.

---

## Confirmed safe

- **BPF_SPRINT2_CAL_BTN_CSS** — pure styling, all `!important` justified by V-layer collision risk.
- **BPF_SPRINT3_SAVED_PANEL_CSS animations** — `@keyframes bpf-saved-fade-*` are scoped names, no collision.
- **Sprint 1 safe-area rules (top topbar/controls)** — additive `env(safe-area-inset-*)`; no transform/positioning hazards.
- **Sprint 6 "Now" line CSS** — purely visual, gradient lines via `::before/::after`, no layout breakage.
- **Sprint 9 keyframes `@keyframes bpf-hint-fade`** — scoped name.
- **Phase D CSS** — All CSS-only after revert; the `margin: 8px 0` on `.prog-item.open` (line 36275) is correct (vertical-only, no horizontal overflow risk).
- **Design tokens block (lines 26–135)** — pure CSS custom properties, no selectors, no cascade interference.

---

## Specific findings per block

### BPF_SPRINT2_CAL_BTN_CSS + JS (lines 33612–33878)
- Self-contained `bpfAddToCalendar` duplicates V331's logic — acceptable to escape IIFE scope. SVG embedded inline.
- `wrapRenderEvents` polling unbounded (M2).
- Capture-phase `click` delegation on `.bpf-evt-cal-btn` (line 33870) — fine.
- ICS file generation: `title.replace(/[^a-z0-9]/gi, "_")` for filename — safe.
- No MutationObserver, no setInterval.
- **Verdict:** Solid. Minor: cap the wrapRenderEvents poll.

### BPF_SPRINT3_SAVED_PANEL_CSS + JS (lines 33891–34717)
- Class-name collision with V402 (C2 — CRITICAL).
- `new Function("return venues")` x3 (C5).
- MutationObserver self-feedback risk through refreshAllHearts (M4).
- Recursive setTimeout(wireSavedBtn) unbounded (M1).
- Per-item wireItem with debouncing flag `_bpfFired` — clean.
- Heart SVGs inline, escapeText present — safe.
- Programme mirror logic: defers to V422 with `setTimeout(..., 0)` (line 34640) — good.
- **Verdict:** Highest concentration of risk. Fixes: rename class, bounded retries, observer.disconnect() during refresh.

### BPF_SPRINT1_SAFE_AREA (lines 34730–34820)
- Adds `min-height: 0 !important; max-height: 80dvh !important` to multiple sheets (lines 34787–34794). The 80dvh cap could clip the venue sheet on devices where the user expects to read a long event list (esp. expanded venue mode).
- However, line 34787 explicitly excludes `.sheet-expanded` via `:not(.sheet-expanded)` for venue sheet — good.
- `padding-bottom: calc(14px + env(safe-area-inset-bottom))` applied to all sheets — fine.
- **Verdict:** Safe. The 80dvh cap on welcome + non-expanded venue is intentional.

### BPF_SPRINT5_TONIGHT_CSS + JS (lines 34927–35494)
- Unstored 30s setInterval (C3 — CRITICAL).
- MutationObserver on `.app` class without debounce (C3).
- Triple-ID specificity selector (L3).
- `BPF_TEST_DATE = new Date(2026, 9, 11, 18, 30)` hardcoded (line 35164) — **flag for go-live**: this overrides real clock today.
- 20s bounded boot interval is correct (line 35189–35194).
- console.log statements left in production code (lines 35171, 35184, 35192, 35277) — harmless but noisy.
- **Verdict:** Critical 30s leak + hardcoded test date. Render debounce missing.

### BPF_SPRINT6_PROGRAMME_CSS + JS (lines 35504–35742)
- Two `.bpf-prog-past` opacity rules (lines 35524 and 35572) — first sets opacity:1, second sets 0.5. Source order wins → 0.5 applied. The first is dead code (line 35524–35530 comment says "Drop the past-event dimming" but the rule contradicts itself). Cleanup advised.
- `setupProgObserver` bounded retry (35707) — good.
- MutationObserver self-trigger guard (M5).
- `window.BPF_TEST_DATE_5 = new Date(2026, 9, 11, 18, 30)` hardcoded (line 35734) — flag for go-live.
- **Verdict:** Mostly safe. Resolve the duplicate `.bpf-prog-past` opacity rules.

### BPF_SPRINT9_UX_CSS + JS (lines 35751–36207)
- `touch-action: none` on too many surfaces (C1 — CRITICAL).
- Drag wired on main `#sheet` (C4 — CRITICAL).
- Universal pill rule with `!important` (L4) — intentional.
- Search hint chips appended to body, hidden on outside click — clean.
- console.log left in (lines 36006, 36074, 36080) — harmless.
- Drag handler uses `setProperty('transform', ..., 'important')` to beat keyframe animations — good defense.
- **Verdict:** Drag-on-everything is the most likely cause of grabber + Festival Info regressions.

### BPF_PHASE_D_COHERENCE_CSS (lines 36222–36298)
- CSS-only after JS revert. The expanded-card frame rule (line 36271–36278) was already fixed per the comment.
- Backgrounds + contrast tweaks are pure styling.
- Active filter footer-line "kill" rule (line 36288–36297) is safe.
- **Verdict:** Safe.

### window.bpfVenues (line 5858)
- Set ONCE at script-top from DEFAULT_VENUES. Never refreshed after Airtable replaces `venues`. Several call sites in Sprint 3 + Sprint 5 fall back to this stale snapshot via try/catch (C5).
- **Fix:** Push another `window.bpfVenues = venues;` inside the Airtable replace callback.

### :root design tokens (lines 26–135)
- Pure additive CSS variables. No selectors, no cascade collision. Safe.

---

## Summary

Worst 5: **C1** (touch-action over-application breaks Festival Info hero & Programme), **C4** (drag handler on `#sheet` blocks scroll inside expanded card → matches "lateral scroll on event expand" report), **C3** (uncleared 30s render interval + undebounced class observer compounds with filter-freeze symptoms), **C2** (`.bpf-saved-panel` class collision with V402 — likely "broken grabber"), **M4** (Sprint 3 observer self-feedback through heart-refresh on the same subtree).
