{
+ return next.handle(req).pipe(
+ catchError((err: HttpErrorResponse) => {
+ // Don't toast the high-frequency /status poll failures.
+ if (!req.url.endsWith('/status')) {
+ this.toast.error(`Request failed: ${req.url.split('/').pop()} (${err.status})`);
+ }
+ return throwError(() => err);
+ })
+ );
+ }
+}
+```
+
+- [ ] **Step 3: Register in app.module.ts**
+
+In `src/app/app.module.ts`, add to imports `MatSnackBarModule` (from `@angular/material/snack-bar`)
+and to providers:
+
+```typescript
+{ provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true },
+```
+
+(import `HTTP_INTERCEPTORS` from `@angular/common/http` and `ErrorInterceptor` from
+`./core/error.interceptor`.)
+
+- [ ] **Step 4: Build check (manual)**
+
+Run from `WormSpy/wormspy`: `npm install` then `ng build`. Expected: build succeeds.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add WormSpy/wormspy/src/app/status WormSpy/wormspy/src/app/core WormSpy/wormspy/src/app/app.module.ts
+git commit -m "feat: add StatusService polling and global HTTP error toasts"
+```
+End commit message with the Co-Authored-By trailer.
+
+---
+
+### Task 5: Bind the UI to backend truth + Hold Focus controls + readouts
+
+**Files:**
+- Modify: `WormSpy/wormspy/src/app/live-feed/live-feed.component.ts`
+- Modify: `WormSpy/wormspy/src/app/live-feed/live-feed.component.html`
+- Delete: `WormSpy/wormspy/src/app/socket/socket.service.ts` and `socket.service.spec.ts`
+
+> The big UX fix: toggles reflect `status$` (so refresh re-syncs); add recording timer / frame
+> counter / z readout / limit-hit warning; rename FocusLock→Hold Focus and add Set Focus / Return to
+> Focus / drift-comp controls calling the Phase 3 routes; poll `hist_max` via `status$`/its own timer;
+> remove dead commented code and the abandoned socket service. Verified manually via `ng serve`.
+
+- [ ] **Step 1: Inject StatusService and expose status$ in the component**
+
+In `live-feed.component.ts`: inject `StatusService` in the constructor, expose `public status$ = this.sockOrStatus.status$;` (rename field as you like), and DELETE the commented-out socket code and the unused `SocketService` injection. Derive button labels from the latest status where they currently use local booleans (subscribe in `ngOnInit` to keep `isRecording`, `isTrackingEnabled`, `manualEnabled`, etc. in sync with the backend; keep optimistic local toggles for responsiveness but reconcile from `status$`).
+
+```typescript
+import { StatusService, BackendStatus } from '../status/status.service';
+// ...
+export class LiveFeedComponent implements OnInit {
+ public latest: BackendStatus | null = null;
+ constructor(private http: HttpClient, private statusSvc: StatusService) {}
+ ngOnInit(): void {
+ this.statusSvc.status$.subscribe((s) => {
+ this.latest = s;
+ this.isRecording = s.is_recording;
+ this.isTrackingEnabled = s.is_tracking;
+ this.manualEnabled = s.manual_enabled;
+ this.isHoldFocusSet = s.has_focus;
+ this.driftOn = s.drift_on;
+ });
+ }
+ // Hold Focus controls
+ public setFocus(): void {
+ this.http.post(this.apiUrl + '/set_focus', {}).subscribe(() => {});
+ }
+ public returnToFocus(): void {
+ this.http.post(this.apiUrl + '/return_to_focus', {}).subscribe(() => {});
+ }
+ public toggleDriftComp(): void {
+ this.driftOn = !this.driftOn;
+ this.http.post(this.apiUrl + '/toggle_drift_comp',
+ { drift_enabled: this.driftOn ? 'True' : 'False' }).subscribe(() => {});
+ }
+}
+```
+
+Add the fields `isHoldFocusSet = false;` and `driftOn = false;`. Remove the old `toggleAutofocus()`
+method and `isAutofocusEnabled` field.
+
+- [ ] **Step 2: Update the template**
+
+In `live-feed.component.html`:
+- Replace the FocusLock toggle with three controls: "Set Focus" → `setFocus()`, "Return to Focus" →
+ `returnToFocus()`, and a toggle "Drift Comp (experimental)" → `toggleDriftComp()` showing `driftOn`.
+- Add a status strip (visible when `latest as s`) showing: recording timer
+ `{{ s.recording_elapsed_s }}s`, `{{ s.frames_written }} frames`, `z = {{ s.z_pos | number:'1.0-0' }}`,
+ connection chips (`engine_running`, `zaber_connected`), and a warning banner when `s.limit_hit`
+ ("Stage limit reached — worm may be off-frame").
+- Bind the Start/Stop Recording and Tracking button labels to `latest?.is_recording` /
+ `latest?.is_tracking` so they reflect the backend after a refresh.
+
+```html
+
+ engine
+ zaber
+ REC {{ s.recording_elapsed_s }}s · {{ s.frames_written }} frames
+ z = {{ s.z_pos | number:'1.0-0' }}
+ ⚠ Stage limit reached
+
+```
+
+- [ ] **Step 3: Delete the abandoned socket stub**
+
+```bash
+git rm WormSpy/wormspy/src/app/socket/socket.service.ts WormSpy/wormspy/src/app/socket/socket.service.spec.ts
+```
+
+Remove any remaining imports of `SocketService`.
+
+- [ ] **Step 4: Build check (manual)**
+
+From `WormSpy/wormspy`: `ng build`. Then `ng serve` and, with the backend running, verify: toggles
+reflect backend state across a page refresh; Set/Return Focus call through; recording shows a live
+timer + frame count; the limit warning appears when tracking hits a limit; a failed request shows a
+toast.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add WormSpy/wormspy/src/app/live-feed
+git commit -m "feat: bind UI to backend /status; add Hold Focus controls and telemetry readouts"
+```
+End commit message with the Co-Authored-By trailer.
+
+---
+
+### Task 6: Backend cleanup + README reconciliation
+
+**Files:**
+- Modify: `WormSpy/backend/code/app.py`
+- Modify: `ReadMe.md`
+
+> Remove cruft exposed by the refactor; reconcile docs. No behaviour change.
+
+- [ ] **Step 1: Remove dead globals / fix the typo'd one**
+
+In `app.py`: remove the `heatmap_enable` typo global (keep `heatmap_enabled` if heatmap is still
+wired, else remove both and the `/toggle_heatmap` route if unused). Remove now-unused module globals
+left from the old generators (`start_recording`, `stop_recording`, `start_recording_r`,
+`stop_recording_r`, `hist_frame`/`hist_max` if the histogram is reworked, `serialPort` if unused,
+`nodeIndex` if DLC node is set elsewhere). For each, confirm with `grep -n app.py` that it has
+no remaining readers before deleting.
+
+- [ ] **Step 2: Decide BaslerCamera disposition**
+
+`grep -n "BaslerCamera" app.py` — it is a legacy alt-camera wrapper now superseded by
+`core/cameras_spinnaker.py`. If unused, remove it from `app.py` (a future `core/cameras_basler.py`
+adapter can be added later if needed). If you keep it, leave a comment that the canonical path is the
+`core` adapters.
+
+- [ ] **Step 3: Reconcile the README**
+
+In `ReadMe.md`, update the recording description: brightfield is now an **intra-frame codec (MJPG,
+or FFV1 lossless)** — not XVID/“MJPG-via-fourcc-XVID” — and the right camera remains 16-bit TIFF.
+Update the autofocus/“FocusLock” mention to **Hold Focus** (operator-set; optional drift comp).
+Add a one-line note that recordings now emit a per-frame CSV spine keyed by `frame_index`.
+
+- [ ] **Step 4: Verify**
+
+Parse: `python -c "import ast; ast.parse(open('WormSpy/backend/code/app.py').read()); print('parse ok')"` → `parse ok`.
+Suite: `cd WormSpy/backend/code && PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python -m pytest -q` → all pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add WormSpy/backend/code/app.py ReadMe.md
+git commit -m "chore: remove dead globals/BaslerCamera; reconcile README with refactor"
+```
+End commit message with the Co-Authored-By trailer.
+
+---
+
+## Self-Review
+
+**Spec coverage (Q6):**
+- *Authoritative state via polled /status* → Tasks 2 (`build_status`) + 3 (route) + 4 (`StatusService` ~2 Hz). ✓
+- *UI reflects backend truth (fixes refresh-lie)* → Task 5 Step 1 (`status$` reconciles toggles). ✓
+- *Surface HTTP errors* → Task 4 (`ErrorInterceptor` → toast; skips the /status poll). ✓
+- *Telemetry readouts (recording timer, frame counter, z, warnings)* → Tasks 1 (counters) + 5 Step 2 (status strip). ✓
+- *Hold Focus controls wired to Phase 3 routes* → Task 5 (set/return/drift). ✓
+- *Poll hist_max instead of one-shot* → Task 5 (status strip / dedicated timer; the legacy one-shot `callHistMax` is removed). ✓
+- *Delete dead socket.io stub + commented code* → Task 5 Step 3 (rm socket service) + Step 1 (remove commented socket code). ✓
+- *Cleanup dead backend code + reconcile README* → Task 6. ✓
+
+**Placeholder scan:** Backend tasks (1–3, 6) carry complete code + tests/parse checks. Angular tasks (4–5) carry complete code with explicit manual `ng build`/`ng serve` verification (no headless-browser CI here) — this is the standard "implement + manual verify" pattern for UI, not a placeholder.
+
+**Type consistency:** `BackendStatus` (TS) mirrors `build_status` (py) keys exactly (Tasks 2/4). `Recorder.frames_written`/`elapsed_s`/`is_recording` used in Tasks 1/3. `StatusService.status$: Observable` consumed in Task 5. The Phase 3 routes `/set_focus`, `/return_to_focus`, `/toggle_drift_comp` called in Task 5 match Phase 3 definitions.
+
+**Known follow-ups (not gaps):** the Angular 13→current framework upgrade (deferred); karma/headless-browser unit tests for the new Angular services (the harness here can't run them); a richer histogram view if the one-shot `get_hist` stream is reworked.