From 4a76bb353029aecda141fa4d1c73769466e32507 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Thu, 24 Sep 2026 15:40:00 -0400 Subject: [PATCH 1/2] docs(plan): WS5b participant screens integration plan --- .../2026-09-24-ws5b-participant-screens.md | 677 ++++++++++++++++++ 1 file changed, 677 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-24-ws5b-participant-screens.md diff --git a/docs/superpowers/plans/2026-09-24-ws5b-participant-screens.md b/docs/superpowers/plans/2026-09-24-ws5b-participant-screens.md new file mode 100644 index 00000000..8d552fce --- /dev/null +++ b/docs/superpowers/plans/2026-09-24-ws5b-participant-screens.md @@ -0,0 +1,677 @@ +# 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. Task 3 edits `LabjsExperimentWindow.tsx`, `ExperimentRuntime.tsx` and `RunComponent.tsx` as #275 leaves them, 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 }; + +/** Every non-Space, non-skip key any screen in the study responds to. */ +const acceptedKeys = (node: unknown, out = new Set()): Set => { + 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>/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: 'green', + label: 'Red ink', + detail: `The word says “green”. Press ${keycap('r')}`, + }, + { + stimulus: 'yellow', + 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) => + `T`; + +/** 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)` | `endScreen()` (the `keypress(Space)': 'end'` screen) | +| 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 `