From 4ade4649765bb351d46df412ced8805ab299af67 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Mon, 28 Sep 2026 10:20:59 -0400 Subject: [PATCH] docs: wrap up the playtest-1 redesign round (TODOS, ROADMAP, user flow; drop implemented plans) --- ROADMAP.md | 19 +- TODOS.md | 32 +- .../plans/2026-09-23-ws2-headset-setup.md | 1301 ----------------- ...26-09-23-ws5-early-exit-incomplete-runs.md | 1121 -------------- .../2026-09-24-ws5b-participant-screens.md | 677 --------- docs/user-flow.md | 112 +- 6 files changed, 73 insertions(+), 3189 deletions(-) delete mode 100644 docs/superpowers/plans/2026-09-23-ws2-headset-setup.md delete mode 100644 docs/superpowers/plans/2026-09-23-ws5-early-exit-incomplete-runs.md delete mode 100644 docs/superpowers/plans/2026-09-24-ws5b-participant-screens.md 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/<condition>/` 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<id,{resolve,reject}>` + 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 `<input>` 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 <!-- Move finished items here with a date, then prune periodically. --> +- **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<EEGData>` 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<RootState['device']> = {}) { - const actions = new Subject<DeviceActionType>(); - const state = { - value: { - device: { - deviceType: DEVICES.MUSE, - deviceAvailability: DEVICE_AVAILABILITY.NONE, - connectionStatus: CONNECTION_STATUS.NOT_YET_CONNECTED, - availableDevices: [], - ...device, - }, - }, - } as unknown as StateObservable<RootState>; - 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<void, 'CANCEL_SEARCH'>('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<DeviceActionType, DeviceActionType, RootState> = ( - 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<DeviceActionType, DeviceActionType, RootState> = ( - 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<DeviceActionType, DeviceActionType, RootState> = ( - 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 | null, ObservableInput<any>>((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<HeadsetSetupApi>; - // 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 = ( - <HeadsetSetupDialog open onClose={onClose} onDone={vi.fn()} /> - ); - 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<Exclude<SetupDevice, DEVICES.LSL>, 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<SetupDevice>(); - const [screen, setScreen] = useState<SetupScreen>('choose'); - const [selectedId, setSelectedId] = useState<string>(); - 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 ( - <Dialog open={open} onOpenChange={(o) => !o && close()}> - <DialogPortal> - <DialogOverlay /> - <DialogPrimitive.Content - aria-describedby={undefined} - className="fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 focus:outline-none" - > - <DialogPrimitive.Title className="sr-only"> - Headset setup - </DialogPrimitive.Title> - <HeadsetSetup - step={step} - device={shownDevice} - found={found} - selectedId={selectedId} - showFixture={import.meta.env.DEV} - showLSL={showLSL} - onChooseDevice={(d) => { - setDevice(d); - setScreen('wear'); - }} - onBack={() => setScreen(screen === 'wear' ? 'choose' : 'wear')} - onContinue={() => setScreen('ready')} - onFindHeadset={find} - onCancel={cancel} - onSelectHeadset={(id) => - setSelectedId(selectedId === id ? undefined : id) - } - onConnect={connect} - onStartSoftwareSource={find} - onDone={() => shownDevice && onDone(shownDevice)} - onClose={close} - /> - </DialogPrimitive.Content> - </DialogPortal> - </Dialog> - ); -} -``` - -`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 = ( - <> - <span aria-hidden className={`h-[10px] w-[10px] ${GLYPH[device]}`} /> - <span>{label}</span> - </> - ); - return onClick ? ( - <button - type="button" - aria-label={`${aria}. Open headset setup`} - onClick={onClick} - className={cn( - className, - 'cursor-pointer hover:border-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2' - )} - > - {content} - </button> - ) : ( - <div role="status" aria-label={aria} className={className}> - {content} - </div> - ); -} -``` - -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: `<DeviceChip device={device} deviceName={deviceName} onClick={onDeviceClick} />`. -- 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<HeadsetSetupApi>({ - openHeadsetSetup: () => undefined, -}); -``` - -In the component: -- Add `const [setupOpen, setSetupOpen] = useState(false);` and `const openHeadsetSetup = () => setSetupOpen(true);`. -- Pass `onDeviceClick={openHeadsetSetup}` to `<AppShell>`. -- Wrap the children and mount the dialog: - -```tsx - <RunProgressContext.Provider value={setProgress}> - <HeadsetSetupContext.Provider value={{ openHeadsetSetup }}> - {children} - </HeadsetSetupContext.Provider> - </RunProgressContext.Provider> - <HeadsetSetupDialog - open={setupOpen} - onClose={() => 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: () => <div data-testid="pretest">PreTest</div>, -})); -vi.mock('../RunComponent', () => ({ - default: () => <div data-testid="run">Run</div>, -})); - -// baseProps: unchanged from the current file, minus availableLSLStreams - -const wrapper = ({ children }: { children: React.ReactNode }) => ( - <HeadsetSetupContext.Provider value={{ openHeadsetSetup }}> - {children} - </HeadsetSetupContext.Provider> -); -const renderCollect = (overrides: Partial<CollectProps> = {}) => - render( - <Collect {...(baseProps as unknown as CollectProps)} {...overrides} />, - { wrapper } - ); - -describe('Collect headset setup', () => { - beforeEach(() => vi.clearAllMocks()); - - it('opens headset setup on arrival without starting a Bluetooth search', () => { - renderCollect(); - - expect(openHeadsetSetup).toHaveBeenCalled(); - expect(mockSetDeviceAvailability).not.toHaveBeenCalledWith( - DEVICE_AVAILABILITY.SEARCHING - ); - }); - - it('does not open headset setup when EEG is disabled', () => { - renderCollect({ isEEGEnabled: false }); - - expect(openHeadsetSetup).not.toHaveBeenCalled(); - }); - - it('reopens headset setup when a connected headset drops', () => { - const { rerender } = renderCollect({ - connectionStatus: CONNECTION_STATUS.CONNECTED, - }); - expect(openHeadsetSetup).not.toHaveBeenCalled(); - - rerender( - <Collect - {...(baseProps as unknown as CollectProps)} - connectionStatus={CONNECTION_STATUS.NOT_YET_CONNECTED} - /> - ); - - expect(openHeadsetSetup).toHaveBeenCalled(); - expect(mockSetDeviceAvailability).not.toHaveBeenCalledWith( - DEVICE_AVAILABILITY.SEARCHING - ); - }); -}); -``` - -The old "closes the connect modal when CONNECTED" test is deleted. The dialog now stays open on its Connected screen until the student presses "Check my signal". `DEVICES` stays imported for `baseProps`. - -Run: `npx vitest run src/renderer/components/CollectComponent/__tests__/CollectModal.test.tsx` -Expected: FAIL. `openHeadsetSetup` is not called, and `SetDeviceAvailability(SEARCHING)` is still dispatched. - -- [ ] **Step 2: Rewire Collect** - -In `CollectComponent/index.tsx`: -- Delete `import ConnectModal`, the `isConnectModalOpen` state, the close-on-CONNECTED effect (lines 63-67), `handleStartConnect`, `handleConnectModalClose`, and the `<ConnectModal …/>` element. -- Delete `DEVICE_AVAILABILITY`, `DiscoveredStream`, and `availableLSLStreams` from imports and Props if they have no other users. -- Add `import React, { useContext, useEffect, useState } from 'react';` and `import { HeadsetSetupContext } from '../../containers/AppShellContainer';`. -- Replace the reprompt effect (lines 49-61) with: - -```tsx - const { openHeadsetSetup } = useContext(HeadsetSetupContext); - - useEffect(() => { - if ( - props.isEEGEnabled && - !isRunComponentOpen && - props.connectionStatus !== CONNECTION_STATUS.CONNECTED && - props.connectionStatus !== CONNECTION_STATUS.CONNECTING - ) { - openHeadsetSetup(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.connectionStatus, props.isEEGEnabled, isRunComponentOpen]); -``` - -The render returns just `<PreTestComponent …/>`. Drop the fragment. - -- [ ] **Step 3: Rewire Explore** - -In `EEGExplorationComponent.tsx`: -- Delete `import ConnectModal` (line 16), `isConnectModalOpen` + its effect (206-211), `handleStartConnect`, and the `<ConnectModal …/>` element (270-281). -- Add `const { openHeadsetSetup } = useContext(HeadsetSetupContext);`, then `<Button size="lg" onClick={openHeadsetSetup}>`. -- `handleStopConnect` becomes `props.DeviceActions.DisconnectFromDevice();` only. `Cleanup` already resets availability. -- Remove now-unused props (`availableLSLStreams`, `availableDevices`, `deviceAvailability`, `deviceType`) only if `tsc` shows no remaining use, and drop them from the Explore container's props mapping. - -- [ ] **Step 4: Delete `ConnectModal.tsx` and verify nothing references it** - -```bash -git rm src/renderer/components/CollectComponent/ConnectModal.tsx -grep -rn "ConnectModal" src # expected: no output -``` - -- [ ] **Step 5: Run the tests and typecheck** - -Run: `npx vitest run src/renderer/components/CollectComponent src/renderer/components/HeadsetSetup` → pass. `npx tsc --noEmit` → 0 errors. - -- [ ] **Step 6: Commit** - -```bash -git add -A src/renderer/components/CollectComponent src/renderer/components/EEGExplorationComponent.tsx src/renderer/containers -git commit -m "feat(headset): Collect and Explore use headset setup; remove ConnectModal and auto-scan" -``` - ---- - -### Task 5: Signal prep after "Check my signal" - -**Files:** -- Create: `src/renderer/components/HeadsetSetup/LiveSignalPrep.tsx` -- Modify: `src/renderer/containers/AppShellContainer.tsx` (context + `onDone`) -- Modify: `src/renderer/components/CollectComponent/index.tsx` (render branch) -- Modify: `src/renderer/components/EEGExplorationComponent.tsx` (connected branch) - -**Interfaces:** -- Consumes: `SignalPrep` props `{ device: DEVICES.MUSE | DEVICES.NEUROSITY; sensors: SensorReading[]; onContinue(): void }` (`SignalPrep.tsx:18-22`), `SignalQualityData.signalQuality: Record<string, SIGNAL_QUALITY>` (`constants/interfaces.ts:181-183`). -- Produces: `HeadsetSetupApi` gains: - ```ts - /** Worn headset just paired via "Check my signal"; hosts show SignalPrep until finished. */ - signalPrep: DEVICES.MUSE | DEVICES.NEUROSITY | null; - finishSignalPrep(): void; - ``` - -- [ ] **Step 1: Create `LiveSignalPrep`** - -```tsx -// src/renderer/components/HeadsetSetup/LiveSignalPrep.tsx -import React, { useEffect, useState } from 'react'; -import { Observable } from 'rxjs'; -import SignalPrep from './SignalPrep'; -import { DEVICES, SIGNAL_QUALITY } from '../../constants/constants'; -import { SignalQualityData } from '../../constants/interfaces'; - -interface Props { - device: DEVICES.MUSE | DEVICES.NEUROSITY; - observable: Observable<SignalQualityData> | null | undefined; - /** Connected device's channel names; missing readings show as disconnected. */ - channels: string[]; - onContinue(): void; -} - -/** `SignalPrep` fed by the live signal-quality stream. */ -export default function LiveSignalPrep({ - device, - observable, - channels, - onContinue, -}: Props) { - const [quality, setQuality] = useState<Record<string, SIGNAL_QUALITY>>({}); - - useEffect(() => { - const sub = observable?.subscribe((chunk) => - setQuality(chunk.signalQuality) - ); - return () => sub?.unsubscribe(); - }, [observable]); - - return ( - <SignalPrep - device={device} - sensors={channels.map((channel) => ({ - channel, - quality: quality[channel] ?? SIGNAL_QUALITY.DISCONNECTED, - }))} - onContinue={onContinue} - /> - ); -} -``` - -- [ ] **Step 2: Extend the context in `AppShellContainer`** - -- Add the two members to `HeadsetSetupApi`, with defaults `signalPrep: null, finishSignalPrep: () => undefined`. -- Add state and an effect: - -```tsx - const [signalPrep, setSignalPrep] = useState< - DEVICES.MUSE | DEVICES.NEUROSITY | null - >(null); - useEffect(() => { - if (device.connectionStatus !== CONNECTION_STATUS.CONNECTED) - setSignalPrep(null); - }, [device.connectionStatus]); -``` - -- Provider value: `{{ openHeadsetSetup, signalPrep, finishSignalPrep: () => setSignalPrep(null) }}`. -- Dialog `onDone`: - -```tsx - onDone={(d) => { - setSetupOpen(false); - if (d === DEVICES.MUSE || d === DEVICES.NEUROSITY) setSignalPrep(d); - }} -``` - -- [ ] **Step 3: Render prep in the hosts** - -Collect: destructure `signalPrep, finishSignalPrep` from the context. Return: - -```tsx - return signalPrep ? ( - <div className="flex h-full justify-center overflow-auto py-[40px]"> - <LiveSignalPrep - device={signalPrep} - observable={props.signalQualityObservable} - channels={props.connectedDevice?.channels ?? []} - onContinue={finishSignalPrep} - /> - </div> - ) : ( - <PreTestComponent …unchanged props… /> - ); -``` - -Explore: in the `connected ?` branch, render the same `LiveSignalPrep` wrapper when `signalPrep` is set; otherwise render `<ConnectedExplore …/>`. Use `props.signalQualityObservable` and `props.connectedDevice?.channels ?? []`. - -- [ ] **Step 4: Typecheck, run the touched tests, commit** - -Run: `npx tsc --noEmit` → 0 errors. `npx vitest run src/renderer/components/CollectComponent src/renderer/components/HeadsetSetup` → pass. - -```bash -git add src/renderer/components/HeadsetSetup/LiveSignalPrep.tsx src/renderer/containers/AppShellContainer.tsx src/renderer/components/CollectComponent/index.tsx src/renderer/components/EEGExplorationComponent.tsx -git commit -m "feat(headset): signal prep after pairing on Collect and Explore" -``` - ---- - -### Task 6: Integrated verification, hardware check, docs - -**Files:** -- Modify: `TODOS.md` (Playtest 1 P0 list) -- Modify: `.llms/learnings.md` (append by hand; do not run Prettier on it) - -- [ ] **Step 1: Full checks (once, here only)** - -Run: `npm run typecheck && npm run lint && npm test && node tests/electron-smoke.mjs` -Expected: 0 type errors, 0 lint errors, all tests pass, smoke PASS. The smoke fails on any console error, which catches a missing Radix `Title`. - -- [ ] **Step 2: Electron playtest with the Fixture (agent, via `skill://electron-playtest`)** - -Record a screenshot at each check: -1. Home → chip reads `No headset` and is a button. Click → "Which headset are you using?", with `Use fixture data` visible (dev). -2. `Use fixture data` → `Start fixture data` → "Is this your headset?" lists `Fixture (Synthetic EEG)`. Select → `Connect to …` → "… is connected / Nothing is being recorded". The chip reads `Fixture`. -3. Open a workspace → Collect while disconnected. The dialog opens on "Which headset?" and no "Looking for…" appears before a click. -4. Choose Muse → `It’s on` → `Find my headset` (no headset present). "Looking for your Muse…" stays past 10 s with no auto "couldn't find". Press ×. Reopen via the chip, find again: the search starts, with no "already in progress" error in the console. -5. Start a run from Collect. The RunBar chip is not clickable. -6. Check that the `wear` screen's cue bullets render. The global `li { list-style: none }` reset (learnings) may hide `list-disc` in `HeadsetSetup.tsx:294`. If hidden, report it with a screenshot. Do not restyle; it is a design follow-up. - -- [ ] **Step 3: Real Muse (human, required by §14 for Bluetooth claims)** - -Collect → Muse → wear → `Find my headset`: -- Found → select → Connect → Connected → `Check my signal` → signal prep with live per-sensor labels → `Continue` → pre-run screen. -- Headset off → search → Cancel → back on "Ready". Headset on → `Search again` finds it. -- Connect, then power the headset off. Collect reopens setup at "Which headset?" with no scan until a click. -- Leave the search running with the headset off for 2+ minutes. Note whether the platform ever ends it (→ "couldn't find") or it runs until Cancel. Both are acceptable; record which. - -- [ ] **Step 4: Docs** - -`TODOS.md`, under "Playtest 1 fixes (P0)": -- Strike "Collect: ConnectModal fails to appear on first navigation…" and "Device chip is display-only…" with `shipped YYYY-MM-DD (WS2, PR #N)`. - -Append to `.llms/learnings.md`: - -```markdown -## Headset setup: discovery is open-ended and gesture-bound - -`HeadsetSetupDialog` (mounted once in `AppShellContainer`, opened via -`HeadsetSetupContext`) replaced `ConnectModal`. Bluetooth search has no timer: -it ends on `DeviceFound`, a rejected/empty `scan()` (→ not found), or -`DeviceActions.CancelSearch` (→ driver `cancelScan()` → `bluetooth:cancelSearch` -rejects the pending `requestDevice()`). `SetDeviceAvailability(SEARCHING)` must -be dispatched synchronously in the click — `searchEpic` calls `scan()` inside -that dispatch, and Web Bluetooth rejects without the user gesture. Which screen -shows is `pairingStep()`; add states there, not in the view. -``` - -- [ ] **Step 5: Commit and PR** - -```bash -git add TODOS.md .llms/learnings.md -git commit -m "docs: WS2 headset setup learnings and TODOS" -gh pr create --title "feat(headset): WS2 headset setup integration" --body "Implements docs/superpowers/plans/2026-09-23-ws2-headset-setup.md. Verification: <paste Step 1 output, Step 2 screenshots, Step 3 hardware notes>." -``` diff --git a/docs/superpowers/plans/2026-09-23-ws5-early-exit-incomplete-runs.md b/docs/superpowers/plans/2026-09-23-ws5-early-exit-incomplete-runs.md deleted file mode 100644 index 274ff313..00000000 --- a/docs/superpowers/plans/2026-09-23-ws5-early-exit-incomplete-runs.md +++ /dev/null @@ -1,1121 +0,0 @@ -# WS5 Early Exit and Incomplete Runs 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:** Ending a run early preserves what was recorded as visibly incomplete data, whether the student presses the RunBar button or holds Escape. That data is excluded from Clean, Analyze and workflow badges, and it is never presented as a finished run. - -**Architecture:** -- **Abort is teardown.** Every runtime already ends its study when it unmounts. Runtimes gain one optional `onAbort(csv)` callback, which teardown calls with the trials recorded so far. Preview omits it, so stopping a preview records nothing. -- **One settle point.** `RunComponent` owns the single outcome of a run. Whichever reports first wins: `onFinish`, `onAbort`, or a fallback timer. -- **The outcome rides on `Stop`.** `Stop({ data, outcome })` reaches the stop epic, which closes the EEG stream and writes behavior. For an incomplete run it then renames both files to `*.incomplete.csv`. -- **Discovery is untouched.** Every existing discovery filter already skips that suffix. The discovery predicates move into one tested module in main. - -**Tech Stack:** React 18, Redux Toolkit, redux-observable 2 / RxJS 7, lab.js 23, jsPsych 8, Electron IPC (main/preload), Vitest. - -**Spec:** `docs/uxr/playtest_naive_1_design_implementation_plan.md` §1.2, §1.5, §8.4, §11 Workstream 5, §14. - -**Skills to read before starting:** -- `.claude/skills/electron-ipc-channel/SKILL.md` — Task 1 adds a channel. -- `.claude/skills/redux-observable-epochs/SKILL.md` — Task 2 edits the recording epic. -- `.claude/skills/electron-playtest/SKILL.md` — Task 5. - -## Scope - -In this plan (the §11 WS5 engineering bullets that need no new visuals): -- Separate normal-finish and abort callbacks. -- The visible early-exit control and hold-Escape both go through abort. Escape no longer finishes a run normally. -- One shared complete/incomplete outcome for behavior + EEG. -- The EEG stream is closed before either outcome is finalized. -- Incomplete runs are excluded from ordinary discovery, and their files are kept. -- Result-screen copy per §1.2 / §1.5. -- The run screen fits under the RunBar. - -Not in this plan: -- **Instruction/practice/main transition screens (§7.2).** - - Designed in PR #273 (`design/ws5-run-screens`; brief `docs/uxr/2026-09-23-ws5-design-brief.md`). - - Swapping its `participantScreens.ts` builders into each `experiment.ts` gets its own integration plan after approval. - - That integration must add `isEEGEnabled` to the params passed to lab.js, because the stillness line reads `this.parameters.isEEGEnabled`. -- **Reveal/delete UI for incomplete recordings.** This belongs to WS6, whose stories include "hidden incomplete data". This plan keeps the files on disk, reachable via Show in folder. -- **An explicit `mode: 'preview' | 'run'` prop.** Nothing consumes it yet. Preview stays distinct because it omits `onAbort` and uses a no-op marker callback. Add the prop when a runtime has to behave differently in Preview. -- **Progress contract (§7.3).** Shipped in #270. - -**Sequencing:** -- WS2 (#274) has merged. -- Execute after PR #267 (marker unification) merges. It edits the same runtime files, but only the `eventCallback` lines, which this plan does not touch. -- Code below uses post-#267 signatures: `JsPsychHostConfig` has no `registry`. -- If PR #273 is approved first, Task 4 uses its `RunResult` and `escapeHeld` RunBar prop (see Task 4, Step 4). - -## Global Constraints - -- "A deliberate click ends immediately; keyboard users can hold Escape briefly to invoke the same action." (§1.5) -- "Do not show a confirmation dialog or offer Resume." (§1.5) -- "Normal completion and early exit use separate runtime callbacks." (§1.5) -- "An early exit preserves collected behavior and EEG as visibly incomplete data." (§1.5) -- "Incomplete recordings are excluded from normal Clean and Analyze selection by default and may be revealed or deleted." (§1.5) -- "The result screen says `Experiment ended early`, never `Recording complete`." (§1.5) -- "After a completed EEG run, `Clean this recording` is primary and `Run another participant` is secondary… A behavior-only run recommends Analyze instead." (§1.2) -- "Close the EEG stream before finalizing either outcome." (§11 WS5) -- "Keep imported studies' internal presentation untouched." "Preserve marker timing and data collected before an early exit." (§11 WS5) -- The overwrite guard from #270 still holds: an incomplete session's number is never reused. -- Existing complete-run file names and contents are unchanged (§14). -- Comments go on definitions, not inside bodies (`.llms/learnings.md`). Mark deliberate corners with `ponytail:`. -- Subagents: skip formatters, project-wide lint, and the full suite. Run only the files named in each task. Task 5 runs everything once. - -## Review Focus - -1. **The teacher ends a run while an imported study is still loading, or while it sits on its "could not run" error.** No inner runtime exists to report, but the run bar must still clear. Tests: Task 3 (dispatcher reports) and Task 4 (fallback settles). -2. **The study finishes normally in the same moment someone ends it early.** Exactly one `Stop` goes out, as `complete`. Test: Task 4. -3. **A participant taps Escape** (common in keyboard tasks). A tap never ends the run; only a hold does. Test: Task 4. -4. **The next run for the same participant after an early exit.** It must not overwrite the incomplete files. Test: Task 1 (`recordingExists` is still true). -5. **Complete runs must be byte-for-byte what they were.** They keep their names, are still discovered, and are never renamed. Tests: Task 1 (discovery) and Task 2 (the complete run is not marked). - -## File Structure - -| File | Change | Responsibility | -|---|---|---| -| `src/main/recordings.ts` | create | session file paths, incomplete marking, discovery predicates | -| `src/main/__tests__/recordings.test.ts` | create | on-disk behavior | -| `src/main/index.ts` (`fs:readWorkspaceRawEEGData`, `fs:readWorkspaceBehaviorData`, `fs:recordingExists`) | modify | use predicates; add `fs:markRecordingIncomplete` | -| `src/preload/index.ts`, `src/renderer/types/electron.d.ts`, `src/renderer/utils/filesystem/storage.ts` | modify | bridge + wrapper for the new channel | -| `src/renderer/actions/experimentActions.ts` | modify | `RunOutcome`, `Stop` payload | -| `src/renderer/epics/experimentEpics.ts` (`startEpic`, `experimentStopEpic`) | modify | remember + close the stream; finalize by outcome | -| `src/renderer/epics/__tests__/experimentEpics.test.ts` | modify | finalize order | -| `src/renderer/components/CollectComponent/PreTestComponent.tsx` (Mousetrap import + `esc` effect) | modify | delete the stray `Mousetrap` `esc` → `Stop` | -| `src/renderer/components/ExperimentRuntime.tsx` | modify | `onAbort` in the contract; the dispatcher reports when no inner runtime exists | -| `src/renderer/components/LabjsExperimentWindow.tsx` | modify | finished/aborting routing; drop Escape → end | -| `src/renderer/utils/jspsych/host.ts` | modify | finished/aborting routing | -| `src/renderer/components/ImportedExperimentWindow.tsx` | modify | a failed host reports abort on teardown | -| `src/renderer/components/__tests__/ExperimentRuntime.test.tsx`, `src/renderer/utils/jspsych/__tests__/host.test.ts` | modify | abort contract | -| `src/renderer/components/CollectComponent/RunComponent.tsx` | modify | settle-once, end early, hold-Escape, result panels, `h-full` root | -| `src/renderer/components/CollectComponent/__tests__/RunComponent.test.tsx` | create | early-exit behavior | -| `src/renderer/containers/AppShellContainer.tsx` | modify | `EndRunContext`; route the RunBar button | -| `src/renderer/components/AppShell/RunBar.tsx` (`onEndRun` docstring) | modify | docstring (no confirm) | - -## Dispatch waves (subagent-driven) - -Worktree: `git worktree add .worktrees/ws5-early-exit -b feat/ws5-early-exit origin/main` (after #267 lands). - -| Wave | Tasks | Why | -|---|---|---| -| 1 | Task 1 ∥ Task 3 | Main/IPC vs runtime files; disjoint | -| 2 | Task 2 | Needs the `markRecordingIncomplete` wrapper (T1) | -| 3 | Task 4 | Needs the `Stop` outcome (T2) and `onAbort` (T3) | -| 4 | Task 5 | Integration verification | - ---- - -### Task 1: Incomplete recordings on disk (main + IPC) - -**Files:** -- Create: `src/main/recordings.ts` -- Test: `src/main/__tests__/recordings.test.ts` -- Modify: `src/main/index.ts` — the raw/behavior discovery filters and the `fs:recordingExists` handler -- Modify: `src/preload/index.ts` (after `recordingExists`), `src/renderer/types/electron.d.ts` (after `recordingExists`), `src/renderer/utils/filesystem/storage.ts` (after `storeBehavioralData`) - -**Interfaces:** -- Produces (main): - - `isRawEEGFile(file)` - - `isBehaviorFile(file)` - - `recordingExists(workspaceDir, subject, group, session): boolean` - - `markRecordingIncomplete(workspaceDir, subject, group, session): void` -- Produces (renderer): `markRecordingIncomplete(title: string, subject: string, group: string, session: number): Promise<void>`, exported from `utils/filesystem/storage.ts`. - -- [ ] **Step 1: Write the failing test** - -```ts -// src/main/__tests__/recordings.test.ts -import fs from 'fs'; -import os from 'os'; -import path from 'path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { - isBehaviorFile, - isRawEEGFile, - markRecordingIncomplete, - recordingExists, -} from '../recordings'; - -let dir: string; -beforeEach(() => { - dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bw-recordings-')); -}); -afterEach(() => fs.rmSync(dir, { recursive: true, force: true })); - -const write = (rel: string) => { - const file = path.join(dir, rel); - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, 'x'); -}; -const csvFiles = () => - (fs.readdirSync(dir, { recursive: true }) as string[]).filter((f) => - f.endsWith('.csv') - ); - -describe('recordings', () => { - it('complete recordings are discovered', () => { - write('Data/P1/Behavior/P1-A-1-behavior.csv'); - write('Data/P1/EEG/P1-A-1-raw.csv'); - - expect(csvFiles().filter(isRawEEGFile)).toHaveLength(1); - expect(csvFiles().filter(isBehaviorFile)).toHaveLength(1); - }); - - it('an ended-early run keeps its files and its session, but drops out of discovery', () => { - write('Data/P1/Behavior/P1-A-1-behavior.csv'); - write('Data/P1/EEG/P1-A-1-raw.csv'); - - markRecordingIncomplete(dir, 'P1', 'A', 1); - - expect(csvFiles()).toHaveLength(2); - expect(csvFiles().filter(isRawEEGFile)).toEqual([]); - expect(csvFiles().filter(isBehaviorFile)).toEqual([]); - expect(recordingExists(dir, 'P1', 'A', 1)).toBe(true); - expect(recordingExists(dir, 'P1', 'A', 2)).toBe(false); - }); - - it('marks a behavior-only run that has no EEG file', () => { - write('Data/P1/Behavior/P1-A-1-behavior.csv'); - - markRecordingIncomplete(dir, 'P1', 'A', 1); - - expect(csvFiles().filter(isBehaviorFile)).toEqual([]); - expect(recordingExists(dir, 'P1', 'A', 1)).toBe(true); - }); -}); -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `npx vitest run src/main/__tests__/recordings.test.ts` -Expected: FAIL — cannot find module `../recordings`. - -- [ ] **Step 3: Implement `recordings.ts`** - -```ts -// src/main/recordings.ts -import fs from 'fs'; -import path from 'path'; - -/** Suffix for ended-early runs. Neither discovery predicate below matches it. */ -const INCOMPLETE = '.incomplete.csv'; - -const sessionFiles = ( - workspaceDir: string, - subject: string, - group: string, - session: number -) => { - const dir = path.join(workspaceDir, 'Data', subject); - const stem = `${subject}-${group}-${session}`; - return [ - path.join(dir, 'Behavior', `${stem}-behavior.csv`), - path.join(dir, 'EEG', `${stem}-raw.csv`), - ]; -}; - -const incomplete = (file: string) => file.replace(/\.csv$/, INCOMPLETE); - -/** Raw EEG recordings Clean and the workflow badges may offer; never incomplete ones. */ -export const isRawEEGFile = (file: string) => file.endsWith('raw.csv'); - -/** Behavior files Analyze and the workflow badges may offer; never incomplete ones. */ -export const isBehaviorFile = (file: string) => file.endsWith('behavior.csv'); - -/** True when any artifact of this session exists, complete or ended early. */ -export const recordingExists = ( - workspaceDir: string, - subject: string, - group: string, - session: number -) => - sessionFiles(workspaceDir, subject, group, session).some( - (file) => fs.existsSync(file) || fs.existsSync(incomplete(file)) - ); - -/** Renames the session's behavior and raw EEG files to `*.incomplete.csv`. Nothing is deleted. */ -export const markRecordingIncomplete = ( - workspaceDir: string, - subject: string, - group: string, - session: number -) => { - for (const file of sessionFiles(workspaceDir, subject, group, session)) { - if (fs.existsSync(file)) fs.renameSync(file, incomplete(file)); - } -}; -``` - -- [ ] **Step 4: Run to verify it passes** - -Run: `npx vitest run src/main/__tests__/recordings.test.ts` -Expected: 3 passed. - -- [ ] **Step 5: Use it in main and add the channel** - -In `src/main/index.ts`: -- Import: `import { isBehaviorFile, isRawEEGFile, markRecordingIncomplete, recordingExists } from './recordings';` -- In `fs:readWorkspaceRawEEGData`: `.filter((filepath) => filepath.slice(-7).includes('raw.csv'))` → `.filter(isRawEEGFile)` -- In `fs:readWorkspaceBehaviorData`: `.filter((filepath) => filepath.slice(-12).includes('behavior.csv'))` → `.filter(isBehaviorFile)` -- Replace the `fs:recordingExists` handler (with its docstring) with: - -```ts -/** True when any artifact of a subject/group/session run is on disk, including ended-early ones. */ -ipcMain.handle( - 'fs:recordingExists', - (_event, title, subject, group, session) => - recordingExists(getWorkspaceDir(title), subject, group, session) -); - -/** Hides an ended-early run from Clean, Analyze and badges; the files stay on disk. */ -ipcMain.handle( - 'fs:markRecordingIncomplete', - (_event, title, subject, group, session) => - markRecordingIncomplete(getWorkspaceDir(title), subject, group, session) -); -``` - -Preload (`src/preload/index.ts`, after `recordingExists`): - -```ts - markRecordingIncomplete: ( - title: string, - subject: string, - group: string, - session: number - ): Promise<void> => - ipcRenderer.invoke( - 'fs:markRecordingIncomplete', - title, - subject, - group, - session - ), -``` - -Types (`src/renderer/types/electron.d.ts`, after `recordingExists`): - -```ts - markRecordingIncomplete: ( - title: string, - subject: string, - group: string, - session: number - ) => Promise<void>; -``` - -Wrapper (`storage.ts`, after `storeBehavioralData`): - -```ts -/** Marks a session ended early: its files leave ordinary Clean/Analyze discovery. */ -export const markRecordingIncomplete = ( - title: string, - subject: string, - group: string, - session: number -): Promise<void> => - api().markRecordingIncomplete(title, subject, group, session); -``` - -- [ ] **Step 6: Typecheck and commit** - -Run: `npx tsc --noEmit` → 0 errors. - -```bash -git add src/main/recordings.ts src/main/__tests__/recordings.test.ts src/main/index.ts src/preload/index.ts src/renderer/types/electron.d.ts src/renderer/utils/filesystem/storage.ts -git commit -m "feat(recordings): mark ended-early runs incomplete, excluded from discovery" -``` - ---- - -### Task 2: `Stop` carries the outcome; the stop epic finalizes in order - -**Files:** -- Modify: `src/renderer/actions/experimentActions.ts` (`Stop`) -- Modify: `src/renderer/epics/experimentEpics.ts` (imports, `startEpic`, `experimentStopEpic`) -- Modify: `src/renderer/epics/__tests__/experimentEpics.test.ts` -- Modify: `src/renderer/components/CollectComponent/PreTestComponent.tsx` (Mousetrap import + `esc` effect) -- Modify (compile-only; Task 4 does the final wiring): `RunComponent.tsx` `onFinish`, `AppShellContainer.tsx` `onEndRun` - -**Interfaces:** -- Consumes: `markRecordingIncomplete` (Task 1), `closeEEGStream(streamId)` (`utils/filesystem/write.ts`). -- Produces: - ```ts - /** How a recorded run ended; an incomplete run is kept but hidden from Clean/Analyze. */ - export type RunOutcome = 'complete' | 'incomplete'; - Stop: createAction<{ data: string; outcome: RunOutcome }, 'STOP'>('STOP') - ``` -- The stop epic runs, in order: - 1. Close the run's EEG stream, if any. - 2. Write behavior, if `data` is non-empty. - 3. If `outcome === 'incomplete'`, mark the session incomplete. - 4. Emit `SetIsRunning(false)`. - - Extra `Stop`s that arrive while finalizing are ignored. - -- [ ] **Step 1: Write the failing tests** - -Changes to `experimentEpics.test.ts`: -- Add `markRecordingIncomplete: vi.fn().mockResolvedValue(undefined)` to the existing `vi.mock('../../utils/filesystem/storage', …)` factory. -- Make `storeBehavioralData: vi.fn().mockResolvedValue(undefined)`. -- Then append: - -```ts -import experimentEpics from '../experimentEpics'; -import type { EEGData } from '../../constants/interfaces'; -import { - markRecordingIncomplete, - storeBehavioralData, -} from '../../utils/filesystem/storage'; -import { - closeEEGStream, - createEEGWriteStream, - writeEEGData, -} from '../../utils/filesystem/write'; - -vi.mock('../../utils/filesystem/write', () => ({ - createEEGWriteStream: vi.fn(), - writeHeader: vi.fn(), - writeEEGData: vi.fn(), - writeEEGEvents: vi.fn().mockResolvedValue(undefined), - closeEEGStream: vi.fn(), -})); -vi.mock('../../utils/eeg/markerRegistry', () => ({ - resolveMarkerRegistry: () => ({ codeToLabel: {} }), -})); - -type Mutable = { experiment: Record<string, unknown>; device: Record<string, unknown> }; - -const recording = (raw?: Subject<EEGData>) => { - const s = rootState('My_Custom') as unknown as Mutable; - s.experiment.subject = 'P1'; - s.experiment.group = 'A'; - if (raw) { - s.device.connectionStatus = CONNECTION_STATUS.CONNECTED; - s.device.rawObservable = raw; - s.device.connectedDevice = { name: 'Fixture', samplingRate: 256, channels: ['AF7'] }; - } - return s; -}; - -const runThenStop = async ( - s: Mutable, - outcome: 'complete' | 'incomplete', - afterStop?: () => void -) => { - const actions = new Subject<ExperimentActionType>(); - const state = { value: s } as unknown as import('redux-observable').StateObservable<RootState>; - const out: ExperimentActionType[] = []; - const sub = experimentEpics(actions, state, undefined).subscribe((a) => out.push(a)); - actions.next(ExperimentActions.Start()); - await vi.waitFor(() => expect(out).toContainEqual(ExperimentActions.SetIsRunning(true))); - s.experiment.isRunning = true; - actions.next(ExperimentActions.Stop({ data: 'csv', outcome })); - afterStop?.(); - await vi.waitFor(() => expect(out).toContainEqual(ExperimentActions.SetIsRunning(false))); - sub.unsubscribe(); -}; - -describe('experiment stop', () => { - afterEach(() => vi.clearAllMocks()); - - it('closes the EEG file, then saves behavior, then marks an ended-early run incomplete', async () => { - const calls: string[] = []; - vi.mocked(createEEGWriteStream).mockResolvedValue('stream-1'); - vi.mocked(closeEEGStream).mockImplementation(async (id) => { - calls.push(`close:${id}`); - }); - vi.mocked(storeBehavioralData).mockImplementation(async () => { - calls.push('behavior'); - }); - vi.mocked(markRecordingIncomplete).mockImplementation(async () => { - calls.push('incomplete'); - }); - const raw = new Subject<EEGData>(); - - await runThenStop(recording(raw), 'incomplete', () => - raw.next({ timestamp: 1, data: [1] } as EEGData) - ); - - expect(calls).toEqual(['close:stream-1', 'behavior', 'incomplete']); - expect(writeEEGData).not.toHaveBeenCalled(); - }); - - it('never marks a completed run', async () => { - await runThenStop(recording(), 'complete'); - - expect(storeBehavioralData).toHaveBeenCalledWith('csv', 'My_Custom', 'P1', 'A', 1); - expect(markRecordingIncomplete).not.toHaveBeenCalled(); - }); -}); -``` - -- [ ] **Step 2: Run to verify they fail** - -Run: `npx vitest run src/renderer/epics/__tests__/experimentEpics.test.ts` -Expected: FAIL — TS/runtime error on `outcome` / `markRecordingIncomplete`, and `closeEEGStream` is never called. - -- [ ] **Step 3: Change the action** - -In `experimentActions.ts`: - -```ts -/** How a recorded run ended; an incomplete run is kept but hidden from Clean/Analyze. */ -export type RunOutcome = 'complete' | 'incomplete'; -``` - -and `Stop: createAction<{ data: string; outcome: RunOutcome }, 'STOP'>('STOP'),`. - -- [ ] **Step 4: Remember and close the stream; finalize by outcome** - -In `experimentEpics.ts`: -- Import `exhaustMap` from `rxjs/operators`, `closeEEGStream` from `../utils/filesystem/write`, `markRecordingIncomplete` from `../utils/filesystem/storage`, and `toast` from `react-toastify`. -- Above `startEpic`: - -```ts -/** The open raw-EEG write stream of the current run; closed by the stop epic. */ -let activeEEGStream: string | null = null; -``` - -- In `startEpic`, make `activeEEGStream = null;` the first line inside `mergeMap(async () => {`, and set `activeEEGStream = streamId;` right after the `if (!streamId) return true;` guard. - -Replace `experimentStopEpic` with: - -```ts -/** - * Finalizes a run exactly once: closes the EEG stream (the raw subscription - * already ended on Stop), writes behavior, and for an ended-early run renames - * both files so Clean and Analyze skip them. Stops arriving meanwhile are ignored. - */ -const experimentStopEpic: Epic< - ExperimentActionType, - ExperimentActionType, - RootState -> = (action$, state$) => - action$.pipe( - filter(isActionOf(ExperimentActions.Stop)), - filter(() => state$.value.experiment.isRunning), - exhaustMap(async ({ payload: { data, outcome } }) => { - const { title, subject, group, session } = state$.value.experiment; - const streamId = activeEEGStream; - activeEEGStream = null; - try { - if (streamId) await closeEEGStream(streamId); - if (title) { - if (data) await storeBehavioralData(data, title, subject, group, session); - if (outcome === 'incomplete') - await markRecordingIncomplete(title, subject, group, session); - } - } catch (error) { - toast.error(`Couldn't finish saving this run: ${(error as Error).message}`); - } - return ExperimentActions.SetIsRunning(false); - }) - ); -``` - -- [ ] **Step 5: Remove the stray `Stop` binding and keep callers compiling** - -- `PreTestComponent.tsx`: delete the `Mousetrap` import and the `useEffect` that binds `esc` to `props.ExperimentActions.Stop`. It dispatched `Stop` with a KeyboardEvent payload, on a screen that is never running. -- `RunComponent.tsx` `onFinish`: `ExperimentActions.Stop({ data: csv, outcome: 'complete' });` (Task 4 replaces this). -- `AppShellContainer.tsx`: `onEndRun={() => dispatch(ExperimentActions.Stop({ data: '', outcome: 'incomplete' }))}` (Task 4 routes this). -- Confirm no other caller uses the old signature: write `{"action":"references","file":"src/renderer/actions/experimentActions.ts","line":<Stop line>,"symbol":"Stop"}` to `xd://lsp`. Expected: only the files above plus `experimentEpics.ts`. - -- [ ] **Step 6: Run tests, typecheck, commit** - -Run: `npx vitest run src/renderer/epics/__tests__/experimentEpics.test.ts` → pass. `npx tsc --noEmit` → 0 errors. - -```bash -git add src/renderer/actions/experimentActions.ts src/renderer/epics src/renderer/components/CollectComponent/PreTestComponent.tsx src/renderer/components/CollectComponent/RunComponent.tsx src/renderer/containers/AppShellContainer.tsx -git commit -m "feat(run): Stop carries outcome; close EEG stream and mark incomplete runs" -``` - ---- - -### Task 3: Runtimes report an early teardown through `onAbort` - -**Files:** -- Modify: `src/renderer/components/ExperimentRuntime.tsx` (`ExperimentRuntimeProps`, dispatcher) -- Modify: `src/renderer/components/LabjsExperimentWindow.tsx` (`'end'` handler, Escape keydown handler, cleanup) -- Modify: `src/renderer/utils/jspsych/host.ts` (`JsPsychHostConfig`, `createJsPsychHost`) -- Modify: `src/renderer/components/ImportedExperimentWindow.tsx` (host creation effect) -- Test: `src/renderer/components/__tests__/ExperimentRuntime.test.tsx`, `src/renderer/utils/jspsych/__tests__/host.test.ts` - -**Interfaces:** -- Produces, on `ExperimentRuntimeProps`: - ```ts - /** - * Called once if the runtime is torn down before the study finishes, with the - * trials recorded so far ('' if none). Omitted by Preview, which records nothing. - */ - onAbort?: (csv: string) => void; - ``` -- Rules: - - A runtime calls exactly one of `onFinish` / `onAbort` per mount. - - A teardown after `onFinish` reports nothing. - - Escape no longer ends a lab.js study; Task 4 owns hold-Escape. - -- [ ] **Step 1: Write the failing tests** - -`host.test.ts`: -- In the existing test "runs a real timeline end to end and reports the CSV", add `const onAbort = vi.fn();` and pass `onAbort` in the config. -- In that test, after `host!.teardown();`, add `expect(onAbort).not.toHaveBeenCalled();`. -- Add: - -```ts - it('teardown mid-run reports the trials so far through onAbort, never onFinish', async () => { - document.body.innerHTML = '<div id="host"></div>'; - const onFinish = vi.fn(); - let host: { teardown: () => void } | undefined; - const aborted = new Promise<string>((resolve) => { - host = createJsPsychHost( - ` - const jsPsych = initJsPsych({}); - jsPsych.run([ - { type: jsPsychCallFunction, func: () => {}, data: { condition: 'Face' } }, - { type: jsPsychHtmlKeyboardResponse, stimulus: 'waiting', data: { condition: 'House' } }, - ]); - `, - { - hostElementId: 'host', - mapping, - eventCallback: vi.fn(), - onFinish, - onAbort: resolve, - } - ); - }); - await new Promise((resolve) => setTimeout(resolve, 20)); - host!.teardown(); - - expect(await aborted).toContain('1,Face,'); - expect(onFinish).not.toHaveBeenCalled(); - }); -``` - -`ExperimentRuntime.test.tsx` (`importedParams()` already defaults to `kind: 'jspsych'`), add: - -```ts - it('reports an abort itself when torn down before an imported study loads', () => { - readImportedExperimentFile.mockReturnValue(new Promise(() => undefined)); - const onAbort = vi.fn(); - const { unmount } = render( - <ExperimentRuntime - {...baseProps} - onAbort={onAbort} - type={EXPERIMENTS.IMPORTED} - experimentObject={{} as never} - params={importedParams()} - /> - ); - - unmount(); - - expect(onAbort).toHaveBeenCalledWith(''); - }); - - it('leaves abort reporting to the inner runtime once it is mounted', async () => { - readImportedExperimentFile.mockResolvedValue('const jsPsych = initJsPsych({});'); - const onAbort = vi.fn(); - const { unmount } = render( - <ExperimentRuntime - {...baseProps} - onAbort={onAbort} - type={EXPERIMENTS.IMPORTED} - experimentObject={{} as never} - params={importedParams()} - /> - ); - await screen.findByTestId('jspsych'); - - unmount(); - - expect(onAbort).not.toHaveBeenCalled(); - }); -``` - -- [ ] **Step 2: Run to verify they fail** - -Run: `npx vitest run src/renderer/utils/jspsych/__tests__/host.test.ts src/renderer/components/__tests__/ExperimentRuntime.test.tsx` -Expected: FAIL: -- the jsPsych abort on teardown calls `onFinish`; -- the dispatcher never calls `onAbort`; -- `onAbort` is not in the types. - -- [ ] **Step 3: Contract + dispatcher** - -In `ExperimentRuntime.tsx`, add `onAbort` (with the docstring above) to `ExperimentRuntimeProps` after `onFinish`. Import `useRef`. Inside `ExperimentRuntime`, after the existing `useEffect`: - -```tsx - const waitingRef = useRef(false); - waitingRef.current = Boolean(imported) && !resolved; - const onAbortRef = useRef(runtime.onAbort); - onAbortRef.current = runtime.onAbort; - useEffect( - () => () => { - if (waitingRef.current) onAbortRef.current?.(''); - }, - [] - ); -``` - -Extend the component docstring: "While an imported study is loading or failed, no inner runtime exists, so the dispatcher itself reports `onAbort('')` on teardown." - -- [ ] **Step 4: lab.js window** - -In `LabjsExperimentWindow.tsx`, destructure `onAbort`. Replace the `experimentToRun.on('end', …)` handler with: - -```ts - let finished = false; - let aborting = false; - const partialCsv = () => { - try { - return experimentToRun.global.datastore.exportCsv(); - } catch { - return ''; - } - }; - experimentToRun.on('end', () => { - finished = true; - if (aborting) onAbort?.(partialCsv()); - else onFinish(experimentToRun.global.datastore.exportCsv()); - }); -``` - -Delete the `experimentToRun.options.events.keydown` Escape handler entirely. Replace the effect cleanup with: - -```ts - return () => { - try { - experimentToRun.internals.controller.audioContext.close(); - } catch { - // No controller before the study prepares; nothing to close. - } - if (finished) return; - aborting = true; - Promise.resolve() - .then(() => experimentToRun.end()) - .catch(() => onAbort?.('')); - }; -``` - -Add `onAbort` to the effect's dependency array. Add a docstring on the component: "Normal end → `onFinish(csv)`; unmount before the end → lab.js `end()` → `onAbort(partial csv)`. Escape is not handled here — see RunComponent's hold-Escape." - -- [ ] **Step 5: jsPsych host** - -In `host.ts`, add to `JsPsychHostConfig`: - -```ts - /** Teardown before the timeline finished; receives the trials so far. */ - onAbort?: (csv: string) => void; -``` - -In `createJsPsychHost`, after `let instance…`: - -```ts - let finished = false; - let aborting = false; - const route = (csv: string) => { - finished = true; - if (aborting) config.onAbort?.(csv); - else config.onFinish(csv); - }; -``` - -In the `initJsPsych` wrapper, call `buildJsPsychOptions({ ...config, onFinish: route, getInstance: () => instance, authorOptions })`. In `teardown`, replace the existing try/catch around `abortExperiment` with: - -```ts - if (!finished) { - aborting = true; - try { - instance?.abortExperiment?.(); - } catch { - config.onAbort?.(''); - } - } -``` - -This is why `route` sends to `onAbort`: jsPsych 8's `abortExperiment` resolves `timeline.run()`, and `run()` then calls `on_finish` with the data so far (`node_modules/jspsych/src/JsPsych.ts:150-151, 199-203`). - -- [ ] **Step 6: Imported window failure path** - -In `ImportedExperimentWindow.tsx`: -- Destructure `onAbort`. -- Pass `onAbort` into the `createJsPsychHost` config. -- Change the catch to: - -```tsx - } catch (failure) { - setError((failure as Error).message); - return () => onAbort?.(''); - } -``` - -- Add `onAbort` to the dependency array. - -- [ ] **Step 7: Run tests, typecheck, commit** - -Run: `npx vitest run src/renderer/utils/jspsych src/renderer/components/__tests__/ExperimentRuntime.test.tsx src/renderer/components/__tests__/ImportedExperimentWindow.test.tsx` → pass. `npx tsc --noEmit` → 0 errors. - -```bash -git add src/renderer/components/ExperimentRuntime.tsx src/renderer/components/LabjsExperimentWindow.tsx src/renderer/components/ImportedExperimentWindow.tsx src/renderer/utils/jspsych/host.ts src/renderer/components/__tests__/ExperimentRuntime.test.tsx src/renderer/utils/jspsych/__tests__/host.test.ts -git commit -m "feat(runtime): separate abort callback; Escape no longer ends lab.js studies" -``` - ---- - -### Task 4: End early from the RunBar or by holding Escape; honest result screens - -**Files:** -- Modify: `src/renderer/components/CollectComponent/RunComponent.tsx` -- Test: `src/renderer/components/CollectComponent/__tests__/RunComponent.test.tsx` (create) -- Modify: `src/renderer/containers/AppShellContainer.tsx` (context + `onEndRun`) -- Modify: `src/renderer/components/AppShell/RunBar.tsx` (`onEndRun` docstring) - -**Interfaces:** -- Consumes: `Stop({ data, outcome })` (Task 2), `onAbort` (Task 3). -- Produces: - ```ts - // AppShellContainer.tsx - /** Lets the running Collect screen receive the RunBar's "End experiment early". */ - export const EndRunContext: React.Context<(end: (() => void) | null) => void>; - ``` -- `RunComponent` dispatches exactly one `Stop` per run. - -- [ ] **Step 1: Write the failing test** - -```tsx -// src/renderer/components/CollectComponent/__tests__/RunComponent.test.tsx -import React from 'react'; -import { act, fireEvent, render, screen } from '@testing-library/react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { CONNECTION_STATUS, EXPERIMENTS } from '../../../constants/constants'; -import { EndRunContext } from '../../../containers/AppShellContainer'; -import Run from '../RunComponent'; - -const runtime = vi.hoisted(() => ({ - props: null as null | { onFinish(csv: string): void; onAbort?(csv: string): void }, -})); - -vi.mock('lab.js', () => ({})); -vi.mock('../../ExperimentRuntime', async () => { - const { useEffect } = await import('react'); - return { - ExperimentRuntime: (props: { onFinish(csv: string): void; onAbort?(csv: string): void }) => { - runtime.props = props; - useEffect(() => () => props.onAbort?.('partial'), [props]); - return <div data-testid="runtime" />; - }, - }; -}); -vi.mock('../../../utils/labjs/functions', () => ({ - getExperimentFromType: () => ({ text: { protocol: {} } }), -})); -vi.mock('../../InputCollect', () => ({ default: () => null })); - -const Stop = vi.fn(); -const props = { - type: EXPERIMENTS.N170, - title: 'Study', - isRunning: true, - params: {} as never, - subject: 'P1', - experimentObject: {} as never, - group: 'A', - session: 1, - isEEGEnabled: true, - connectionStatus: CONNECTION_STATUS.CONNECTED, - ExperimentActions: { Stop, Start: vi.fn(), SetSession: vi.fn() } as never, -}; - -let endRun: (() => void) | null = null; -const wrapper = ({ children }: { children: React.ReactNode }) => ( - <EndRunContext.Provider value={(end) => { endRun = end; }}> - {children} - </EndRunContext.Provider> -); - -describe('ending a run early', () => { - afterEach(() => { - vi.useRealTimers(); - vi.clearAllMocks(); - endRun = null; - }); - - it('keeps the partial run as incomplete and says it ended early', () => { - const { rerender } = render(<Run {...props} />, { wrapper }); - - act(() => endRun!()); - expect(Stop).toHaveBeenCalledWith({ data: 'partial', outcome: 'incomplete' }); - - rerender(<Run {...props} isRunning={false} />); - expect(screen.getByRole('heading', { name: 'Experiment ended early' })).toBeInTheDocument(); - expect(screen.queryByText(/Recording complete/)).toBeNull(); - }); - - it('a tap of Escape never ends the run; holding it does', () => { - vi.useFakeTimers(); - render(<Run {...props} />, { wrapper }); - - fireEvent.keyDown(window, { key: 'Escape' }); - act(() => vi.advanceTimersByTime(400)); - fireEvent.keyUp(window, { key: 'Escape' }); - act(() => vi.advanceTimersByTime(2000)); - expect(Stop).not.toHaveBeenCalled(); - - fireEvent.keyDown(window, { key: 'Escape' }); - act(() => vi.advanceTimersByTime(1000)); - expect(Stop).toHaveBeenCalledWith({ data: 'partial', outcome: 'incomplete' }); - }); - - it('a run that finishes as it is ended early is recorded once, as complete', () => { - render(<Run {...props} />, { wrapper }); - - act(() => runtime.props!.onFinish('full')); - act(() => endRun!()); - - expect(Stop).toHaveBeenCalledTimes(1); - expect(Stop).toHaveBeenCalledWith({ data: 'full', outcome: 'complete' }); - }); -}); -``` - -If `RunComponent` still imports modules that touch Electron at import time (e.g. `utils/eeg`, `lslBridge`), add `vi.mock(<path>, () => ({ emitMarker: vi.fn() }))` and similar for each. The component under test must stay real. - -- [ ] **Step 2: Run to verify it fails** - -Run: `npx vitest run src/renderer/components/CollectComponent/__tests__/RunComponent.test.tsx` -Expected: FAIL: -- `EndRunContext` is not exported; -- no `onAbort` is wired; -- Escape does nothing. - -- [ ] **Step 3: Shell routing** - -In `AppShellContainer.tsx`, add `useCallback, useRef` to the React import (if not already present). After the other contexts: - -```tsx -/** Lets the running Collect screen receive the RunBar's "End experiment early". */ -export const EndRunContext = createContext<(end: (() => void) | null) => void>( - () => undefined -); -``` - -In the component: - -```tsx - const endRun = useRef<(() => void) | null>(null); - const registerEndRun = useCallback((end: (() => void) | null) => { - endRun.current = end; - }, []); -``` - -`onEndRun={() => endRun.current ? endRun.current() : dispatch(ExperimentActions.Stop({ data: '', outcome: 'incomplete' }))}`. The fallback only fires when no Collect screen is mounted; it keeps the RunBar from getting stuck. Wrap `{children}` in `<EndRunContext.Provider value={registerEndRun}>`. - -`RunBar.tsx`, `onEndRun` docstring → `/** Ends the run immediately — no confirm (plan §1.5); data so far is kept as incomplete. */` - -- [ ] **Step 4: RunComponent** - -Module level, above `Run`: - -```tsx -/** How long Escape must be held to end a run early; a tap never ends it. */ -const END_EARLY_HOLD_MS = 1000; -/** ponytail: a runtime that never reports on teardown gets this long, then the run ends with no behavior data. */ -const ABORT_FALLBACK_MS = 3000; -``` - -Imports: `useRef` from React; `EndRunContext` next to `RunProgressContext`; `RunOutcome` from `../../actions/experimentActions`. - -Replace the `hasFinished` state and the `onFinish` / `handleRunAgain` callbacks with: - -```tsx - const [outcome, setOutcome] = useState<RunOutcome | null>(null); - const [ending, setEnding] = useState(false); - const settled = useRef(false); - const registerEndRun = useContext(EndRunContext); - - const settle = useCallback( - (csv: string, result: RunOutcome) => { - if (settled.current) return; - settled.current = true; - ExperimentActions.Stop({ data: csv, outcome: result }); - setOutcome(result); - }, - [ExperimentActions] - ); - const onFinish = useCallback((csv: string) => settle(csv, 'complete'), [settle]); - const onAbort = useCallback((csv: string) => settle(csv, 'incomplete'), [settle]); - const endEarly = useCallback(() => setEnding(true), []); - - useEffect(() => { - if (!isRunning) return undefined; - registerEndRun(endEarly); - return () => registerEndRun(null); - }, [isRunning, endEarly, registerEndRun]); - - useEffect(() => { - if (!isRunning) return undefined; - let hold: ReturnType<typeof setTimeout> | undefined; - const down = (e: KeyboardEvent) => { - if (e.key === 'Escape' && !e.repeat) hold = setTimeout(endEarly, END_EARLY_HOLD_MS); - }; - const up = (e: KeyboardEvent) => { - if (e.key === 'Escape') clearTimeout(hold); - }; - window.addEventListener('keydown', down, true); - window.addEventListener('keyup', up, true); - return () => { - clearTimeout(hold); - window.removeEventListener('keydown', down, true); - window.removeEventListener('keyup', up, true); - }; - }, [isRunning, endEarly]); - - useEffect(() => { - if (!ending) return undefined; - const fallback = setTimeout(() => onAbort(''), ABORT_FALLBACK_MS); - return () => clearTimeout(fallback); - }, [ending, onAbort]); - - const handleRunAgain = useCallback(() => setOutcome(null), []); - const handleRunAnother = useCallback(() => { - setOutcome(null); - setIsInputCollectOpen(true); - }, []); -``` - -Change the existing `isRunning` effect (the one that calls `setGate('off')`) so it resets each run: - -```tsx - useEffect(() => { - if (!isRunning) return; - setGate('off'); - setEnding(false); - settled.current = false; - }, [isRunning]); -``` - -Render changes: -- Root: `h-screen` → `h-full`. The run screen sits inside the AppShell content area under the 64px RunBar, so `h-screen` pushes the bottom of every participant screen below the fold. #271 fixed the same bug in `PreTestComponent`. -- Replace every `!hasFinished` with `!outcome`. -- The runtime renders only while not ending: `{isRunning && !ending && (<div …><ExperimentRuntime … onFinish={onFinish} onAbort={onAbort} onProgress={reportProgress} /></div>)}`. -- Add `{isRunning && ending && (<p className="flex h-full items-center justify-center text-ink-muted">Saving what was recorded…</p>)}`. -- Replace the `hasFinished` completion panel with: - -```tsx - {!isRunning && outcome === 'incomplete' && ( - <div className="flex flex-col items-center justify-center h-full text-center gap-4"> - <h1 className="m-0">Experiment ended early</h1> - <p className="text-gray-600"> - What <b>{subject}</b> recorded is saved but marked incomplete, so it - won't appear in Clean or Analyze. - </p> - <Button onClick={handleRunAgain}>Run again</Button> - </div> - )} - - {!isRunning && outcome === 'complete' && ( - <div className="flex flex-col items-center justify-center h-full text-center gap-4"> - <h1 className="m-0">Recording complete 🎉</h1> - <p className="text-gray-600"> - Saved <b>{subject}</b>'s data. - </p> - <div className="flex gap-3 mt-2"> - <Button asChild> - {isEEGEnabled ? ( - <Link to={SCREENS.CLEAN.route}>Clean this recording →</Link> - ) : ( - <Link to={SCREENS.ANALYZE.route}>Analyze results →</Link> - )} - </Button> - <Button variant="secondary" onClick={handleRunAnother}> - Run another participant - </Button> - </div> - </div> - )} -``` - -**If PR #273 is approved before this task runs:** -- Render its pure-props `RunResult` (`outcome` / `modality` / `subject` + these callbacks) instead of the two inline panels and the "Saving what was recorded…" paragraph. The test keeps asserting the `Experiment ended early` heading either way. -- Wire its `escapeHeld` RunBar prop the way `RunProgressContext` works: - - `AppShellContainer` owns `const [escapeHeld, setEscapeHeld] = useState(false)`. - - It exports `EscapeHeldContext = createContext<(held: boolean) => void>(() => undefined)` and provides `setEscapeHeld`. - - It passes `escapeHeld` through a new optional `AppShell` prop to `RunBar`. - - In the hold-Escape effect: `setEscapeHeld(true)` when the hold timer starts; `setEscapeHeld(false)` on keyup, when the timer fires, and in the cleanup. - -Without #273, the inline panels are interim copy on the current layout. - -- [ ] **Step 5: Run tests, typecheck, commit** - -Run: `npx vitest run src/renderer/components/CollectComponent` → pass. `npx tsc --noEmit` → 0 errors. - -```bash -git add src/renderer/components/CollectComponent src/renderer/containers/AppShellContainer.tsx src/renderer/components/AppShell -git commit -m "feat(run): end early via RunBar or held Escape; ended-early result screen" -``` - ---- - -### Task 5: Integrated verification and docs - -**Files:** -- Modify: `TODOS.md` -- Modify: `.llms/learnings.md` (append by hand; no Prettier) - -- [ ] **Step 1: Full checks (once)** - -Run: `npm run typecheck && npm run lint && npm test && node tests/electron-smoke.mjs` -Expected: 0 errors, all tests pass, smoke PASS. - -- [ ] **Step 2: Electron playtest, Fixture headset (agent, `skill://electron-playtest`)** - -For each check, take a screenshot and list `Data/<subject>/` after each run: -1. **Faces/Houses complete run.** - - The result says "Recording complete 🎉", with `Clean this recording →` / `Run another participant`. - - Files are `P-A-1-behavior.csv` and `P-A-1-raw.csv`. - - The raw file's last line is a complete row (the stream was closed). - - The participant screen's bottom (the Space prompt) is visible without scrolling at 1366×768. -2. **Same participant; press `End experiment early` mid-practice.** - - The run stops immediately, with no dialog, and "Experiment ended early" appears. - - Files are `…-behavior.incomplete.csv` (header + rows so far) and `…-raw.incomplete.csv`. - - The Collect badge count is unchanged, and Clean's dataset list does not show the run. -3. **`Run again`.** The #270 prompt offers session 2, and session 1's incomplete files are byte-identical afterwards. -4. **Escape.** Tap it during a trial: the run continues. Hold it ~1 s: it ends early, same as check 2. -5. **Imported jsPsych study (any bundled sample).** End it early; `.incomplete.csv` contains the trials completed so far. -6. **Behavior-only workspace (EEG off).** - - A complete run recommends `Analyze results →`. - - End early: only `…-behavior.incomplete.csv` is written. -7. **Preview on Collect and Design, then `Stop preview`.** No files are written. - -- [ ] **Step 3: Docs** - -`TODOS.md`: -- Under the Playtest 1 list, note that WS5 early exit shipped (`YYYY-MM-DD, PR #N`). -- Add under Next: "WS5 participant screens (#273): integrate `participantScreens.ts` builders into each built-in `experiment.ts`; pass `isEEGEnabled` to lab.js params." - -Append to `.llms/learnings.md`: - -```markdown -## Early exit = runtime teardown; incomplete = `*.incomplete.csv` - -Runtimes report exactly one of `onFinish(csv)` / `onAbort(csv)` per mount. -Unmounting a running runtime is the abort: lab.js `end()` and jsPsych -`abortExperiment()` both fire their normal end hooks (jsPsych calls -`on_finish` after an abort), so each runtime routes on an `aborting` flag. -`RunComponent` settles once (runtime report, or a 3 s fallback) and -dispatches `Stop({ data, outcome })`; the stop epic closes the EEG stream, -writes behavior, then `fs:markRecordingIncomplete` renames both files to -`*.incomplete.csv`. Every discovery filter (`src/main/recordings.ts`) and the -workflow badges skip that suffix; `recordingExists` still counts it so the -session number is never reused. Before this, "End experiment early" wrote the -partial run as a normal file and `closeEEGStream` was never called. -``` - -- [ ] **Step 4: Commit and PR** - -```bash -git add TODOS.md .llms/learnings.md -git commit -m "docs: WS5 early exit learnings and TODOS" -gh pr create --title "feat(run): WS5 early exit and incomplete runs" --body "Implements docs/superpowers/plans/2026-09-23-ws5-early-exit-incomplete-runs.md. Verification: <Step 1 output, Step 2 screenshots + file listings>." -``` diff --git a/docs/superpowers/plans/2026-09-24-ws5b-participant-screens.md b/docs/superpowers/plans/2026-09-24-ws5b-participant-screens.md deleted file mode 100644 index 721efc70..00000000 --- a/docs/superpowers/plans/2026-09-24-ws5b-participant-screens.md +++ /dev/null @@ -1,677 +0,0 @@ -# WS5b Participant Screens Integration 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:** Every BrainWaves-owned lab.js experiment shows the approved participant screens (#273) instead of its hand-written instruction, practice→main and end screens. The keys a participant is shown always match the keys the trials accept. - -**Architecture:** -- **Built-ins** (Faces/Houses, Stroop, Visual Search, Multitasking) get a `screens.ts` next to `experiment.ts` holding their `InstructionsScreenParams`. `experiment.ts` sets `content: instructionsScreen(instructions)` / `transitionScreen(instructions)` / `endScreen()`. -- **Custom**'s keys and condition names are the teacher's settings, so its two screens are built at `before:prepare` from `this.parameters`. This is the same pattern `initLoopWithStimuli` uses. Setting an option in a hook still gets lab.js's `${…}` templating: the options proxy parses on set once armed, and `arm()` parses everything otherwise (`node_modules/lab.js/dist/es2022/base/util/options.js:84-112`). -- The stillness line reads `this.parameters.isEEGEnabled`. `LabjsExperimentWindow` sets it from a new `isEEGEnabled` runtime prop, which `RunComponent` passes. -- Story fixtures move to the real sources, so Storybook shows what runs. - -**Tech Stack:** lab.js 23, React 18, TypeScript, Vitest (+ real lab.js under jsdom, per #275's `LabjsExperimentWindow.test.tsx` shims). - -**Spec:** -- `docs/uxr/playtest_naive_1_design_implementation_plan.md` §7.1–7.4 and §11 WS5. -- Approved design: PR #273 (`src/renderer/experiments/shared/participantScreens.ts`, `src/renderer/components/ParticipantScreens/`, brief `docs/uxr/2026-09-23-ws5-design-brief.md`). - -**Prerequisite:** PR #275 (WS5 early exit) — merged 2026-09-24 (`0025f8e`). Task 3 edits `LabjsExperimentWindow.tsx`, `ExperimentRuntime.tsx` and `RunComponent.tsx` as #275 left them (including its final lifecycle refactor `c5c2788`; `<ExperimentRuntime>` still renders in `RunComponent.tsx`), and extends #275's `LabjsExperimentWindow.test.tsx`. - -## Global Constraints - -- "Preserve the content and layout of imported Lab.js and jsPsych studies." (§7.1) Only the five BrainWaves-owned studies change. `imported/` is untouched. -- "Show response mappings before practice and again before the recorded task." (§7.2) -- "Accuracy and response-speed language must come from the experiment protocol." (§7.4) The pacing lines come from each protocol, exactly as approved in #273. -- The stillness line appears only when EEG is on (brief). -- Keep every screen's `responses` (`keypress(Space)`, `keypress(q)` → `skipPractice`, `next`, `end`), `hooks`, `title` and position unchanged. Only `content` changes, so lab.js flow, skip-practice and progress (`utils/labjs/progress.ts`) behave exactly as before. -- Multitasking: only its Intro and End screens change. Its multi-page Instructions screen and canvas block screens stay (out of scope in #273). -- Marker emission, trial screens and `taskHelp` footers are untouched. -- Obsolete copy is deleted, not parked (§14): the built-ins' `params.intro` text, and the old screen HTML. -- Comments go on definitions, not inside bodies. No one-expression wrapper functions (fewer than 3 call sites and not an exported domain name). -- Subagents: skip formatters, project-wide lint and the full suite. Run only the files named. Task 4 runs everything once. - -## Review Focus - -1. **A participant is shown a key the task doesn't accept, or the reverse.** The approved fixtures were hand-copied from the protocols. Test: Task 1 contract test, which compares the keys shown on the screen that actually runs with the keys its trials accept. -2. **Custom with 1, 3 or 4 conditions, a condition with a folder but no key, and empty slots.** Test: Task 2. -3. **A teacher's intro containing `${`** must not be evaluated as a template. It is inserted as the `${this.parameters.intro}` placeholder (a single interpolation, as today), never pasted into the template source. Test: Task 2. -4. **Behavior-only run** must not show the stillness line; an EEG run must. Test: Task 3 (real lab.js). -5. **Existing workspaces** still carry the old `intro` in `appState.json`. That is harmless: `experimentObject` is always rebuilt from the type (`HomeScreen.tsx`, `experimentEpics.ts`) and built-in screens no longer read `intro`. No test needed; noted for the reviewer. - -## File Structure - -| File | Change | Responsibility | -|---|---|---| -| `src/renderer/experiments/{faces_houses,stroop,search,multitasking}/screens.ts` | create | each built-in's participant-screen params | -| `src/renderer/experiments/{faces_houses,stroop,search,multitasking}/experiment.ts` | modify | Instruction / Main task / End `content` → builders | -| `src/renderer/experiments/{faces_houses,stroop,search,multitasking}/params.ts` | modify | delete obsolete `intro` | -| `src/renderer/constants/interfaces.ts` | modify | `intro?: string` | -| `src/renderer/experiments/__tests__/participantScreens.test.ts` | create | shown keys == accepted keys, built-ins | -| `src/renderer/utils/labjs/customStimuli.ts` | modify | `customResponseRules`, `customInstructionsScreen`, `customTransitionScreen` | -| `src/renderer/utils/labjs/__tests__/customScreens.test.ts` | create | custom screens behavior | -| `src/renderer/experiments/custom/experiment.ts` | modify | hooks build Instruction / Main task; End → `endScreen()` | -| `src/renderer/components/ExperimentRuntime.tsx`, `LabjsExperimentWindow.tsx`, `CollectComponent/RunComponent.tsx` | modify | `isEEGEnabled` → lab.js parameters | -| `src/renderer/components/__tests__/LabjsExperimentWindow.test.tsx` | modify | stillness line on/off, real lab.js | -| `src/renderer/components/ParticipantScreens/{fixtures.ts,ParticipantScreens.stories.tsx}` | modify | stories import the real sources | - -## Dispatch waves - -Worktree: `git worktree add .worktrees/ws5b-screens -b feat/ws5b-participant-screens origin/main` (after #275). - -| Wave | Tasks | Why | -|---|---|---| -| 1 | Task 1 ∥ Task 3 | built-in experiments + stories vs runtime files; disjoint | -| 2 | Task 2 | edits the same stories/fixtures files as Task 1 | -| 3 | Task 4 | integration verification | - ---- - -### Task 1: Built-in experiments show the approved screens - -**Files:** -- Create: `src/renderer/experiments/faces_houses/screens.ts`, `stroop/screens.ts`, `search/screens.ts`, `multitasking/screens.ts` -- Modify: the four `experiment.ts`, the four `params.ts`, `src/renderer/constants/interfaces.ts` -- Modify: `src/renderer/components/ParticipantScreens/fixtures.ts`, `ParticipantScreens.stories.tsx` -- Test: `src/renderer/experiments/__tests__/participantScreens.test.ts` - -**Interfaces:** -- Consumes: `instructionsScreen`, `transitionScreen`, `endScreen`, `keycap`, `stimulusExamples`, `InstructionsScreenParams` from `src/renderer/experiments/shared/participantScreens.ts`. -- Produces: `export const instructions: InstructionsScreenParams` in each built-in's `screens.ts`. `transitionScreen(instructions)` is valid, because `TransitionScreenParams` is `{ rules, pacing }` and a variable carries no excess-property check. - -- [ ] **Step 1: Write the failing contract test** - -```ts -// src/renderer/experiments/__tests__/participantScreens.test.ts -import { describe, expect, it, vi } from 'vitest'; -import { facesHousesExperiment } from '../faces_houses/experiment'; -import { params as facesHousesParams } from '../faces_houses/params'; -import { stroopExperiment } from '../stroop/experiment'; -import { searchExperimentObject } from '../search/experiment'; -import { multitaskingExperimentObject } from '../multitasking/experiment'; - -vi.mock('lab.js', () => ({})); - -type Node = { title?: string; content?: unknown; responses?: Record<string, string> }; - -/** Every non-Space, non-skip key any screen in the study responds to. */ -const acceptedKeys = (node: unknown, out = new Set<string>()): Set<string> => { - if (Array.isArray(node)) node.forEach((child) => acceptedKeys(child, out)); - else if (node && typeof node === 'object') { - for (const key of Object.keys((node as Node).responses ?? {})) { - const match = /^key(?:press|down)\((.+)\)$/.exec(key); - if (match && match[1] !== 'Space' && match[1] !== 'q') out.add(match[1].toLowerCase()); - } - Object.values(node).forEach((child) => acceptedKeys(child, out)); - } - return out; -}; - -/** The `content` of the first screen with this title. */ -const screenContent = (node: unknown, title: string): string | undefined => { - if (Array.isArray(node)) { - for (const child of node) { - const found = screenContent(child, title); - if (found) return found; - } - } else if (node && typeof node === 'object') { - if ((node as Node).title === title && typeof (node as Node).content === 'string') - return (node as Node).content as string; - for (const child of Object.values(node)) { - const found = screenContent(child, title); - if (found) return found; - } - } - return undefined; -}; - -/** Response keycaps on a participant screen (Space and the Q skip hint excluded). */ -const shownKeys = (html: string) => - new Set( - [...html.matchAll(/<kbd class="bw-participant-key">([^<]+)<\/kbd>/g)] - .map(([, key]) => key.toLowerCase()) - .filter((key) => key !== 'q') - ); - -const stimulusKeys = (stimuli: Array<{ response?: string }> = []) => - stimuli.map(({ response }) => response ?? '').filter(Boolean); - -describe.each([ - ['Faces/Houses', facesHousesExperiment, 'Instruction', 'Main task', stimulusKeys(facesHousesParams.stimuli)], - ['Stroop', stroopExperiment, 'Instruction', 'Main task', []], - ['Visual Search', searchExperimentObject, 'Instruction', 'Main task instruction', []], - ['Multitasking', multitaskingExperimentObject, 'Intro', undefined, []], -])('%s participant screens', (_, study, instructionTitle, transitionTitle, dynamicKeys) => { - const accepted = new Set([...acceptedKeys(study), ...dynamicKeys]); - - it('show exactly the keys the trials accept before practice', () => { - const html = screenContent(study, instructionTitle as string) ?? ''; - expect(shownKeys(html)).toEqual(accepted); - }); - - it.runIf(transitionTitle)('show the same keys again before the recorded task', () => { - const html = screenContent(study, transitionTitle as string) ?? ''; - expect(shownKeys(html)).toEqual(accepted); - }); -}); -``` - -Faces/Houses trials get their keys at runtime from each stimulus's `response` (`initResponseHandlers`), so the test adds `params.stimuli` responses. If `stroop/experiment.ts` does not export `stroopExperiment` under that name, import whatever `stroop/index.ts` imports as `experimentObject`. - -- [ ] **Step 2: Run to verify it fails** - -Run: `npx vitest run src/renderer/experiments/__tests__/participantScreens.test.ts` -Expected: FAIL. The current screens contain no `bw-participant-key` keycaps, so every shown set is empty. - -- [ ] **Step 3: Create the four `screens.ts`** - -Move each object out of `components/ParticipantScreens/fixtures.ts` verbatim. The approved copy is unchanged: - -```ts -// src/renderer/experiments/faces_houses/screens.ts -import type { InstructionsScreenParams } from '../shared/participantScreens'; - -/** What participants are told before practice and reminded of before the recorded trials. */ -export const instructions: InstructionsScreenParams = { - title: 'Faces and houses', - summary: - 'You will see a series of face and house images. Press the right key when an image appears', - rules: [ - { - keys: [ - { key: '1', meaning: 'Face' }, - { key: '9', meaning: 'House' }, - ], - }, - ], - pacing: - "This isn't a speed test. Take about 1–1.5 seconds per picture and answer carefully.", - canSkipPractice: true, -}; -``` - -```ts -// src/renderer/experiments/stroop/screens.ts -import { - keycap, - stimulusExamples, - type InstructionsScreenParams, -} from '../shared/participantScreens'; - -/** What participants are told before practice and reminded of before the recorded trials. */ -export const instructions: InstructionsScreenParams = { - title: 'Stroop task', - summary: - 'You will see color words printed in colored ink. Press the key for the ink color, not the word.', - example: stimulusExamples([ - { - stimulus: '<span style="color: red">green</span>', - label: 'Red ink', - detail: `The word says “green”. Press ${keycap('r')}`, - }, - { - stimulus: '<span style="color: blue">yellow</span>', - label: 'Blue ink', - detail: `The word says “yellow”. Press ${keycap('b')}`, - }, - ]), - rules: [ - { - keys: [ - { key: 'r', meaning: 'Red' }, - { key: 'g', meaning: 'Green' }, - { key: 'b', meaning: 'Blue' }, - { key: 'y', meaning: 'Yellow' }, - ], - }, - ], - pacing: 'Answer quickly, and as accurately as you can.', - canSkipPractice: true, -}; -``` - -```ts -// src/renderer/experiments/search/screens.ts -import { stimulusExamples, type InstructionsScreenParams } from '../shared/participantScreens'; - -/** A search-task letter with the same `letter` class and inline styles the task's run hook sets. */ -const searchLetter = (style: string) => - `<span class="letter" style="${style}">T</span>`; - -/** What participants are told before practice and reminded of before the recorded trials. */ -export const instructions: InstructionsScreenParams = { - title: 'Visual search', - summary: - 'Look for the right-side-up orange T. Ignore upside-down orange Ts and blue Ts.', - example: stimulusExamples([ - { stimulus: searchLetter('color: orange'), label: 'Find this', detail: 'Orange T, right side up' }, - { stimulus: searchLetter('color: orange; transform: rotate(-180deg)'), label: 'Ignore', detail: 'Upside-down orange T' }, - { stimulus: searchLetter('color: lightblue'), label: 'Ignore', detail: 'Blue T' }, - ]), - rules: [ - { - keys: [ - { key: 'b', meaning: 'Orange T is there' }, - { key: 'n', meaning: 'No orange T' }, - ], - }, - ], - pacing: - 'Speed counts here: find the orange T as quickly as you can, without guessing.', - canSkipPractice: true, -}; -``` - -```ts -// src/renderer/experiments/multitasking/screens.ts -import type { InstructionsScreenParams } from '../shared/participantScreens'; - -/** - * The intro screen. Space continues to Multitasking's own instruction screens - * (skip-practice lives there), so no Q hint. - */ -export const instructions: InstructionsScreenParams = { - title: 'Multitasking', - summary: - 'You will see a shape with dots inside. Where it appears tells you which rule to follow. The next screens explain each rule with examples.', - rules: [ - { - when: 'Shape on top: answer the shape', - keys: [ - { key: 'b', meaning: 'Diamond' }, - { key: 'n', meaning: 'Rectangle' }, - ], - }, - { - when: 'Shape on the bottom: count the dots', - keys: [ - { key: 'b', meaning: '2 dots' }, - { key: 'n', meaning: '3 dots' }, - ], - }, - ], - pacing: 'Speed counts here: answer as fast as you can without making errors.', - start: 'see the instructions', -}; -``` - -- [ ] **Step 4: Swap the screen contents** - -In each built-in `experiment.ts`, add: - -```ts -import { - endScreen, - instructionsScreen, - transitionScreen, -} from '../shared/participantScreens'; -import { instructions } from './screens'; -``` - -Then replace only the `content:` value of these screens. Leave every other property as it is: - -| Experiment | `title: 'Instruction'` / `'Intro'` | transition screen | `title: 'End'` | -|---|---|---|---| -| faces_houses | `instructionsScreen(instructions)` | `'Main task'` → `transitionScreen(instructions)` | `endScreen()` | -| stroop | `instructionsScreen(instructions)` | `'Main task'` → `transitionScreen(instructions)` | `title: 'Thanks'` → `endScreen()` | -| search | `instructionsScreen(instructions)` | `'Main task instruction'` → `transitionScreen(instructions)` | `endScreen()` | -| multitasking | `'Intro'` → `instructionsScreen(instructions)` | — (its `Instructions` and block screens stay) | `endScreen()` | - -Search's old Instruction content defined `.letter` in a `<style>` block. That styling now comes from `.bw-participant-example-stimulus .letter` in `app.global.css`. The trial screens keep their own `<style>` blocks. - -Use `import { instructions, instructions as … }`, or read the exports, to confirm the exported study names match (`facesHousesExperiment`, `searchExperimentObject`, `multitaskingExperimentObject`, and Stroop's export). Adjust the test imports to match; don't rename the exports. - -- [ ] **Step 5: Delete the obsolete intro copy** - -- Delete the `intro:` entry from `faces_houses/params.ts`, `stroop/params.ts`, `search/params.ts` and `multitasking/params.ts`. It still contains "Press the the space bar", and nothing reads it once the screens are swapped. -- In `constants/interfaces.ts`, change `intro: string;` to `/** Teacher-written intro for custom experiments; built-ins' screens carry their own copy. */ intro?: string;`. -- `grep -rn "parameters.intro\|params.intro" src/renderer` should now list only `custom/`, `CustomDesignComponent.tsx` and the ParticipantScreens custom stories. - -- [ ] **Step 6: Point the stories at the real sources** - -In `ParticipantScreens/fixtures.ts`, delete `searchLetter`, `FACES_HOUSES`, `STROOP`, `VISUAL_SEARCH` and `MULTITASKING`, plus any imports left unused. Keep the Custom fixtures; Task 2 converts them. In `ParticipantScreens.stories.tsx`: - -```ts -import { instructions as FACES_HOUSES } from '../../experiments/faces_houses/screens'; -import { instructions as STROOP } from '../../experiments/stroop/screens'; -import { instructions as VISUAL_SEARCH } from '../../experiments/search/screens'; -import { instructions as MULTITASKING } from '../../experiments/multitasking/screens'; -``` - -The Transition story becomes `content: transitionScreen(FACES_HOUSES)`. - -- [ ] **Step 7: Run, typecheck, commit** - -Run: `npx vitest run src/renderer/experiments/__tests__/participantScreens.test.ts` → 7 passed (4 instruction + 3 transition). `npx tsc --noEmit` → 0 errors. - -```bash -git add src/renderer/experiments src/renderer/constants/interfaces.ts src/renderer/components/ParticipantScreens -git commit -m "feat(experiments): built-in studies show the approved participant screens" -``` - ---- - -### Task 2: Custom experiments build their screens from the teacher's conditions - -**Files:** -- Modify: `src/renderer/utils/labjs/customStimuli.ts` -- Modify: `src/renderer/experiments/custom/experiment.ts` (Instruction, Main task, End screens) -- Modify: `src/renderer/components/ParticipantScreens/fixtures.ts`, `ParticipantScreens.stories.tsx` (Custom stories) -- Test: `src/renderer/utils/labjs/__tests__/customScreens.test.ts` - -**Interfaces:** -- Consumes: `CONDITION_SLOTS`, `conditionTitle`, the private `isActiveSlot` (same file); `instructionsScreen`, `transitionScreen`, `ResponseRule` (Task-1-independent, from #273). -- Produces (exported from `customStimuli.ts`): - - `customResponseRules(params: ExperimentParameters): ResponseRule[]` - - `customInstructionsScreen(params: ExperimentParameters): string` - - `customTransitionScreen(params: ExperimentParameters): string` - -- [ ] **Step 1: Write the failing test** - -```ts -// src/renderer/utils/labjs/__tests__/customScreens.test.ts -import { describe, expect, it } from 'vitest'; -import { EVENTS } from '../../../constants/constants'; -import type { ExperimentParameters } from '../../../constants/interfaces'; -import { - customInstructionsScreen, - customResponseRules, - customTransitionScreen, -} from '../customStimuli'; - -const slot = (type: EVENTS, title: string, dir: string, response: string) => ({ - type, title, dir, audioDir: '', response, -}); - -const params = { - intro: 'Look at ${danger} each animal', - stimulus1: slot(EVENTS.STIMULUS_1, 'Dog', '/pics/dogs', '1'), - stimulus2: slot(EVENTS.STIMULUS_2, 'Condition 2', '/pics/cats', '9'), - stimulus3: slot(EVENTS.STIMULUS_3, 'Bird', '/pics/birds', ''), - stimulus4: slot(EVENTS.STIMULUS_4, '', '', ''), -} as unknown as ExperimentParameters; - -describe('custom participant screens', () => { - it('list every condition with a folder, its key, and no empty slots', () => { - expect(customResponseRules(params)).toEqual([ - { - keys: [ - { key: '1', meaning: 'Dog' }, - { key: '9', meaning: 'cats' }, - { key: undefined, meaning: 'Bird' }, - ], - }, - ]); - }); - - it('show the configured keys before practice and again before the recorded task', () => { - for (const html of [customInstructionsScreen(params), customTransitionScreen(params)]) { - expect(html).toContain('<kbd class="bw-participant-key">1</kbd>'); - expect(html).toContain('<kbd class="bw-participant-key">9</kbd>'); - expect(html).toContain('No key'); - } - }); - - it("keeps the teacher's intro out of the template source", () => { - const html = customInstructionsScreen(params); - expect(html).toContain('${this.parameters.intro}'); - expect(html).not.toContain('${danger}'); - }); -}); -``` - -The `'cats'` case pins `conditionTitle`'s existing placeholder → folder-basename rule, so the screen names a condition exactly as the behavior CSV and ERP labels do. - -- [ ] **Step 2: Run to verify it fails** - -Run: `npx vitest run src/renderer/utils/labjs/__tests__/customScreens.test.ts` -Expected: FAIL. The three functions are not exported. - -- [ ] **Step 3: Implement in `customStimuli.ts`** - -```ts -import { - instructionsScreen, - transitionScreen, - type ResponseRule, -} from '../../experiments/shared/participantScreens'; - -/** - * What a participant is told to press: each condition with a stimulus folder, - * named as it is recorded (`conditionTitle`), with its key or none. - */ -export function customResponseRules(params: ExperimentParameters): ResponseRule[] { - return [ - { - keys: CONDITION_SLOTS.flatMap(({ name }) => { - const slot = params[name]; - return slot && isActiveSlot(slot) - ? [{ key: slot.response || undefined, meaning: conditionTitle(slot) }] - : []; - }), - }, - ]; -} - -/** - * The custom instruction screen. The teacher's intro stays a lab.js - * placeholder so its text is interpolated once, never parsed as a template. - */ -export function customInstructionsScreen(params: ExperimentParameters): string { - return instructionsScreen({ - title: 'Welcome to your experiment', - summary: '${this.parameters.intro}', - rules: customResponseRules(params), - canSkipPractice: true, - }); -} - -/** The custom practice → recorded-task screen, with the same keys again. */ -export function customTransitionScreen(params: ExperimentParameters): string { - return transitionScreen({ rules: customResponseRules(params) }); -} -``` - -Check whether `participantScreens.ts` imports anything from `utils/labjs`. If it does, move these three functions into `experiments/custom/screens.ts` instead, to avoid an import cycle. - -- [ ] **Step 4: Build the custom screens at prepare time** - -In `custom/experiment.ts`: -- Import `customInstructionsScreen`, `customTransitionScreen` from `../../utils/labjs/customStimuli`, `endScreen` from `../shared/participantScreens`, and `ExperimentParameters` as a type. -- Add, above `customExperiment`: - -```ts -/** Builds the instruction screen from this workspace's conditions when lab.js prepares it. */ -function prepareInstructions(this: { parameters: unknown; options: { content?: string } }) { - this.options.content = customInstructionsScreen(this.parameters as ExperimentParameters); -} - -/** Builds the practice → recorded-task screen from this workspace's conditions. */ -function prepareTransition(this: { parameters: unknown; options: { content?: string } }) { - this.options.content = customTransitionScreen(this.parameters as ExperimentParameters); -} -``` - -- `title: 'Instruction'` screen: set `hooks: { 'before:prepare': prepareInstructions }` and `content: ''`. -- `title: 'Main task'` screen: set `hooks: { 'before:prepare': prepareTransition }` and `content: ''`. -- `title: 'End'` screen: `content: endScreen()`. -- `responses` stay as they are. - -- [ ] **Step 5: Custom stories render what runs** - -In `ParticipantScreens/fixtures.ts`, replace `CUSTOM` and `CUSTOM_FOUR_KEYS` (the `InstructionsScreenParams` objects) with parameter fixtures. Keep `CUSTOM_INTRO` / `CUSTOM_LONG_INTRO`: - -```ts -import { EVENTS } from '../../constants/constants'; -import type { ExperimentParameters } from '../../constants/interfaces'; - -const slot = (type: EVENTS, title: string, response: string) => ({ - type, title, dir: `/pics/${title.toLowerCase()}`, audioDir: '', response, -}); -const emptySlot = (type: EVENTS) => ({ type, title: '', dir: '', audioDir: '', response: '' }); - -/** Two keyed conditions and one watched-only condition. */ -export const CUSTOM_PARAMS = { - stimulus1: slot(EVENTS.STIMULUS_1, 'Dog', '1'), - stimulus2: slot(EVENTS.STIMULUS_2, 'Cat', '9'), - stimulus3: slot(EVENTS.STIMULUS_3, 'Bird', ''), - stimulus4: emptySlot(EVENTS.STIMULUS_4), -} as unknown as ExperimentParameters; - -/** Four conditions on the default keys for four (1, 4, 6, 9). */ -export const CUSTOM_FOUR_KEYS_PARAMS = { - stimulus1: slot(EVENTS.STIMULUS_1, 'Monkey', '1'), - stimulus2: slot(EVENTS.STIMULUS_2, 'Parrot', '4'), - stimulus3: slot(EVENTS.STIMULUS_3, 'Frog', '6'), - stimulus4: slot(EVENTS.STIMULUS_4, 'Jaguar', '9'), -} as unknown as ExperimentParameters; -``` - -Stories: `InstructionsCustom` → `content: customInstructionsScreen(CUSTOM_PARAMS)`; `InstructionsCustomLongIntro` → `content: customInstructionsScreen(CUSTOM_FOUR_KEYS_PARAMS)`. Keep their `parameters: { isEEGEnabled: true, intro: … }`. Update both docstrings, since the no-key condition now reads its own title ("Bird") next to a "No key" cap. - -- [ ] **Step 6: Run, typecheck, commit** - -Run: `npx vitest run src/renderer/utils/labjs/__tests__/customScreens.test.ts src/renderer/experiments/custom` → pass. `npx tsc --noEmit` → 0 errors. - -```bash -git add src/renderer/utils/labjs src/renderer/experiments/custom src/renderer/components/ParticipantScreens -git commit -m "feat(custom): participant screens built from the teacher's conditions" -``` - ---- - -### Task 3: The stillness line follows the run's EEG setting - -**Files:** -- Modify: `src/renderer/components/ExperimentRuntime.tsx` (`ExperimentRuntimeProps`) -- Modify: `src/renderer/components/LabjsExperimentWindow.tsx` (after `experimentToRun.parameters.title = title;`, destructure, deps) -- Modify: `src/renderer/components/CollectComponent/RunComponent.tsx` (`<ExperimentRuntime …>`) -- Test: `src/renderer/components/__tests__/LabjsExperimentWindow.test.tsx` (from #275; reuse its `vi.hoisted` shims) - -**Interfaces:** -- Produces, on `ExperimentRuntimeProps`: - ```ts - /** EEG is being recorded; lab.js screens read it as `parameters.isEEGEnabled` (the stillness line). */ - isEEGEnabled?: boolean; - ``` -- Preview omits the prop, so preview screens hide the stillness line. That's fine: nothing is recorded. - -- [ ] **Step 1: Write the failing test** - -Append to `LabjsExperimentWindow.test.tsx`: - -```tsx -import { instructionsScreen, STILLNESS_LINE } from '../../experiments/shared/participantScreens'; - -const instructionsStudy = { - type: 'lab.flow.Sequence', - content: [ - { - type: 'lab.html.Screen', - content: instructionsScreen({ - title: 'Faces and houses', - summary: 'summary', - rules: [{ keys: [{ key: '1', meaning: 'Face' }] }], - }), - }, - ], -}; - -it.each([ - [true, true], - [false, false], -])('EEG %s → stillness line shown: %s', async (isEEGEnabled, shown) => { - const { unmount } = render( - <LabjsExperimentWindow - title="Study" - experimentObject={instructionsStudy as never} - params={{} as never} - isEEGEnabled={isEEGEnabled} - eventCallback={vi.fn()} - onFinish={vi.fn()} - /> - ); - await screen.findByText('Faces and houses', {}, { timeout: 3000 }); - - expect(Boolean(screen.queryByText(STILLNESS_LINE))).toBe(shown); - unmount(); -}); -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `npx vitest run src/renderer/components/__tests__/LabjsExperimentWindow.test.tsx` -Expected: FAIL. TS error (`isEEGEnabled` is not a prop), and the EEG-on case shows no stillness line. - -- [ ] **Step 3: Implement** - -- `ExperimentRuntime.tsx`: add the `isEEGEnabled` member (docstring above) to `ExperimentRuntimeProps`. The dispatcher already spreads `...runtime` into both windows; `ImportedExperimentWindow` ignores it. -- `LabjsExperimentWindow.tsx`: - - destructure `isEEGEnabled`; - - after `experimentToRun.parameters.title = title;`, add `experimentToRun.parameters.isEEGEnabled = Boolean(isEEGEnabled);`; - - add `isEEGEnabled` to the effect's dependency array. -- `RunComponent.tsx`: pass `isEEGEnabled={isEEGEnabled}` to `<ExperimentRuntime>` (the prop is already in scope). - -- [ ] **Step 4: Run, typecheck, commit** - -Run: `npx vitest run src/renderer/components/__tests__/LabjsExperimentWindow.test.tsx src/renderer/components/CollectComponent` → pass. `npx tsc --noEmit` → 0 errors. - -```bash -git add src/renderer/components/ExperimentRuntime.tsx src/renderer/components/LabjsExperimentWindow.tsx src/renderer/components/CollectComponent/RunComponent.tsx src/renderer/components/__tests__/LabjsExperimentWindow.test.tsx -git commit -m "feat(runtime): lab.js screens know whether EEG is recording" -``` - ---- - -### Task 4: Integrated verification and docs - -**Files:** `TODOS.md`; `.llms/learnings.md` (append by hand; no Prettier). - -- [ ] **Step 1: Full checks (once)** - -Run: `npm run typecheck && npm run lint && npm test && node tests/electron-smoke.mjs` -Expected: 0 errors, all tests pass, smoke PASS. - -- [ ] **Step 2: Electron playtest with the Fixture headset (agent, `skill://electron-playtest`)** - -Screenshot each screen at the Electron window's default size and at 1280×720: -1. **Faces/Houses, EEG workspace, Collect → Run & record.** - - The Instruction screen matches the approved `InstructionsFacesHouses` story, including the stillness line. - - Press `Q`: practice is skipped, and the transition screen appears with the same keys. - - Space → trials. Let it finish, or end it early, to reach the End screen. -2. **Stroop, Visual Search, Multitasking:** the instruction screen matches its story. Multitasking's Space leads into its original multi-page instructions. -3. **Custom workspace with two condition folders** (any two image folders): the instruction screen names both conditions with their assigned keys (defaults 1/9). The transition shows them again. -4. **Behavior-only workspace (EEG off):** no stillness line anywhere. -5. **Preview on Design and Collect:** the new screens appear, without the stillness line, and nothing is recorded. -6. **Imported jsPsych study:** unchanged (§7.1). -7. **Progress:** the RunBar still shows `Practice trial N of 6` → `Trial N of 120` for Faces/Houses. - -- [ ] **Step 3: Docs** - -`TODOS.md`: add under the Playtest 1 list: `~~BrainWaves-owned participant screens (§7.2)~~ — shipped YYYY-MM-DD (WS5b, PR #N)`. - -Append to `.llms/learnings.md`: - -```markdown -## Participant screens: built-ins own `screens.ts`; Custom builds at prepare - -Built-in lab.js studies take their instruction / practice→main / end screen -content from `experiments/shared/participantScreens.ts` builders fed by each -experiment's `screens.ts`; `experiments/__tests__/participantScreens.test.ts` -fails if the keys a screen shows ever drift from the keys its trials accept. -Custom studies can't be static (keys and names are the teacher's), so their -screens are built in `before:prepare` hooks from `this.parameters` — -setting `this.options.content` there is still run through lab.js's `${…}` -templating (options proxy parses on set/arm). The teacher's intro stays a -`${this.parameters.intro}` placeholder so its text is never parsed as a -template. The stillness line reads `parameters.isEEGEnabled`, which -`LabjsExperimentWindow` sets from the `isEEGEnabled` runtime prop. -``` - -- [ ] **Step 4: Commit and PR** - -```bash -git add TODOS.md .llms/learnings.md -git commit -m "docs: WS5b participant screens learnings and TODOS" -gh pr create --title "feat(experiments): WS5b participant screens in built-in and custom studies" --body "Implements docs/superpowers/plans/2026-09-24-ws5b-participant-screens.md. Verification: <Step 1 output, Step 2 screenshots>." -``` diff --git a/docs/user-flow.md b/docs/user-flow.md index debb4069..54339c13 100644 --- a/docs/user-flow.md +++ b/docs/user-flow.md @@ -6,100 +6,58 @@ User flow through the BrainWaves Electron app. If this disagrees with the runnin ```mermaid flowchart TD - HOME["HOME"] - HOME --> MY_EXP["MY EXPERIMENTS\n(saved workspaces)"] - HOME --> EXP_BANK["EXPERIMENT BANK\n(4 built-in cards)"] - HOME --> EXPLORE["EXPLORE EEG DATA\n(raw streaming)"] + HOME["HOME /\n(Continue your work · Start Faces/Houses · Explore EEG)"] + HOME -->|"Browse all experiments →"| BANK["EXPERIMENT BANK /home"] + HOME -->|"Open live view"| EXPLORE + HOME -->|"Continue / Start"| PREPARE + BANK -->|"Pick a card, name the workspace"| PREPARE - MY_EXP -->|"Open Experiment"| DESIGN - EXP_BANK -->|"Pick card → Design"| DESIGN - - EXPLORE --> CONNECT_MODAL_EXP["Headset setup\n(Muse / Neurosity / LSL)"] - CONNECT_MODAL_EXP --> SIGNAL_PREP_EXP["Signal prep\n(worn headsets)"] - SIGNAL_PREP_EXP --> EEG_EXPLORE["Live EEG Viewer\n(signal quality + waveform)"] - - subgraph DESIGN ["DESIGN /design"] + subgraph EXPLORE ["EXPLORE /explore (no workspace, nothing recorded)"] direction TB - D_OV["OVERVIEW"] - D_BG["BACKGROUND"] - D_PR["PROTOCOL"] - D_PV["PREVIEW\n(lab.js)"] - D_OV --> D_BG --> D_PR --> D_PV - EEG_TOGGLE["Enable/Disable EEG"] + EX_SETUP["Headset setup → signal prep"] + EX_LIVE["Live signal + quality summary"] + EX_LESSONS["Lessons: cleaner signal · noise sources · eyes-closed"] + EX_SETUP --> EX_LIVE --> EX_LESSONS end - DESIGN -->|"Top nav: Collect"| COLLECT - - subgraph COLLECT ["COLLECT /collect"] - direction TB - PRE_TEST["PRE-TEST\n(signal quality + EEG viewer)"] - CONNECT_MODAL["Headset setup\n① pick Muse / Neurosity / LSL\n② wear + power on\n③ Find my headset → select → connect"] - SIGNAL_PREP["Signal prep\n(per-sensor quality, never gates)"] - PRE_TEST -->|"EEG enabled & not connected"| CONNECT_MODAL - CONNECT_MODAL -->|"Check my signal"| SIGNAL_PREP - SIGNAL_PREP -->|"Continue"| PRE_TEST - PRE_TEST -->|"Run & Record"| RUN - RUN["RUN\n(subject ID / group / session)"] - EXP_WINDOW["ExperimentWindow\n(lab.js + EEG markers)"] - RUN -->|"Run Experiment"| EXP_WINDOW - EXP_WINDOW -->|"complete"| DONE_COLLECT["Recording saved"] + subgraph SHELL ["Workspace shell: PREPARE → COLLECT → CLEAN → ANALYZE"] + direction LR + PREPARE["PREPARE /design"] + COLLECT["COLLECT /collect"] + CLEAN["CLEAN /clean\n(EEG only)"] + ANALYZE["ANALYZE /analyze"] + PREPARE --> COLLECT + COLLECT -->|"EEG"| CLEAN + COLLECT -->|"Behavior only"| ANALYZE + CLEAN -->|"Save cleaned dataset & analyze"| ANALYZE end - - DONE_COLLECT -->|"EEG enabled\nTop nav: Clean"| CLEAN - DONE_COLLECT -->|"Behavior only\nTop nav: Analyze"| ANALYZE - - subgraph CLEAN ["CLEAN /clean\n(EEG only)"] - direction TB - CL_SEL["Select subject + recording(s)"] - CL_LOAD["Load Dataset\n(Pyodide epochs + reviewer)"] - CL_CLEAN["Clean Data\n(reject artifacts → .fif)"] - CL_SEL --> CL_LOAD --> CL_CLEAN - end - - CLEAN -->|"Analyze Dataset"| ANALYZE - - subgraph ANALYZE ["ANALYZE /analyze"] - direction TB - AN_OV["OVERVIEW\n(topoplot)"] - AN_ERP["ERP"] - AN_BEH["BEHAVIOR"] - AN_EXP["Export"] - AN_OV --> AN_ERP --> AN_BEH --> AN_EXP - end - - DESIGN -->|"Home"| HOME - COLLECT -->|"Home"| HOME - CLEAN -->|"Home"| HOME - ANALYZE -->|"Home"| HOME ``` -## Stage Descriptions +The shell bar shows the current area (gold underline), one recommended `NEXT →`, and truthful data badges (`N recordings`, `N cleaned`). Any area can be opened; Clean and Analyze explain what they need when there is no data. During a recorded run the bar is replaced by the RunBar (`EEG recording` / `Behavior only`, elapsed time, `End experiment early`). -### 1. Home (`/` and `/home`) +## Stage Descriptions -Three tabs: +### 1. Home (`/`), Experiment Bank (`/home`) -- **My Experiments** — saved workspaces; Delete, Go to Folder, Open Experiment. -- **Experiment Bank** — five cards: Faces/Houses (N170), Stroop, Multi-tasking, Visual Search, and **Custom**. Built-in cards start a workspace and go to Design. Custom opens a title prompt, then Design with extra authoring tabs. -- **Explore EEG Data** — connect a headset and stream live EEG with no experiment. +- **Home** — `Welcome back` (or `Welcome to BrainWaves` on first run): Continue your work (saved workspaces, newest first, with Open, Show in folder and a confirmed Delete), Start Faces/Houses (the recommended first experiment; a naming dialog suggests the next free name), and Explore EEG. +- **Experiment Bank** — Faces/Houses (N170), Stroop, Multi-tasking, Visual Search, Custom, and imported jsPsych / lab.js studies. Built-in cards start a uniquely named workspace and open Prepare. -### 2. Design (`/design`) +### 2. Prepare (`/design`) -| Tab | Content | -|---|---| -| **Overview** | Title and experiment description | -| **Background** | Framing questions and external reading | -| **Protocol** | Step-by-step instructions with condition images | -| **Preview** | Live lab.js preview | +Built-in experiments show `PrepareSteps`: **Overview → Background → Protocol → Preview**, each with one forward action. Protocol shows the condition cards, keycaps and a flow diagram with the experiment's real trial counts. Preview runs the participant screens in a labelled preview box. Nothing here gates Collect. -**Enable EEG** (gear / toggle) controls whether Clean appears downstream. +Settings → EEG on/off controls whether Clean appears downstream. -Custom experiments add Conditions / Trials / Parameters / Instructions. Pick 1–4 image folders and key responses; the first image of each condition is a practice trial. Runtime is the Faces/Houses lab.js template parameterized by those stimuli (`filepath` URLs). `experiments/custom/experiment.js` is kept on disk but is not the runtime (it still uses the pre-Vite `this.files[dir/filename]` lookup). +Custom experiments keep their authoring steps (Overview, Conditions, Trials, Parameters, Instructions, Preview); imported studies show Overview, Markers and Preview. Custom: pick 1–4 image folders and key responses; the first image of each condition is a practice trial. Runtime is the Faces/Houses lab.js template parameterized by those stimuli (`filepath` URLs). `experiments/custom/experiment.js` is kept on disk but is not the runtime (it still uses the pre-Vite `this.files[dir/filename]` lookup). ### 3. Collect (`/collect`) -- **Pre-Test** — headset setup opens automatically when EEG is on and nothing is connected (also from the header device chip): pick **Muse**, **Neurosity Crown**, or an **LSL stream** if liblsl loaded → wear/power-on tips → `Find my headset` (the only thing that starts a search; it runs until a headset is found, the student cancels, or one minute passes, which asks "Is your Muse turned on?") → connect → `Check my signal` → signal prep → pre-run screen with signal quality + live waveform. Muse/Neurosity are Web Bluetooth. There is no USB receiver (that was Emotiv). -- **Run** — subject ID, group, session → full-screen lab.js. Markers go through `injectMarker()` (active BLE driver) and, when LSL is available, `sendMarker()` to the outlet. Behavioral CSV is saved on end. +- **Pre-Test** — headset setup opens when EEG is on and nothing is connected (also from the shell's device chip, whose Connected screen offers Disconnect): pick **Muse**, **Neurosity Crown**, or an **LSL stream** if liblsl loaded → wear/power-on tips → `Find my headset` (the only thing that starts a search; it ends when a headset is found, the student cancels, or after one minute: "Is your Muse turned on?") → connect → `Check my signal` → signal prep → the pre-run screen with signal quality, the live waveform and the Ready-to-run card. Muse and Neurosity use Web Bluetooth. +- **Run** — subject ID, group, session (a taken session is never overwritten) → SPACE to begin → BrainWaves instruction, practice and main-task screens → end screen. `End experiment early` or a held Escape ends the run with no confirm and keeps what was recorded as `*.incomplete.csv`, which Clean and Analyze leave out. Markers go through `injectMarker()` (active BLE driver) and, when LSL is available, `sendMarker()` to the outlet. + +### Explore (`/explore`) — no workspace + +Connect a headset and watch the live signal. The quality summary names the action (Ready / Settling / Adjust sensors / No signal) beside the head diagram and sensor card. Three activities: **How do I get a cleaner signal?** (3 tips), **Where is this noise coming from?** (blink steps: noise defined, blink bands, a prediction, several blinks, then paused calm-vs-blinking strips), and the **eyes-closed activity** (a countdown and chimes mark ten seconds eyes closed; the review compares paused eyes-open and eyes-closed segments). Nothing is recorded. ### 4. Clean (`/clean`) — EEG only