diff --git a/ROADMAP.md b/ROADMAP.md
index 26280e63..39f806e6 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -9,7 +9,7 @@ Ship a signed-off Muse classroom loop: Design → Collect → Clean → Analyze,
- [x] Cut Emotiv SDK
- [x] Muse + Neurosity first-party drivers (`EEGDriver` registry)
- [x] LSL outlets for connected first-party devices (epochs + stimulus markers)
-- [x] External LSL inlet in ConnectModal (when liblsl is available)
+- [x] External LSL inlet in headset setup (when liblsl is available)
- [x] Restore custom-experiment authoring (see TODOS — P0)
- [x] QA built-in + custom experiments on Muse hardware
- [x] First release dry-run (`v1.0.0-rc.1`) + packaged-app smoke
@@ -17,9 +17,22 @@ Ship a signed-off Muse classroom loop: Design → Collect → Clean → Analyze,
CSV is still the system of record. Using LSL *internally* for recording is not a V1 goal.
-## V1.5 — visual polish
+## V1.5 — playtest-driven redesign
-Epoch-reviewer onboarding (plain language, guided mode). See TODOS "Next".
+Fix what the first naive playtest (`docs/uxr/playtest_naive_1.md`, 2026-09-18) showed: unclear starting point and next action, headset setup, the Explore lessons, and a Clean → Analyze workflow that did not hold together. Plan: `docs/uxr/playtest_naive_1_design_implementation_plan.md` (gitignored). Each surface went Storybook design → approval → integration.
+
+- [x] WS1 Navigation shell and Home (#269)
+- [x] WS2 Headset setup and connection (#272, #274)
+- [x] WS3 Explore lessons, including the eyes-closed activity (#282, #286)
+- [x] WS4 Prepare steps and preview (#276, #281)
+- [x] WS5 Participant screens, early exit and incomplete runs (#273, #275, #279)
+- [x] WS6 Clean (#280, #285)
+- [x] WS7 Analyze (#277, #284)
+- [ ] WS8 Full manual QA on real hardware, then a second naive playtest (see TODOS "Next")
+- [ ] Fixes and language PR from WS8, then release v1.5
+
+Known gap going in: the Collect pre-run screen never got its own redesign.
+Epoch-reviewer guided mode stays a V1.5+ item, contingent on the playtest.
## V2 — lesson content
diff --git a/TODOS.md b/TODOS.md
index 2c223193..f490d170 100644
--- a/TODOS.md
+++ b/TODOS.md
@@ -13,18 +13,18 @@ Deferred and in-flight work. Keep this current — when something ships, delete
- ~~Collect: ConnectModal fails to appear on first navigation to the collect screen.~~ — shipped 2026-09-23 (WS2, PR #274). `ConnectModal` is gone; Collect opens the shell-owned `HeadsetSetupDialog` whenever EEG is on and no headset is connected, and never scans until `Find my headset`.
- ~~QA (WS2, PR #274): real-Muse pass~~ — done 2026-09-24 (human, real Muse): find → connect → signal prep, cancel/search again, drop → setup reopens, cancel while connecting. Finding: the platform never ends a search itself (it ran until Cancel), hence the one-minute limit below.
- ~~QA (PR #274): one-minute search limit on a real Muse~~ — done 2026-09-24 (human, real Muse): with the headset off, the search ends after a minute on "We couldn't find your Muse" / "Is your Muse turned on?". "Search again" afterwards is covered by `HeadsetSetupDialog.timeout.test.tsx` (real reducer, epics and dialog; faked driver).
- - Collect: layout incorrect.
- - Clean: crash at "Ready to clean subject" — reproduce and fix.
- - Analyze: layout broken by recent component changes; stray elements popping up.
- - Student-facing experiment names (current names are placeholder/dev-written).
+ - Collect: layout incorrect. **Still open.** The pre-run Collect screen (`CollectComponent/PreTestComponent.tsx`) never got a Storybook redesign: WS5's approved design covered only the in-experiment screens, and #270 added the Ready card and SPACE gate. It still uses the old `HelpButton` + `LessonSidebar` pattern that broke Analyze. Re-check in the v1.5 QA pass; redesign if the second playtest trips on it.
+ - ~~Clean: crash at "Ready to clean subject"~~ — the screen was rewritten in WS6 (PR #285); that path no longer exists. Confirm in the v1.5 QA pass.
+ - ~~Analyze: layout broken, stray elements~~ — fixed 2026-09-27 (WS7, PR #284): the dead `HelpButton`, the in-flow help panel and the `h-screen` wrapper are gone.
+ - Student-facing experiment names (current names are placeholder/dev-written). Re-check in the v1.5 QA pass.
- ~~Preview → run CTA; overwrite protection~~ — shipped 2026-09-23 (`Run & record` after a preview on all Design surfaces; an existing subject/group/session — behavior or EEG file — is never overwritten: the run moves to the next free session or is cancelled; branch `feat/run-preview-polish`).
- ~~Larger pre-screen edit affordance~~ — shipped 2026-09-23 (Ready-to-run card, same branch).
- ~~"Press SPACE to begin" prompt~~ — shipped 2026-09-23 (gate between Ready and `Start`; nothing records until SPACE, same branch).
- ~~In-experiment progress~~ — shipped 2026-09-23 in the RunBar (lab.js: innermost loop position + practice/main; jsPsych: total only when the timeline has no loop/conditional functions or custom sampling). Multitasking counts trials within each block — no study-wide total.
- ~~Pre-run coaching copy~~ — shipped 2026-09-23. Pacing is per-protocol (`protocol.pacing`, set for Faces/Houses, Visual Search, Multitasking); the shared EEG line is stillness/no talking only (plan §7.4).
- - Clean vs Analyze labels are ambiguous — clarify the step purpose.
- - Clean: allow choosing a different file without going back/undoing.
- - Add "What does clean your data mean?" student explainer (aligns with Epoch reviewer onboarding work below).
+ - ~~Clean vs Analyze labels are ambiguous~~ — WS6/WS7 (PRs #285, #284): Clean opens with what cleaning does and its five-step loop; Analyze's EEG tabs say "Clean first" until a cleaned recording exists. Judge it in the second playtest.
+ - ~~Clean: allow choosing a different file without going back/undoing~~ — shipped 2026-09-27 (WS6, PR #285): `← Pick different data`, one recording at a time.
+ - ~~"What does clean your data mean?" explainer~~ — shipped 2026-09-27 (WS6, PR #285): the docked cleaning primer.
- ~~Nav-state distinction: the workflow bar (Prepare/Collect/Clean/Analyze) and the local steps bar (Overview/Background/Protocol/Preview) read as one stacked nav on Prepare — Workstream 4, plan §3.2. (playtest 09-23)~~ — shipped 2026-09-25 (WS4 Prepare integration, PR #281). Built-ins' Design screen is `PrepareSteps`: gold step pills under the gold-underline workflow bar, one forward action per step.
- ~~Blocked areas~~ — shipped 2026-09-23 (`WorkspaceAreaGate` on /clean and /analyze, PR #269).
- ~~Device chip is display-only~~ — shipped 2026-09-23 (WS2, PR #274). The header chip opens headset setup; the RunBar chip stays status-only.
@@ -39,10 +39,20 @@ Deferred and in-flight work. Keep this current — when something ships, delete
- [ ] **Multitasking's Prepare protocol diagram** describes two rules on the same keys (`Top: diamond → B`, `Bottom: 2 dots → B`, …); verify with a teacher whether it reads clearly.
- [ ] **Expanded preview** — the Design preview is fit-to-box at zoom 0.55 (small print ~10px). Add an expanded/full-size preview with the same Preview label and Stop action.
- [ ] **Collect without a workspace** — Collect actions (Preview, Run & record) should be unavailable when there is no workspace (`params` is null after `ExperimentCleanup`, e.g. cancelling headset setup while connecting). `LabjsExperimentWindow` now waits instead of crashing (PR #281), but the buttons still look usable.
-- [ ] **Cleaning UXR playtest** (added 2026-09-25, after the WS6 Clean design #280) — a naive user, with no facilitator help, cleans one real recording in the redesigned Clean screen. Can they find the job (leave out noisy trials, flag a bad sensor, review auto-flag suggestions, watch the Live ERP change, save), and do they understand why cleaning comes before Analyze? Run after WS6 integration, before WS8's full second playtest.
+- [ ] **Full manual QA pass (WS8, before the second playtest)** — plan §11 WS8 steps 1–13, on real hardware and the packaged build. Checks this round could not run, which a human must:
+ - Explore on a real Muse: blink bands appear, step 4's paused calm/blinking strips, and the eyes-closed ratio text (the fixture has no blinks, and the hidden agent window was throttled).
+ - The WS5 hand checks listed under "Playtest 1 fixes" above (complete run, "session 2" prompt, ended-early imported study, behavior-only runs).
+ - The re-check items above: the Collect pre-run layout, student-facing experiment names, and the WorkflowNav gold border in a visible window.
+ - Packaged app: a macOS arm64 build of `main` at a1b7153 boots and loads Pyodide.
+- [ ] **Second naive playtest (WS8, v1.5 gate)** — protocol in `docs/uxr/` (gitignored). A naive participant runs the whole journey unaided, including cleaning one real recording in the redesigned Clean screen (this replaces the separate cleaning UXR playtest added 2026-09-25). Compare against `docs/uxr/playtest_naive_1.md`; findings feed the v1.5 fixes and language PR.
+- [ ] **Bundle the Lato webfont** (found 2026-09-27) — every Storybook design was approved in Lato (`.storybook/preview-head.html` loads it from Google Fonts), but the app has no Lato, so it falls back to Helvetica Neue and text wraps differently on every screen. Ship Lato latin 300/400/700 `woff2` in `assets/fonts/` with `@font-face` rules like the two display fonts, and point Storybook at the same files; classroom machines may be offline.
+- [ ] **Run & record does nothing when the session exists as ended-early** (found in the WS6 playtest) — pre-existing: starting a run for a subject/group/session whose files are `*.incomplete.csv` opens nothing. The overwrite guard (#270) should treat it like any taken session.
+- [ ] **Fixture headset doubles its markers** (found in the WS6 playtest) — a 120-trial Faces/Houses run under the fixture headset loads as 193 epochs, because the fixture CSV replays its own embedded markers alongside the app's. Testing-only, but it skews every agent playtest count. Strip the replayed markers.
+- [ ] **Explore: no stream stall watchdog** (WS3, PR #286) — the stream-stopped banner shows on the stream's `error`/`complete`. A stream that silently stops emitting shows nothing. Add a watchdog if playtests hit it.
+- [ ] **Explore: cleaner-signal tips layout had no design** (WS3, PR #286) — the tips reuse the approved step panel, but the head diagram floats top right with blank space below. Merged as-is; decide in the second playtest.
- [ ] **Participant screens for imported jsPsych/lab.js studies** (deferred 2026-09-25): wrap author timelines with BrainWaves instruction/transition/end screens.
- [ ] **Import stimuli into the workspace?** — today custom experiments load images/sounds straight from wherever the student keeps them (Documents/Downloads) via the `bwfile://` allowlist; moving/renaming that folder silently breaks the study, and a workspace can't be zipped up and shared as a self-contained bundle. Alternative: copy stimuli into `BrainWaves_Workspaces/
/stimuli//` at selection time (single pre-authorized root, portable study bundles; costs disk duplication + stale copies if the source folder is edited later). **Contingent on user testing** — students may actually prefer managing their own folders in Documents/Downloads, since workspace folders are semi-private territory full of mysterious things like `appState.json`. Decide after watching a class use the current flow.
-- [ ] **Hooks / function-component migration (AI-friendly).** Most student screens are still class components + `react-redux` `connect()` / `bindActionCreators` containers (`src/renderer/containers/`). Newer work (`EpochReviewer`, `LiveErpPane`, `RunComponent`, `TopNavComponent`) is hooks. Incremental, screen-by-screen; do not big-bang. Pattern to copy: hooks + `useDispatch`/`useSelector` like `App.tsx`. Not a V1 blocker.
+- [ ] **Hooks / function-component migration (AI-friendly).** Most student screens are still class components + `react-redux` `connect()` / `bindActionCreators` containers (`src/renderer/containers/`). Newer work (`EpochReviewer`, `LiveErpPane`, `RunComponent`, `AppShell`, the redesigned Clean/Analyze/Explore screens) is hooks. Incremental, screen-by-screen; do not big-bang. Pattern to copy: hooks + `useDispatch`/`useSelector` like `App.tsx`. Not a V1 blocker.
- [ ] **Epoch reviewer Phase 3 — onboarding layer.** Plain-language explanations of epochs + each artifact type, a **guided mode** (step through auto-flagged epochs with "why we flagged this," student confirms/overrides), channel legend tied to head position (Muse 10-20), student-facing tone. Builds on the Phase 0-2 reviewer (PRs #223/#224/#225). **Open question OQ3 (onboarding depth) is still unresolved** — how much curriculum (tooltips only vs. a real walkthrough), guided-mode-as-default? This is product-shaped, not architecture.
- [ ] **WorkflowNav stale gold border** (added 2026-09-27, found in the WS7 Analyze playtest) — intermittent: after reload → Home → open workspace (lands on `/design`) → Analyze, the PREPARE button keeps a computed gold `border-bottom` although its class is `border-transparent`, and the current ANALYZE button stays transparent. Also seen on `/clean`. `aria-current` and `areaForPath` are correct, so the route logic is fine. A clone inserted at the same spot computes transparent, and re-attaching the node (`display: none` → `''`) fixes it. That points at a Chromium style-invalidation issue, not a location bug. The same path is sometimes fine. In one run every class-driven style on the page went stale (Analyze tabs, the selected sensor), and any viewport resize restyled everything correctly. So this may be an artifact of the CDP-driven, occluded Electron window: confirm it in a visible, human-driven window before fixing anything.
- [ ] **Topo legend mislabels conditions** (added 2026-09-27, predates WS7) — `plt.legend(labels)` in `utils.py` `plot_topo` takes its handles from the current axes' lines, so the swatches don't match the conditions (House gets a blue line, Face none). Pass explicit handles, e.g. `Line2D` in each condition's palette color.
@@ -65,12 +75,14 @@ Deferred and in-flight work. Keep this current — when something ships, delete
- [ ] **(Optional) Full Pyodide worker RPC** — the analysis/Clean pipeline crash is now **fixed** (harvested from PR #194): a `dataKey` routing pattern parallel to `plotKey` — the worker echoes `dataKey` + PyProxy-converted results, and `pyodideMessageEpic` routes `epochsInfo`→`SetEpochInfo` / `channelInfo`→`SetChannelInfo`; the info epics are fire-and-forget. This unblocks the pipeline without the bigger refactor. The deeper latent issue remains, though: `worker.postMessage` returns `undefined` on *post*, so the `await`s in `webworker/index.ts` are no-ops and cross-message sequencing still relies on worker FIFO. A true `runPython(worker, code, ctx?)` RPC — `Map` + one `message` listener, worker echoes `id` — would let epics `await` real results and delete the `plotKey`/`dataKey` switch entirely. Only worth doing if the FIFO sequencing ever actually bites; not urgent now.
- [ ] Pyodide-fidelity smoke test — analysis pipeline is tested against native MNE, not yet under Pyodide/WASM (see `.llms/learnings.md`). **In progress:** the epoch-review Phase 0 adds a *narrow* Pyodide test for the `get_epochs_arrays` float32 buffer path (byteLength, decode-vs-native, transfer detaches source); the full-pipeline Pyodide job remains deferred.
-- [ ] **Epoch reviewer Phase 2 polish (from PR #225 review).** Three non-blocking behaviors flagged during the Phase 2 forge: (1) `get_epochs_arrays`/`suggest_rejections` use `pick_types(eeg=True)` whose MNE default is `exclude='bads'`, so after a Clean that flags a bad channel the re-fetched reviewer omits that channel from the display (saved `.fif` is unaffected) — decide whether to keep bad channels visible-but-greyed (`exclude=[]`) instead of vanishing; (2) re-running "Auto-flag" with the same threshold re-adds suggestions the user had manually unclicked (additive union merge in `CleanComponent.componentDidUpdate`); (3) the auto-flag threshold `` has no min guard (0 µV flags everything). All in `src/renderer/components/CleanComponent/` + `webworker/utils.py`.
+- [ ] **CI has no feature-flow tests** (2026-09-27) — the `playtest` job only boots the app (preload, React, Pyodide ready). Nothing drives Collect → Clean → Analyze, so every flow check this round was an agent playtest. One scripted fixture-headset flow per journey area is the prerequisite for the proposed automated review → automerge cycle (ponytail review + correctness review + an independent playtest + required checks + `gh pr merge --auto` behind a merge queue, for PRs that implement an approved design only).
## Done recently
+- **Playtest-1 redesign round, WS1–WS7** (2026-09-22 → 2026-09-27) — every surface from `docs/uxr/playtest_naive_1_design_implementation_plan.md` went Storybook design → review → integration: nav shell and Home (#269), headset setup (#272, #274), participant screens and early exit (#273, #275, #279), Prepare (#276, #281), Analyze (#277, #284), Clean (#280, #285) and Explore (#282, #286). Clean is one recording at a time with Accept/Restore suggestions and ended-early recordings deletable to the Trash; Analyze has its own cleaned-epoch slot, error states and palette colors for every condition; Explore adds the eyes-closed activity and Disconnect in the setup dialog. The old Phase 2 reviewer polish items (bad channels vanishing, auto-flag re-adding suggestions, no threshold floor) went with the Clean rewrite.
+
- **Release workflow: manual approval gate** (2026-09-01) — Switched from `push: tags` auto-trigger to `workflow_dispatch` with a `confirm=YES` input. Prevents empty releases caused by GitHub auto-creating a non-draft release that conflicts with electron-builder's `releaseType: draft`. Release process: tag → Actions → Run workflow → type YES → builds draft → manually publish in GitHub UI.
- **Custom experiments Muse QA** (2026-08-26) — Click-through QA on Muse hardware confirmed working post-refactor. Design → Conditions (image + sound folders) → Preview → Collect → Clean → Analyze produces valid ERPs. Sound stimuli latency acceptable for ERP work.
- **Fixture/Replay EEGDriver** (2026-08-26) — Merged (PR #247). Synthetic 4-channel CSV fixture replays as live `Observable` at 256 Hz with injectable markers. 12 tests passing. Enables agent and CI testing of Collect → Clean → Analyze without a physical headset.
diff --git a/docs/superpowers/plans/2026-09-23-ws2-headset-setup.md b/docs/superpowers/plans/2026-09-23-ws2-headset-setup.md
deleted file mode 100644
index a768a0d8..00000000
--- a/docs/superpowers/plans/2026-09-23-ws2-headset-setup.md
+++ /dev/null
@@ -1,1301 +0,0 @@
-# WS2 Headset Setup Integration Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Replace `ConnectModal` with the approved `HeadsetSetup` stories wired to live device state: discovery starts only from `Find my headset`, search is open-ended and cancellable, the shell device chip opens setup, and signal prep follows pairing on Collect/Explore.
-
-**Architecture:** One `HeadsetSetupDialog` container, mounted once in `AppShellContainer`, turns Redux device state plus local screen state into the pure `HeadsetSetup` view through a tested `pairingStep()` function. Collect, Explore and the device chip open it through a `HeadsetSetupContext` (same pattern as `RunProgressContext`). The existing `EEGDriver` interface and device actions stay; the only new action is `CancelSearch`, which replaces the 3-second `searchTimerEpic`.
-
-**Tech Stack:** React 18, Redux Toolkit, redux-observable 2 / RxJS 7, Radix Dialog, Vitest + Testing Library, Electron (Web Bluetooth via `select-bluetooth-device`).
-
-**Spec:** `docs/uxr/playtest_naive_1_design_implementation_plan.md` §3.1, §4, §11 Workstream 2, §14. Approved design: `src/renderer/components/HeadsetSetup/` (PR #272, Storybook `Domain/HeadsetSetup`).
-
-**Skills to read before starting:** `.claude/skills/electron-ipc-architecture/SKILL.md` (Bluetooth crosses main/renderer), `.claude/skills/electron-playtest/SKILL.md` (Task 6).
-
-## Global Constraints
-
-- First-time discovery uses Web Bluetooth `requestDevice()`, which requires an explicit user action. `SetDeviceAvailability(SEARCHING)` MUST be dispatched synchronously inside the click handler; `searchEpic` calls `scan()` synchronously in `map` (see the NOTE above it in `deviceEpics.ts`). Never move `scan()` behind `from()`, `await`, or a timer.
-- "Remove automatic first-time scanning from the Collect mount effect." (§4.3)
-- "Replace the fixed three-second failure path with a cancellable search that remains active until a device is found, the user cancels, or the platform reports failure." (§4.3)
-- "Cancel the pending Bluetooth picker when the setup UI closes." (§4.3)
-- "Preserve the current driver interface and Redux actions unless an actual missing state requires a minimal extension." (§11 WS2)
-- "Feed the shell chip from the same connection state and keep `Connected` distinct from `Recording`." (§11 WS2). No second connection state machine.
-- The chip opens setup "outside a run" (§3.1): the `RunBar` chip stays non-interactive.
-- No multi-headset picker, no IPC changes. Main still auto-selects the first advertised device (§4.3, §13).
-- Signal prep never gates (the `SignalPrep` docstring).
-- Obsolete UI is deleted, not parked (§14). `ConnectModal.tsx`, `searchTimerEpic`, and `SEARCH_TIMER` go away.
-- Do not restyle `HeadsetSetup.tsx` / `SignalPrep.tsx`. They are the approved design. Wiring only.
-- Comments go on definitions (docstrings), not narrating inside function bodies (`.llms/learnings.md`).
-- Subagents: skip formatters, project-wide lint, and the full test suite. Run only the files named in each task. Task 6 runs everything once.
-
-## Review Focus
-
-1. **Closing mid-search** (×, Escape, overlay click) must cancel the pending `requestDevice()`. If it doesn't, the next `Find my headset` hangs or fails. The test is in Task 3.
-2. **A headset that drops on Collect** reopens setup at "Which headset?" and must NOT start a scan without a click. The test is in Task 4.
-3. **A rejected `connect()`** currently errors `connectEpic`'s stream and kills every device epic until reload. After a failure, "Try again" must still work. The test is in Task 1.
-4. **Cancel during Connecting** must never be followed by a late `CONNECTED` from the abandoned promise. The test is in Task 1.
-5. **LSL discovery IPC rejects** (liblsl hiccup). The student must land on "couldn't find", not a spinner forever. The test is in Task 1.
-
-## File Structure
-
-| File | Change | Responsibility |
-|---|---|---|
-| `src/renderer/actions/deviceActions.ts` | modify | add `CancelSearch` |
-| `src/renderer/epics/deviceEpics.ts` | modify | open-ended search, `cancelSearchEpic`, connect failure/cancel, LSL discovery failure; delete `searchTimerEpic` |
-| `src/renderer/constants/constants.ts` | modify | delete `SEARCH_TIMER` |
-| `src/main/index.ts:694-696` | modify | comment only: renderer cancels, not a timer |
-| `src/renderer/epics/__tests__/deviceEpics.test.ts` | create | epic behavior |
-| `src/renderer/components/HeadsetSetup/pairingStep.ts` | create | pure Redux+screen → `PairingStep` |
-| `src/renderer/components/HeadsetSetup/__tests__/pairingStep.test.ts` | create | precedence rules |
-| `src/renderer/components/HeadsetSetup/HeadsetSetupDialog.tsx` | create | container: Radix dialog + handlers |
-| `src/renderer/components/HeadsetSetup/__tests__/HeadsetSetupDialog.test.tsx` | create | no-scan-on-open, cancel-on-close |
-| `src/renderer/containers/AppShellContainer.tsx` | modify | mount dialog, `HeadsetSetupContext`, chip click, signal-prep flag |
-| `src/renderer/components/AppShell/DeviceChip.tsx`, `AppShell.tsx` | modify | optional `onClick` / `onDeviceClick` |
-| `src/renderer/components/CollectComponent/index.tsx` | modify | open via context, no scan, signal prep |
-| `src/renderer/components/EEGExplorationComponent.tsx` | modify | open via context, signal prep |
-| `src/renderer/components/CollectComponent/ConnectModal.tsx` | delete | replaced |
-| `src/renderer/components/CollectComponent/__tests__/CollectModal.test.tsx` | modify | new contract |
-| `src/renderer/components/HeadsetSetup/LiveSignalPrep.tsx` | create | subscribes to quality stream → `SignalPrep` |
-
-## Dispatch waves (subagent-driven)
-
-Work in one worktree: `git worktree add .worktrees/ws2-headset-setup -b feat/ws2-headset-setup` (see `superpowers:using-git-worktrees`).
-
-| Wave | Tasks | Why |
-|---|---|---|
-| 1 | Task 1 ∥ Task 2 | Disjoint files; Task 2 is pure |
-| 2 | Task 3 | Needs `pairingStep` (T2) and `CancelSearch` (T1) |
-| 3 | Task 4 | Needs `HeadsetSetupContext` (T3) |
-| 4 | Task 5 | Extends the context (T3) and the hosts (T4) |
-| 5 | Task 6 | Integration verification: agent via CDP with the Fixture; the human with a real Muse |
-
----
-
-### Task 1: Open-ended, cancellable discovery and safe connect in `deviceEpics`
-
-**Files:**
-- Modify: `src/renderer/actions/deviceActions.ts:45` (after `Cleanup`)
-- Modify: `src/renderer/epics/deviceEpics.ts:37-148, 256-265, 330-344`
-- Modify: `src/renderer/constants/constants.ts:66` (delete `SEARCH_TIMER`)
-- Modify: `src/main/index.ts:694-696` (comment)
-- Test: `src/renderer/epics/__tests__/deviceEpics.test.ts` (create)
-
-**Interfaces:**
-- Consumes: `getDriver(type).scan() / cancelScan() / connect(device)` from `src/renderer/utils/eeg` (unchanged `EEGDriver`).
-- Produces: `DeviceActions.CancelSearch()` → driver `cancelScan()` + `SetDeviceAvailability(NONE)`. After this task:
- - A search only ends via `DeviceFound`, `SetDeviceAvailability(NONE)` (scan rejected or returned nothing), or `CancelSearch`.
- - A failed connect emits `SetConnectionStatus(DISCONNECTED)`.
- - `DisconnectFromDevice` abandons an in-flight connect.
- - A failed LSL discovery emits `SetAvailableLSLStreams([])`.
-
-- [ ] **Step 1: Write the failing tests**
-
-```ts
-// src/renderer/epics/__tests__/deviceEpics.test.ts
-import { Subject } from 'rxjs';
-import { afterEach, describe, expect, it, vi } from 'vitest';
-import type { StateObservable } from 'redux-observable';
-import { DeviceActions } from '../../actions';
-import type { DeviceActionType } from '../../actions';
-import {
- CONNECTION_STATUS,
- DEVICE_AVAILABILITY,
- DEVICES,
-} from '../../constants/constants';
-import type { RootState } from '../../reducers';
-import deviceEpics from '../deviceEpics';
-
-const driver = vi.hoisted(() => ({
- scan: vi.fn(),
- cancelScan: vi.fn(),
- connect: vi.fn(),
- disconnect: vi.fn(),
-}));
-const lsl = vi.hoisted(() => ({ discoverLSLStreams: vi.fn() }));
-
-vi.mock('../../utils/eeg', () => ({
- getDriver: () => driver,
- setActiveDriver: vi.fn(),
-}));
-vi.mock('../../utils/eeg/muse', () => ({
- createMuseSignalQualityObservable: vi.fn(),
-}));
-vi.mock('../../utils/eeg/lslInlet', () => lsl);
-vi.mock('../../utils/eeg/lslBridge', () => ({}));
-
-const MUSE = { id: 'muse-1', name: 'Muse-4A2F' };
-const INFO = { name: 'Muse-4A2F', samplingRate: 256, channels: ['AF7'] };
-
-function harness(device: Partial = {}) {
- const actions = new Subject();
- const state = {
- value: {
- device: {
- deviceType: DEVICES.MUSE,
- deviceAvailability: DEVICE_AVAILABILITY.NONE,
- connectionStatus: CONNECTION_STATUS.NOT_YET_CONNECTED,
- availableDevices: [],
- ...device,
- },
- },
- } as unknown as StateObservable;
- const out: DeviceActionType[] = [];
- const sub = deviceEpics(actions, state, undefined).subscribe((a) =>
- out.push(a)
- );
- return { actions, state, out, sub };
-}
-
-const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
-
-describe('device discovery', () => {
- afterEach(() => {
- vi.useRealTimers();
- vi.clearAllMocks();
- });
-
- it('keeps searching until the driver answers — no timeout gives up', async () => {
- vi.useFakeTimers();
- driver.scan.mockReturnValue(new Promise(() => undefined));
- const h = harness({ deviceAvailability: DEVICE_AVAILABILITY.SEARCHING });
-
- h.actions.next(
- DeviceActions.SetDeviceAvailability(DEVICE_AVAILABILITY.SEARCHING)
- );
- await vi.advanceTimersByTimeAsync(60_000);
-
- expect(h.out).toEqual([]);
- expect(driver.cancelScan).not.toHaveBeenCalled();
- h.sub.unsubscribe();
- });
-
- it('ends the search as not found when the platform rejects the scan', async () => {
- driver.scan.mockRejectedValue(new Error('NotFoundError'));
- const h = harness({ deviceAvailability: DEVICE_AVAILABILITY.SEARCHING });
-
- h.actions.next(
- DeviceActions.SetDeviceAvailability(DEVICE_AVAILABILITY.SEARCHING)
- );
- await flush();
-
- expect(h.out).toEqual([
- DeviceActions.SetDeviceAvailability(DEVICE_AVAILABILITY.NONE),
- ]);
- h.sub.unsubscribe();
- });
-
- it('cancelling stops the platform search and ends it without a not-found echo', async () => {
- let reject: (e: Error) => void = () => undefined;
- driver.scan.mockReturnValue(
- new Promise((_, r) => {
- reject = r;
- })
- );
- const h = harness({ deviceAvailability: DEVICE_AVAILABILITY.SEARCHING });
- h.actions.next(
- DeviceActions.SetDeviceAvailability(DEVICE_AVAILABILITY.SEARCHING)
- );
-
- h.actions.next(DeviceActions.CancelSearch());
- h.state.value.device.deviceAvailability = DEVICE_AVAILABILITY.NONE;
- reject(new Error('cancelled'));
- await flush();
-
- expect(driver.cancelScan).toHaveBeenCalledTimes(1);
- expect(h.out).toEqual([
- DeviceActions.SetDeviceAvailability(DEVICE_AVAILABILITY.NONE),
- ]);
- h.sub.unsubscribe();
- });
-
- it('reports not found when LSL discovery fails', async () => {
- lsl.discoverLSLStreams.mockRejectedValue(new Error('liblsl'));
- const h = harness({ deviceType: DEVICES.LSL });
-
- h.actions.next(DeviceActions.DiscoverLSLStreams());
- await flush();
-
- expect(h.out).toEqual([DeviceActions.SetAvailableLSLStreams([])]);
- h.sub.unsubscribe();
- });
-});
-
-describe('device connection', () => {
- afterEach(() => vi.clearAllMocks());
-
- it('reports a failed connect and still connects on the next try', async () => {
- driver.connect
- .mockRejectedValueOnce(new Error('GATT'))
- .mockResolvedValueOnce(INFO);
- const h = harness();
-
- h.actions.next(DeviceActions.ConnectToDevice(MUSE));
- await flush();
- expect(h.out).toContainEqual(
- DeviceActions.SetConnectionStatus(CONNECTION_STATUS.DISCONNECTED)
- );
-
- h.actions.next(DeviceActions.ConnectToDevice(MUSE));
- await flush();
- expect(h.out).toContainEqual(
- DeviceActions.SetConnectionStatus(CONNECTION_STATUS.CONNECTED)
- );
- h.sub.unsubscribe();
- });
-
- it('never reports connected after the attempt was cancelled', async () => {
- let resolve: (info: typeof INFO) => void = () => undefined;
- driver.connect.mockReturnValue(
- new Promise((r) => {
- resolve = r;
- })
- );
- const h = harness({ connectionStatus: CONNECTION_STATUS.CONNECTING });
-
- h.actions.next(DeviceActions.ConnectToDevice(MUSE));
- h.actions.next(DeviceActions.DisconnectFromDevice());
- resolve(INFO);
- await flush();
-
- expect(h.out).not.toContainEqual(
- DeviceActions.SetConnectionStatus(CONNECTION_STATUS.CONNECTED)
- );
- h.sub.unsubscribe();
- });
-});
-```
-
-- [ ] **Step 2: Run to verify they fail**
-
-Run: `npx vitest run src/renderer/epics/__tests__/deviceEpics.test.ts`
-Expected: FAIL. `CancelSearch` is not a function. The timeout test sees `SetDeviceAvailability(NONE)` + `cancelScan`. The reject test sees no output. The connect-retry test errors (the epic stream dies). The cancel-connect test sees `CONNECTED`. LSL sees no output.
-
-- [ ] **Step 3: Add the action**
-
-In `deviceActions.ts`, after `Cleanup`:
-
-```ts
- /** Stops an in-progress Bluetooth search; the pending requestDevice() rejects. */
- CancelSearch: createAction('CANCEL_SEARCH'),
-```
-
-- [ ] **Step 4: Rewrite search, cancel, connect, and LSL discovery in `deviceEpics.ts`**
-
-Replace `searchMuseEpic` (lines 37-59) with:
-
-```ts
-/**
- * Runs one discovery per SEARCHING. `scan()` is called synchronously inside the
- * dispatch so Web Bluetooth keeps the user gesture (Observable.from loses it).
- * The search stays open until the driver answers; a rejected or empty scan
- * ends it as not found. Results after a cancel are dropped.
- */
-const searchEpic: Epic = (
- action$,
- state$
-) =>
- action$.pipe(
- filter(isActionOf(DeviceActions.SetDeviceAvailability)),
- pluck('payload'),
- filter((status) => status === DEVICE_AVAILABILITY.SEARCHING),
- map(() => getDriver(state$.value.device.deviceType).scan()),
- mergeMap((promise) =>
- promise.then(
- (devices) =>
- devices?.length
- ? DeviceActions.DeviceFound(devices)
- : DeviceActions.SetDeviceAvailability(DEVICE_AVAILABILITY.NONE),
- () => DeviceActions.SetDeviceAvailability(DEVICE_AVAILABILITY.NONE)
- )
- ),
- filter(
- () =>
- state$.value.device.deviceAvailability === DEVICE_AVAILABILITY.SEARCHING
- )
- );
-```
-
-Replace `searchTimerEpic` (lines 84-111) with:
-
-```ts
-/** User cancelled: reject the pending requestDevice() in main and end the search. */
-const cancelSearchEpic: Epic = (
- action$,
- state$
-) =>
- action$.pipe(
- filter(isActionOf(DeviceActions.CancelSearch)),
- tap(() => getDriver(state$.value.device.deviceType).cancelScan()),
- map(() => DeviceActions.SetDeviceAvailability(DEVICE_AVAILABILITY.NONE))
- );
-```
-
-Replace `connectEpic` (lines 113-140) with this. The success branch body is unchanged from lines 123-139:
-
-```ts
-/**
- * Connects the chosen device. A rejected connect reports DISCONNECTED (the
- * setup flow's "failed" state) without killing the epic; DisconnectFromDevice
- * abandons an in-flight attempt so a late success cannot report CONNECTED.
- */
-const connectEpic: Epic = (
- action$,
- state$
-) =>
- action$.pipe(
- filter(isActionOf(DeviceActions.ConnectToDevice)),
- pluck('payload'),
- mergeMap((device) =>
- from(getDriver(state$.value.device.deviceType).connect(device)).pipe(
- catchError(() => of(null)),
- takeUntil(
- action$.pipe(filter(isActionOf(DeviceActions.DisconnectFromDevice)))
- ),
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- mergeMap>((deviceInfo) => {
- if (deviceInfo != null && deviceInfo.samplingRate != null) {
- setActiveDriver(state$.value.device.deviceType);
- return of(
- DeviceActions.SetDeviceType(state$.value.device.deviceType),
- DeviceActions.SetDeviceInfo(deviceInfo),
- DeviceActions.SetConnectionStatus(CONNECTION_STATUS.CONNECTED)
- );
- }
- return of(
- DeviceActions.SetConnectionStatus(CONNECTION_STATUS.DISCONNECTED)
- );
- })
- )
- )
- );
-```
-
-In `discoverLSLStreamsEpic` (line 263), make the inner observable fail soft:
-
-```ts
- mergeMap(() =>
- from(discoverLSLStreams()).pipe(catchError(() => of([])))
- ),
-```
-
-In `combineEpics` (lines 330-344): rename `searchMuseEpic` → `searchEpic`, replace `searchTimerEpic` with `cancelSearchEpic`. Remove `timer` and `SEARCH_TIMER` from the imports.
-
-- [ ] **Step 5: Delete `SEARCH_TIMER` and fix the main-process comment**
-
-Delete `export const SEARCH_TIMER = 3000;` from `constants.ts:66`. Confirm there are no other users: `grep -rn SEARCH_TIMER src` should print nothing. In `src/main/index.ts` replace lines 694-696 with:
-
-```ts
- // Nothing visible yet — keep scanning. The event fires again as devices
- // appear; the renderer's Cancel calls bluetooth:cancelSearch to reject.
-```
-
-- [ ] **Step 6: Run to verify they pass**
-
-Run: `npx vitest run src/renderer/epics/__tests__/deviceEpics.test.ts`
-Expected: 6 passed.
-
-- [ ] **Step 7: Commit**
-
-```bash
-git add src/renderer/actions/deviceActions.ts src/renderer/epics/deviceEpics.ts src/renderer/epics/__tests__/deviceEpics.test.ts src/renderer/constants/constants.ts src/main/index.ts
-git commit -m "feat(device): open-ended cancellable discovery; connect failures no longer kill epics"
-```
-
----
-
-### Task 2: `pairingStep()` — which setup screen to show
-
-**Files:**
-- Create: `src/renderer/components/HeadsetSetup/pairingStep.ts`
-- Test: `src/renderer/components/HeadsetSetup/__tests__/pairingStep.test.ts`
-
-**Interfaces:**
-- Consumes: `PairingStep` from `./HeadsetSetup` (exists).
-- Produces:
- ```ts
- export type SetupScreen = 'choose' | 'wear' | 'ready' | 'discovery';
- export interface PairingInputs {
- screen: SetupScreen;
- isLSL: boolean;
- availability: DEVICE_AVAILABILITY;
- connectionStatus: CONNECTION_STATUS;
- lslSearching: boolean;
- foundCount: number;
- }
- export function pairingStep(i: PairingInputs): PairingStep;
- ```
-
-- [ ] **Step 1: Write the failing test**
-
-```ts
-// src/renderer/components/HeadsetSetup/__tests__/pairingStep.test.ts
-import { describe, expect, it } from 'vitest';
-import {
- CONNECTION_STATUS,
- DEVICE_AVAILABILITY,
-} from '../../../constants/constants';
-import { PairingInputs, pairingStep } from '../pairingStep';
-
-const base: PairingInputs = {
- screen: 'discovery',
- isLSL: false,
- availability: DEVICE_AVAILABILITY.NONE,
- connectionStatus: CONNECTION_STATUS.NOT_YET_CONNECTED,
- lslSearching: false,
- foundCount: 0,
-};
-
-describe('pairingStep', () => {
- it('shows connected whenever a device is connected, even when reopened from the chip', () => {
- expect(
- pairingStep({
- ...base,
- screen: 'choose',
- connectionStatus: CONNECTION_STATUS.CONNECTED,
- })
- ).toBe('connected');
- });
-
- it('keeps the student on their setup screen until they search, ignoring stale results', () => {
- expect(
- pairingStep({
- ...base,
- screen: 'ready',
- availability: DEVICE_AVAILABILITY.AVAILABLE,
- foundCount: 1,
- })
- ).toBe('ready');
- });
-
- it('follows a Bluetooth search to found or not found', () => {
- expect(
- pairingStep({ ...base, availability: DEVICE_AVAILABILITY.SEARCHING })
- ).toBe('searching');
- expect(
- pairingStep({
- ...base,
- availability: DEVICE_AVAILABILITY.AVAILABLE,
- foundCount: 1,
- })
- ).toBe('found');
- expect(pairingStep(base)).toBe('notFound');
- });
-
- it('shows a failed connect, but a fresh search replaces the old failure', () => {
- const failed = {
- ...base,
- availability: DEVICE_AVAILABILITY.AVAILABLE,
- foundCount: 1,
- connectionStatus: CONNECTION_STATUS.DISCONNECTED,
- };
- expect(pairingStep(failed)).toBe('failed');
- expect(
- pairingStep({ ...failed, availability: DEVICE_AVAILABILITY.SEARCHING })
- ).toBe('searching');
- });
-
- it('shows connecting while an attempt is in flight', () => {
- expect(
- pairingStep({
- ...base,
- availability: DEVICE_AVAILABILITY.AVAILABLE,
- foundCount: 1,
- connectionStatus: CONNECTION_STATUS.CONNECTING,
- })
- ).toBe('connecting');
- });
-
- it('follows LSL discovery from its own stream list, not Bluetooth availability', () => {
- const lsl = { ...base, isLSL: true, availability: DEVICE_AVAILABILITY.SEARCHING };
- expect(pairingStep({ ...lsl, lslSearching: true })).toBe('searching');
- expect(pairingStep({ ...lsl, foundCount: 2 })).toBe('found');
- expect(pairingStep(lsl)).toBe('notFound');
- });
-});
-```
-
-- [ ] **Step 2: Run to verify it fails**
-
-Run: `npx vitest run src/renderer/components/HeadsetSetup/__tests__/pairingStep.test.ts`
-Expected: FAIL. Cannot find module `../pairingStep`.
-
-- [ ] **Step 3: Implement**
-
-```ts
-// src/renderer/components/HeadsetSetup/pairingStep.ts
-import {
- CONNECTION_STATUS,
- DEVICE_AVAILABILITY,
-} from '../../constants/constants';
-import type { PairingStep } from './HeadsetSetup';
-
-/** Screens the student moves through by hand; `discovery` hands over to device state. */
-export type SetupScreen = 'choose' | 'wear' | 'ready' | 'discovery';
-
-export interface PairingInputs {
- screen: SetupScreen;
- /** LSL lists streams from its own discovery, not Bluetooth availability. */
- isLSL: boolean;
- availability: DEVICE_AVAILABILITY;
- connectionStatus: CONNECTION_STATUS;
- lslSearching: boolean;
- /** Headsets or EEG streams currently listable. */
- foundCount: number;
-}
-
-/**
- * The pairing screen to show. A live connection always wins; before the
- * student presses search their own screen wins; after that Redux device state
- * decides. A new search outranks a previous failed connect.
- */
-export function pairingStep(i: PairingInputs): PairingStep {
- if (i.connectionStatus === CONNECTION_STATUS.CONNECTED) return 'connected';
- if (i.screen !== 'discovery') return i.screen;
- if (i.connectionStatus === CONNECTION_STATUS.CONNECTING) return 'connecting';
- if (i.isLSL) {
- if (i.lslSearching) return 'searching';
- return i.foundCount ? 'found' : 'notFound';
- }
- if (i.availability === DEVICE_AVAILABILITY.SEARCHING) return 'searching';
- if (i.connectionStatus === CONNECTION_STATUS.DISCONNECTED) return 'failed';
- if (i.availability === DEVICE_AVAILABILITY.AVAILABLE && i.foundCount)
- return 'found';
- return 'notFound';
-}
-```
-
-- [ ] **Step 4: Run to verify it passes**
-
-Run: `npx vitest run src/renderer/components/HeadsetSetup/__tests__/pairingStep.test.ts`
-Expected: 6 passed.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add src/renderer/components/HeadsetSetup/pairingStep.ts src/renderer/components/HeadsetSetup/__tests__/pairingStep.test.ts
-git commit -m "feat(headset): derive pairing step from device state"
-```
-
----
-
-### Task 3: `HeadsetSetupDialog` container, shell mount, clickable chip
-
-**Files:**
-- Create: `src/renderer/components/HeadsetSetup/HeadsetSetupDialog.tsx`
-- Test: `src/renderer/components/HeadsetSetup/__tests__/HeadsetSetupDialog.test.tsx`
-- Modify: `src/renderer/containers/AppShellContainer.tsx:1-21, 81-110`
-- Modify: `src/renderer/components/AppShell/DeviceChip.tsx` (whole component)
-- Modify: `src/renderer/components/AppShell/AppShell.tsx:9-48, 96`
-
-**Interfaces:**
-- Consumes:
- - `pairingStep`, `SetupScreen` (Task 2)
- - `DeviceActions.CancelSearch` (Task 1)
- - `HeadsetSetup` props (`HeadsetSetup.tsx:48-76`)
-- Produces:
- ```ts
- // AppShellContainer.tsx
- export interface HeadsetSetupApi {
- /** Opens pairing at "Which headset?" (or Connected); never starts a search. */
- openHeadsetSetup(): void;
- }
- export const HeadsetSetupContext: React.Context;
- // HeadsetSetupDialog.tsx
- export default function HeadsetSetupDialog(props: {
- open: boolean;
- onClose(): void;
- onDone(device: SetupDevice): void;
- }): JSX.Element;
- ```
- Task 5 adds `signalPrep` and `finishSignalPrep` to `HeadsetSetupApi`.
-
-- [ ] **Step 1: Write the failing test**
-
-```tsx
-// src/renderer/components/HeadsetSetup/__tests__/HeadsetSetupDialog.test.tsx
-import React from 'react';
-import { fireEvent, render, screen } from '@testing-library/react';
-import { describe, expect, it, vi } from 'vitest';
-import { DeviceActions } from '../../../actions';
-import {
- DEVICE_AVAILABILITY,
- DEVICES,
-} from '../../../constants/constants';
-import HeadsetSetupDialog from '../HeadsetSetupDialog';
-
-const store = vi.hoisted(() => ({
- dispatch: vi.fn(),
- state: {
- device: {
- availableDevices: [],
- availableLSLStreams: [],
- connectionStatus: 'NOT_YET_CONNECTED',
- deviceAvailability: 'NONE',
- deviceType: 'MUSE',
- },
- },
-}));
-vi.mock('react-redux', () => ({
- useDispatch: () => store.dispatch,
- useSelector: (select: (s: unknown) => unknown) => select(store.state),
-}));
-
-describe('HeadsetSetupDialog', () => {
- it('searches only when asked, and closing mid-search cancels the platform search', () => {
- const onClose = vi.fn();
- const ui = (
-
- );
- const { rerender } = render(ui);
-
- fireEvent.click(screen.getByRole('button', { name: 'Muse' }));
- fireEvent.click(screen.getByRole('button', { name: 'It’s on' }));
- expect(store.dispatch).not.toHaveBeenCalledWith(
- DeviceActions.SetDeviceAvailability(DEVICE_AVAILABILITY.SEARCHING)
- );
-
- fireEvent.click(screen.getByRole('button', { name: 'Find my headset' }));
- expect(store.dispatch).toHaveBeenCalledWith(
- DeviceActions.SetDeviceType(DEVICES.MUSE)
- );
- expect(store.dispatch).toHaveBeenCalledWith(
- DeviceActions.SetDeviceAvailability(DEVICE_AVAILABILITY.SEARCHING)
- );
-
- store.state.device.deviceAvailability = DEVICE_AVAILABILITY.SEARCHING;
- rerender(ui);
- expect(screen.getByText(/Looking for your Muse/)).toBeInTheDocument();
-
- fireEvent.click(screen.getByRole('button', { name: 'Close setup' }));
- expect(store.dispatch).toHaveBeenCalledWith(DeviceActions.CancelSearch());
- expect(onClose).toHaveBeenCalled();
- });
-});
-```
-
-- [ ] **Step 2: Run to verify it fails**
-
-Run: `npx vitest run src/renderer/components/HeadsetSetup/__tests__/HeadsetSetupDialog.test.tsx`
-Expected: FAIL. Cannot find module `../HeadsetSetupDialog`.
-
-- [ ] **Step 3: Implement the container**
-
-```tsx
-// src/renderer/components/HeadsetSetup/HeadsetSetupDialog.tsx
-import React, { useEffect, useState } from 'react';
-import { useDispatch, useSelector } from 'react-redux';
-import * as DialogPrimitive from '@radix-ui/react-dialog';
-import { Dialog, DialogOverlay, DialogPortal } from '../ui/dialog';
-import HeadsetSetup, { FoundHeadset, SetupDevice } from './HeadsetSetup';
-import { pairingStep, SetupScreen } from './pairingStep';
-import { DeviceActions } from '../../actions';
-import {
- CONNECTION_STATUS,
- DEVICE_AVAILABILITY,
- DEVICES,
-} from '../../constants/constants';
-import { RootState } from '../../store';
-
-const MODEL: Record, string> = {
- [DEVICES.MUSE]: 'Muse headset',
- [DEVICES.NEUROSITY]: 'Neurosity Crown',
- [DEVICES.FIXTURE]: 'Synthetic EEG replay',
-};
-
-const isWorn = (d?: SetupDevice) =>
- d === DEVICES.MUSE || d === DEVICES.NEUROSITY;
-
-interface Props {
- open: boolean;
- onClose(): void;
- /** "Check my signal" pressed on the Connected screen. */
- onDone(device: SetupDevice): void;
-}
-
-/**
- * Live pairing dialog around the approved `HeadsetSetup` view. The student's
- * own screens (choose → wear → ready) are local; once they press search,
- * Redux device state picks the screen via `pairingStep`. Closing while
- * searching or connecting cancels that attempt.
- */
-export default function HeadsetSetupDialog({ open, onClose, onDone }: Props) {
- const dispatch = useDispatch();
- const {
- availableDevices,
- availableLSLStreams,
- connectionStatus,
- deviceAvailability,
- deviceType,
- } = useSelector((s: RootState) => s.device);
- const [device, setDevice] = useState();
- const [screen, setScreen] = useState('choose');
- const [selectedId, setSelectedId] = useState();
- const [lslSearching, setLslSearching] = useState(false);
- const [showLSL, setShowLSL] = useState(false);
-
- useEffect(() => {
- window.electronAPI
- ?.isLSLAvailable?.()
- .then(setShowLSL)
- .catch(() => setShowLSL(false));
- }, []);
-
- useEffect(() => {
- if (open) {
- setScreen('choose');
- setSelectedId(undefined);
- }
- }, [open]);
-
- useEffect(() => setLslSearching(false), [availableLSLStreams]);
-
- const connected = connectionStatus === CONNECTION_STATUS.CONNECTED;
- const shownDevice = connected ? (deviceType as SetupDevice) : device;
- const isLSL = shownDevice === DEVICES.LSL;
- const found: FoundHeadset[] = isLSL
- ? availableLSLStreams
- .filter((s) => s.type === 'EEG')
- .map((s) => ({
- id: s.uid,
- name: s.name,
- model: `${s.channelCount} channels at ${s.sampleRate} Hz`,
- }))
- : availableDevices.map((d) => ({
- id: d.id,
- name: d.name ?? d.id,
- model: shownDevice ? MODEL[shownDevice as keyof typeof MODEL] : '',
- }));
- const step = pairingStep({
- screen,
- isLSL,
- availability: deviceAvailability,
- connectionStatus,
- lslSearching,
- foundCount: found.length,
- });
-
- /**
- * Starts discovery. Must stay synchronous: Web Bluetooth's requestDevice()
- * only runs inside the click that dispatched SEARCHING.
- */
- function find() {
- if (!device) return;
- setSelectedId(undefined);
- setScreen('discovery');
- if (device === DEVICES.LSL) {
- setLslSearching(true);
- dispatch(DeviceActions.DiscoverLSLStreams());
- return;
- }
- if (connectionStatus === CONNECTION_STATUS.DISCONNECTED) {
- dispatch(
- DeviceActions.SetConnectionStatus(CONNECTION_STATUS.NOT_YET_CONNECTED)
- );
- }
- dispatch(DeviceActions.SetDeviceType(device));
- dispatch(DeviceActions.SetDeviceAvailability(DEVICE_AVAILABILITY.SEARCHING));
- }
-
- function cancel() {
- if (step === 'searching' && !isLSL) dispatch(DeviceActions.CancelSearch());
- if (step === 'connecting') dispatch(DeviceActions.DisconnectFromDevice());
- setLslSearching(false);
- setScreen(isWorn(device) ? 'ready' : 'wear');
- }
-
- function close() {
- if (step === 'searching' || step === 'connecting') cancel();
- onClose();
- }
-
- function connect() {
- if (isLSL) {
- const stream = availableLSLStreams.find((s) => s.uid === selectedId);
- if (stream) dispatch(DeviceActions.ConnectToLSLStream(stream));
- return;
- }
- const target = availableDevices.find((d) => d.id === selectedId);
- if (target) dispatch(DeviceActions.ConnectToDevice(target));
- }
-
- return (
-
- );
-}
-```
-
-`Title` is required. Without it Radix logs a `console.error`, and `tests/electron-smoke.mjs` fails on any console error.
-
-- [ ] **Step 4: Run the test to verify it passes**
-
-Run: `npx vitest run src/renderer/components/HeadsetSetup/__tests__/HeadsetSetupDialog.test.tsx`
-Expected: 1 passed.
-
-- [ ] **Step 5: Make the chip optionally interactive**
-
-Replace the body of `DeviceChip` (`DeviceChip.tsx:14-40`):
-
-```tsx
-export default function DeviceChip({
- device,
- deviceName = 'Headset',
- onClick,
-}: {
- device: DeviceState;
- /** Shown when connected, e.g. `Muse 2`. */
- deviceName?: string;
- /** Opens headset setup. Omitted during a run, where the chip is status only. */
- onClick?(): void;
-}) {
- const [label, aria] = {
- none: ['No headset', 'Device: no headset connected'],
- connected: [
- `${deviceName} · Connected`,
- `Device: ${deviceName} connected, not recording`,
- ],
- fixture: ['Fixture', 'Device: fixture data, no headset'],
- }[device];
- const className =
- 'flex h-[32px] items-center gap-[8px] whitespace-nowrap rounded-full border border-[#e0e0e0] bg-white px-[12px] text-[13px] text-ink';
- const content = (
- <>
-
- {label}
- >
- );
- return onClick ? (
-
- ) : (
-
- {content}
-
- );
-}
-```
-
-Add `import { cn } from '../ui/utils';`. In `AppShell.tsx`:
-- Add to `AppShellProps`: `/** Opens headset setup from the chip; not offered during a run. */ onDeviceClick?(): void;`
-- Destructure it.
-- Pass it only to the header chip at line 96: ``.
-- Leave `RunBar` untouched.
-
-- [ ] **Step 6: Mount the dialog and context in `AppShellContainer`**
-
-After `RunProgressContext` (line 21):
-
-```tsx
-export interface HeadsetSetupApi {
- /** Opens pairing at "Which headset?" (or Connected); never starts a search. */
- openHeadsetSetup(): void;
-}
-
-/** Lets Collect and Explore open the one shell-owned headset setup dialog. */
-export const HeadsetSetupContext = createContext({
- openHeadsetSetup: () => undefined,
-});
-```
-
-In the component:
-- Add `const [setupOpen, setSetupOpen] = useState(false);` and `const openHeadsetSetup = () => setSetupOpen(true);`.
-- Pass `onDeviceClick={openHeadsetSetup}` to ``.
-- Wrap the children and mount the dialog:
-
-```tsx
-
-
- {children}
-
-
- setSetupOpen(false)}
- onDone={() => setSetupOpen(false)}
- />
-```
-
-Import `HeadsetSetupDialog from '../components/HeadsetSetup/HeadsetSetupDialog'`.
-
-- [ ] **Step 7: Typecheck the touched surface and commit**
-
-Run: `npx tsc --noEmit` → 0 errors. `npx vitest run src/renderer/components/HeadsetSetup src/renderer/components/AppShell` → pass.
-
-```bash
-git add src/renderer/components/HeadsetSetup src/renderer/components/AppShell src/renderer/containers/AppShellContainer.tsx
-git commit -m "feat(headset): shell-owned setup dialog; device chip opens it"
-```
-
----
-
-### Task 4: Collect and Explore open the new dialog; delete `ConnectModal`
-
-**Files:**
-- Modify: `src/renderer/components/CollectComponent/index.tsx:1-118`
-- Modify: `src/renderer/components/EEGExplorationComponent.tsx:16, 205-284`
-- Delete: `src/renderer/components/CollectComponent/ConnectModal.tsx`
-- Modify: `src/renderer/components/CollectComponent/__tests__/CollectModal.test.tsx`
-- Modify: whichever containers pass `availableLSLStreams` to Collect/Explore (find with `grep -rn availableLSLStreams src/renderer/containers`)
-
-**Interfaces:**
-- Consumes: `HeadsetSetupContext` / `openHeadsetSetup()` (Task 3).
-- Produces: Collect opens setup whenever EEG is on, no run is open, and the headset is neither connected nor connecting. It never dispatches `SetDeviceAvailability(SEARCHING)` itself.
-
-- [ ] **Step 1: Rewrite the Collect test to the new contract (it will fail)**
-
-Replace lines 1-115 of `CollectModal.test.tsx`. Keep `baseProps` (lines 28-59) as they are, except remove `availableLSLStreams`:
-
-```tsx
-import React from 'react';
-import { render, screen } from '@testing-library/react';
-import { describe, expect, it, vi, beforeEach } from 'vitest';
-import {
- CONNECTION_STATUS,
- DEVICE_AVAILABILITY,
- DEVICES,
-} from '../../../constants/constants';
-import { HeadsetSetupContext } from '../../../containers/AppShellContainer';
-import Collect, { Props as CollectProps } from '../index';
-
-const mockSetDeviceAvailability = vi.fn();
-const openHeadsetSetup = vi.fn();
-
-vi.mock('lab.js', () => ({}));
-vi.mock('../PreTestComponent', () => ({
- default: () =>