**Note** from Bead: Time Line Web Component · [canonical source](https://redfish.acequia.io/guerin/.agents/ade9cea6-f50e-4922-8266-4bdd14ed9c73/2026-06-19/notes/02-camera-grid-timeline-update-plan.md) · session 2026-06-19 · discussion: Talk: Time Line Web Component
Stephen: mount firewatch `65783732`, check for an active session, process my own process-latest-on-complete request, plan the timeline update, **use git**, **keep the new version in this bead until we decide to deploy**, give a local test URL, and look at image caching (maybe a service worker for a no-cache policy).
## Active-session check No active session is working firewatch right now. Its `STATE.md` and last repo commit (`ad6a2d1`, "S3b") are 2026-06-14; the `claude-chats.md` registry (a snapshot) shows nothing newer than 2026-06-16; today is 2026-06-20. Live-session state only exists in the off-namespace `.claude` store, and there is no current signal there. **Proceeded.** (If a firewatch animator wakes, the working copy is isolated in this bead — no collision.)
## Working copy + git (kept in THIS bead, not deployed) - Canonical source confirmed: firewatch `repo/` is **byte-identical** to the deployed `apps/camera-grid/` (index.js, timeline.js, index.html, cameras-sandy.json). Repo is the source. - Imported that source into **`ade9cea6…/repo/camera-grid/`** and `git init`'d a fresh repo here: - `265c41d` Import camera-grid from firewatch repo @ ad6a2d1 (S3b) - `3cedf4a` Timeline scrub: process-latest-on-complete + SW frame cache - Nothing was written into firewatch's tree or any deploy target. **Deploy is gated on Stephen.**
## Local test URL **http://localhost:5180/** (zero-dep `.serve.mjs`, `PORT=5180`). `localhost` is a secure context, so the service worker registers. Verified `/`, `/index.js`, `/sw.js`, `/cameras-sandy.json` all 200; both modified files pass `node --check`. (Restart after a reboot: `cd repo/camera-grid && PORT=5180 node .serve.mjs`.)
## Change 1 — process-latest-on-complete on the scrub **Before:** every `pointermove` called `seekFromX → updateWall()` synchronously — N binary searches + full canvas redraw + per-tile `img.src` writes per event; no coalescing, no completion gating. **After** (`index.js`): - Single-slot buffer `pendingX` holds only the most recent pointer x; `onScrub()` overwrites it and kicks `pumpScrub()` if idle. - `pumpScrub()` computes the playhead, calls `updateWall()` (which now **returns the changed `<img>`s**), and gates the next pump on `Promise.allSettled(changed.map(img => img.decode()))`, raced against a `DECODE_BUDGET_MS = 250` cap so one stalled tile cannot freeze the loop. Completion drives the loop via `requestAnimationFrame(pumpScrub)`. - Result: fast renders process more intermediate positions, slow renders skip more — self-pacing, no tuning constants, no event backlog. The playhead still redraws each pump, so it stays responsive. This is the **floor + ceiling** from the audit (`notes/01`) combined: rAF-coalesced (floor) *and* decode-gated (ceiling).
## Change 2 — service worker for the image cache **The finding (this is the real caching problem):** the frames AND telemetry are served by the source (`simtable.acequia.io`, nephele) with: ``` Cache-Control: private, no-cache ETag: "…" Last-Modified: … Access-Control-Allow-Origin: * ``` `no-cache` does not mean "don't store" — it means **"revalidate before every reuse."** So every time a scrub revisits a frame the browser issues a conditional GET and gets a 304: a network round-trip per tile per revisit, even though these are **immutable historical screenshots** that will never change. `private` also blocks any shared/CDN cache. For a live feed this policy is correct; for the historical archive we scrub it is exactly wrong. **Fix — `sw.js` (client-side override, the right layer):** - **Frame images → cache-first.** First load fetches + stores; every revisit serves from Cache Storage with **zero network**. Immutable, so no revalidation is ever needed. Re-scrubbing becomes instant and works offline. - **Telemetry json → stale-while-revalidate.** Serve cached immediately, refresh in the background, so a still-live bundle that appended frames is picked up on the next load without ever blocking. - The source sends `ACAO: *`, so the SW fetches in **cors** mode and caches **real** responses (not opaque) — no opaque-quota padding, and responses stay inspectable. - Scope: registered at `./sw.js` from the app root; it intercepts the cross-origin frame/telemetry GETs the page makes (matched by `…/ops-alertcalifornia/…`), passes everything else straight through. - A `postMessage({type:'purge'})` handler is in place for a future purge button. - Cache name `firewatch-frames-v1` (bump to invalidate). **Why SW over fixing the server header:** the immutable-frame vs live-feed split lives in the *consumer* (we know we're scrubbing history); the source can't know that per request. A server fix (`immutable, max-age=…` on the dated archive tree) is a good *complementary* change owned by the simtable/incident side, but the SW gives the client control now and is what the app actually needs for fast scrub. Recommend both eventually; SW is the one in scope here.
## Caveat / how to verify - The two changes are **structurally** verified (serves, syntax, code review). They are **not yet browser-verified** (cameras actually loading, SW intercepting, scrub feeling instant). The camera-grid CDP convention is a dedicated Chrome on **:9333** (per the firewatch bead — :9222 is incident-viewer's). Offered to CDP-verify on request; otherwise Stephen tests at the local URL (DevTools → Application → Service Workers + Cache Storage to watch `firewatch-frames-v1` fill).
## Follow-up fix (2026-06-20) — intermittent CORS on re-scrub Stephen tested: scrubbing felt better, but jumping around after heavy scrubbing threw a batch of `No 'Access-Control-Allow-Origin' header is present` errors on frame fetches. **Root cause (client-side regression I introduced; the server is fine):** the wall `<img>` tiles have no `crossorigin` attribute, so they load **no-cors**. My v1 SW fetched the *same* frame URLs in **cors** mode. Both modes share the browser HTTP cache, and a cache entry tagged for one mode cannot satisfy a request in the other. While steadily scrubbing forward every URL is fresh, so it works; once you scrub enough and then **jump back to URLs already touched**, the cors fetch collides with a no-cors-tagged HTTP-cache entry (or vice-versa) → the CORS error. Verified the server is consistent: sequential GET, conditional GET (304), and a parallel burst all return `200/304 + ACAO: *`. So it was never a server or 304-header problem. **Fix (commit `bf5f238`):** the SW fetches frames in the **same no-cors mode** as the `<img>` (`fetch(req)` where `req.mode === 'no-cors'`), and caches the **opaque** response — which `<img>` renders fine. no-cors requests are never subject to CORS enforcement, so the error cannot recur. Telemetry stays **cors** (its JSON body must be readable; it is only ever fetched by app code, never by an `<img>`, so it has no mode clash). Bumped the cache to `firewatch-frames-v2` and the SW now deletes superseded caches on activate (drops the v1 cors-mode frame entries). Trade-off: opaque responses incur quota padding — acceptable for small thumbnails; the alternative (add `crossorigin="anonymous"` to every tile so the `<img>` itself becomes cors) is a deeper app change I avoided. **To pick up the fix:** hard-reload http://localhost:5180/ once (the new SW `skipWaiting()`s + claims and clears v1). Then scrub heavily and jump around — the CORS batch should be gone and re-scrub served from `firewatch-frames-v2`.
## Status / next - **Done:** working copy + git, both changes, local server, caching analysis. - **Gated on Stephen:** deploy decision (then push to firewatch `repo/` + `apps/camera-grid/` via `deploy.sh`, or hand the diff to the firewatch bead's `response/`). - **Open:** browser/CDP verification; optional server-side `immutable` header on the archive tree; fold the pattern into the shared timeline-component spec (design question #7, `notes/00`).