From 828eac9b7b2fc9c5f341cbdd0fce559ab40b1e8b Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Fri, 25 Sep 2026 16:05:57 -0400 Subject: [PATCH 1/9] docs(plan): WS4 Prepare integration plan --- .../2026-09-25-ws4-prepare-integration.md | 536 ++++++++++++++++++ 1 file changed, 536 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-25-ws4-prepare-integration.md diff --git a/docs/superpowers/plans/2026-09-25-ws4-prepare-integration.md b/docs/superpowers/plans/2026-09-25-ws4-prepare-integration.md new file mode 100644 index 00000000..795cdd35 --- /dev/null +++ b/docs/superpowers/plans/2026-09-25-ws4-prepare-integration.md @@ -0,0 +1,536 @@ +# WS4 Prepare 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:** The approved Prepare screens (#276) become the real Prepare surface for the four built-in experiments. The protocol diagram's keys and its trial timeline come from each experiment's real code, not hand-copied fixtures or the stale `nbTrials` param. + +**Architecture:** +- `PrepareSteps` (#276) becomes the shell of the built-in Design screen: it owns the gold step pill, the centered column, the step content and the sticky action row. `DesignComponent/index.tsx` supplies the content and wiring. Custom and Imported keep their own authoring flows. +- Per-experiment data moves from `PrepareSteps/fixtures.ts` into each experiment as `prepare.ts`, next to WS5b's `screens.ts`. Story fixtures then read those modules, so Storybook shows what runs. +- Trial counts are derived from each experiment's real loop structure, because `params.nbTrials` is stale for Stroop and Search (says 150, unused; real counts are 96 and 80). +- One preview label app-wide: `PreviewLabel` (#276) replaces `PreviewButtonComponent`'s inline status, and the preview box is tall enough to show a participant screen without scrolling. + +**Tech Stack:** React 18, Redux Toolkit, lab.js study objects (read-only), TypeScript, Vitest, Storybook 9. + +**Spec:** +- `docs/uxr/playtest_naive_1_design_implementation_plan.md` §1.2, §1.4, §3.2, §3.3, §6, §11 WS4. +- Approved design: PR #276 (`src/renderer/components/PrepareSteps/`, `PreviewLabel.tsx`). + +## Global Constraints + +- "Keep `Prepare` stable in the global workflow while local headings use Learn, Design, or Configure." (§11 WS4). The global bar is untouched. +- "Add action-specific Back and forward controls without turning visited sections into Collect prerequisites." (§11 WS4). No lesson or preview completion gates Collect (§1.4, §13). +- "Generate built-in experiment flow descriptions from known parameters where possible." (§11 WS4) — here, from the real loops. +- "Do not persist lesson-completed or preview-completed flags solely for gating." (§11 WS4) +- "Package a licensed local Oliver Sacks clip only when rights and captions are available; otherwise implement the illustrated fallback." (§11 WS4) The fallback ships; no remote player. +- The stillness line's source of truth is WS5b's `isEEGEnabled` parameter. The EEG toggle keeps setting the workspace's `isEEGEnabled` exactly as today. +- Real trial counts: Faces/Houses 6 practice / 120 recorded; Stroop 8 / 96; Visual Search 8 / 80; Multitasking is block-based with no study-wide total (per #270's known gap). +- `params.nbTrials` / `nbPracticeTrials` are NOT the source of counts. +- Rule A: at 1366×768 and 1280×720, each step's content and its primary action are visible without a page scroll. +- Comments go on definitions, not inside bodies. No one-expression wrapper functions. No new dependencies. +- Subagents: skip formatters, project-wide lint and the full suite. Run only the files named per task. Task 4 runs everything once. + +## Open contracts (verify before coding) + +Four details are not pinned in the task text. Each implementer resolves them by reading the code and records the answer in its report. + +1. **`PrepareFixture` location.** It is exported from `PrepareSteps.tsx` today. If `experiments/*/prepare.ts` importing it creates an import cycle (experiments → components → experiments), move `PrepareFixture`, `ResponseMapping` and `FlowPhase` into `PrepareSteps/flow.ts` and re-export them. Check with `lsp` references before moving. +2. **`ResponseMapping.when`.** The type has no `when` field, but the approved Protocol diagram shows per-rule labels ("Shape on top: answer the shape"). Either add `when?: string` and render it, or fold the rule label into `label` strings. Do the former if `PrepareSteps` already has a slot for it; otherwise the latter, and say so. +3. **Multitasking's keys.** `multitasking/content_protocol.js` has no `condition_first_key` / `condition_second_key`. Derive `responses` from the real `b`/`n` mapping in `multitasking/experiment.ts` and the rule wording from `content_protocol.js`'s `condition_first` / `condition_second`. Do not invent keys. +4. **`Experiment.prepare` on the type.** `constants/interfaces.ts`'s `Experiment` interface has `icon`, `experimentObject`, `params`, `text`. Adding `prepare?: PrepareFixture` is the intent; confirm `imported/` and `custom/` compile without it (they should, since it's optional). + +### Rulings + +- **Ruling 1 (Multitasking).** Its `prepare.ts` describes a block task with two rules on the same keys, not a linear trial count. `flowFromStructure` has a `blocks` variant, and the timeline never claims a study-wide total. Cost if wrong: Multitasking's Protocol reads like a linear task and its counts are wrong. +- **Ruling 2 (EEG toggle + Customize).** They move into `PrepareSteps`'s action row as optional props (`isEEGEnabled`, `onEEGEnabledChange`, `onCustomize`), since `PrepareSteps` owns the step chrome now. Cost if wrong: one extra prop pass-through and a story to remove. +- **Ruling 3 (SecondaryNavComponent name).** Keep the name and add a docstring saying it's now the Custom/Imported authoring nav. Renaming risks touching unrelated callers for no user-visible gain. Cost if wrong: a later rename PR. +- **Ruling 4 (preview box height).** `min-h-[420px]`, growing with content, instead of `h-[330px]`. Cost if wrong: one line of CSS. + +## Review Focus + +1. **A key or count that drifts** between the diagram and the task. WS5b added `experiments/__tests__/participantScreens.test.ts` for participant screens; Task 1 adds the same style of test for Prepare. +2. **Multitasking.** Two rules on the same keys is the hardest diagram; verify it reads clearly and never claims a study-wide total. +3. **The preview box.** At 330px tall the participant screen scrolled (logged in TODOS). Task 3 must prove the whole first participant screen, keys included, is visible without scrolling at both sizes. +4. **Custom and Imported must not regress.** They keep their own steps and the `SecondaryNavComponent` header. Task 2's test covers both. +5. **Workspaces already on disk** (`appState.json`) carry no prepare data. It's derived at render time from `type`, so no migration is needed. Note it for the reviewer. + +## File Structure + +| File | Change | Responsibility | +|---|---|---| +| `src/renderer/experiments/{faces_houses,stroop,search,multitasking}/prepare.ts` | create | per-experiment `responses` + `flow` (real counts) | +| `src/renderer/components/PrepareSteps/flow.ts` | create | `flowFromStructure`, `FlowPhase`, `LoopStructure` | +| `src/renderer/components/PrepareSteps/fixtures.ts` | modify | re-export the per-experiment modules | +| `src/renderer/experiments/__tests__/prepareContent.test.ts` | create | keys and counts match the real studies | +| `src/renderer/constants/interfaces.ts`, `experiments/*/index.ts` | modify | `prepare?: PrepareFixture` on `Experiment` | +| `src/renderer/components/DesignComponent/index.tsx` | modify | built-ins render `PrepareSteps` | +| `src/renderer/components/DesignComponent/__tests__/DesignPrepare.test.tsx` | create | step flow + Custom/Imported untouched | +| `src/renderer/components/SecondaryNavComponent/index.tsx` | modify | docstring: Custom/Imported authoring nav | +| `src/renderer/components/PreviewButtonComponent.tsx` + `__tests__/PreviewButton.test.tsx` | modify / create | one preview label | +| `src/renderer/components/PrepareSteps/PrepareSteps.tsx` | modify | optional EEG/Customize props; preview box height | +| `TODOS.md`, `.llms/learnings.md` | modify | notes | + +## Dispatch waves + +Worktree: `git worktree add .worktrees/ws4-integration -b feat/ws4-prepare-integration origin/main`, from `main` after #279 merges (Task 4 touches `RunComponent` only via `PreviewExperimentComponent`; if #279 is still open, base on `origin/main` and rebase before the PR). + +| Wave | Tasks | Why | +|---|---|---| +| 1 | Task 1 ∥ Task 3 | per-experiment modules vs PreviewButton; disjoint | +| 2 | Task 2 | needs Task 1's modules | +| 3 | Task 4 | needs Task 2's shell | +| 4 | verification, docs, PR | | + +--- + +### Task 1: Per-experiment Prepare data and the real trial counts + +**Files:** +- Create: `src/renderer/experiments/{faces_houses,stroop,search,multitasking}/prepare.ts` +- Create: `src/renderer/components/PrepareSteps/flow.ts` +- Test: `src/renderer/experiments/__tests__/prepareContent.test.ts` +- Modify: `src/renderer/components/PrepareSteps/fixtures.ts` (re-export) + +**Interfaces:** +- Consumes: each experiment's `content_overview.js` / `content_background.js` / `content_protocol.js`, its stimulus images, and its real loop structure. +- Produces: + +```ts +// flow.ts +export interface FlowPhase { label: string; count?: number } +export interface LoopStructure { + /** Trials the participant practices before the recorded task. */ + practice?: number; + /** Trials recorded for analysis. */ + recorded?: number; + /** Block-structured tasks (Multitasking) instead of practice/recorded. */ + blocks?: Array<{ label: string; practice?: number; recorded?: number }>; +} +export function flowFromStructure(s: LoopStructure): FlowPhase[] +``` + +```ts +// each experiments//prepare.ts +export const prepare: PrepareFixture; // { overview, background, protocol, responses, flow, icon } +``` + +- [ ] **Step 1: Write the failing test** + +```ts +// src/renderer/experiments/__tests__/prepareContent.test.ts +import { describe, expect, it, vi } from 'vitest'; +import { flowFromStructure } from '../../components/PrepareSteps/flow'; +import { prepare as faces } from '../faces_houses/prepare'; +import { prepare as stroop } from '../stroop/prepare'; +import { prepare as search } from '../search/prepare'; +import { prepare as multi } from '../multitasking/prepare'; + +vi.mock('lab.js', () => ({})); + +/** Keys shown in the protocol diagram. */ +const shownKeys = (p: { responses: { key: string }[] }) => + new Set(p.responses.map(({ key }) => key.toLowerCase())); + +describe('flowFromStructure', () => { + it('describes a linear task with practice and recorded trials', () => { + expect(flowFromStructure({ practice: 6, recorded: 120 })).toEqual([ + { label: 'Instructions' }, + { label: 'Practice trials', count: 6 }, + { label: 'Main-task reminder' }, + { label: 'Recorded trials', count: 120 }, + { label: 'Completion' }, + ]); + }); + + it('describes a block task without a study-wide total', () => { + const flow = flowFromStructure({ + blocks: [ + { label: 'Practice blocks', practice: 2 }, + { label: 'Recorded blocks', recorded: 4 }, + ], + }); + expect(flow).toEqual([ + { label: 'Instructions' }, + { label: 'Practice blocks', count: 2 }, + { label: 'Main-task reminder' }, + { label: 'Recorded blocks', count: 4 }, + { label: 'Completion' }, + ]); + }); +}); + +describe('per-experiment prepare content', () => { + it('Faces/Houses shows the keys its stimuli accept, and 6 practice / 120 recorded', () => { + expect(shownKeys(faces)).toEqual(new Set(['1', '9'])); + expect(faces.flow).toContainEqual({ label: 'Practice trials', count: 6 }); + expect(faces.flow).toContainEqual({ label: 'Recorded trials', count: 120 }); + }); + + it('Stroop shows r/g/b/y, and 8 practice / 96 recorded', () => { + expect(shownKeys(stroop)).toEqual(new Set(['r', 'g', 'b', 'y'])); + expect(stroop.flow).toContainEqual({ label: 'Practice trials', count: 8 }); + expect(stroop.flow).toContainEqual({ label: 'Recorded trials', count: 96 }); + }); + + it('Visual Search shows b/n, and 8 practice / 80 recorded', () => { + expect(shownKeys(search)).toEqual(new Set(['b', 'n'])); + expect(search.flow).toContainEqual({ label: 'Practice trials', count: 8 }); + expect(search.flow).toContainEqual({ label: 'Recorded trials', count: 80 }); + }); + + it('Multitasking shows its rules on b/n and never claims a study-wide total', () => { + expect(shownKeys(multi)).toEqual(new Set(['b', 'n'])); + expect(multi.flow.some((p) => /recorded trials/i.test(p.label))).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run src/renderer/experiments/__tests__/prepareContent.test.ts` +Expected: FAIL — cannot find `../faces_houses/prepare` and `../../components/PrepareSteps/flow`. + +- [ ] **Step 3: Implement `flow.ts`** + +```ts +// src/renderer/components/PrepareSteps/flow.ts +/** One phase of a task's timeline, as shown in the protocol diagram. */ +export interface FlowPhase { + label: string; + /** Omit for a phase with no fixed count (e.g. Multitasking's blocks). */ + count?: number; +} + +/** A task's real loop structure. Multitasking uses `blocks`. */ +export interface LoopStructure { + practice?: number; + recorded?: number; + blocks?: Array<{ label: string; practice?: number; recorded?: number }>; +} + +/** + * Builds the vertical timeline from a task's real loop structure. Never reads + * `params.nbTrials`, which is stale for Stroop and Search. + */ +export function flowFromStructure(structure: LoopStructure): FlowPhase[] { + const phases: FlowPhase[] = [{ label: 'Instructions' }]; + if (structure.blocks) { + for (const block of structure.blocks) { + if (block.practice) phases.push({ label: block.label, count: block.practice }); + } + phases.push({ label: 'Main-task reminder' }); + for (const block of structure.blocks) { + if (block.recorded) phases.push({ label: block.label, count: block.recorded }); + } + phases.push({ label: 'Completion' }); + return phases; + } + if (structure.practice) phases.push({ label: 'Practice trials', count: structure.practice }); + phases.push({ label: 'Main-task reminder' }); + if (structure.recorded) phases.push({ label: 'Recorded trials', count: structure.recorded }); + phases.push({ label: 'Completion' }); + return phases; +} +``` + +- [ ] **Step 4: Implement the four `prepare.ts`** + +Each file is one self-contained object importing its own content and images, exactly as `PrepareSteps/fixtures.ts` does today. Faces/Houses: + +```ts +// src/renderer/experiments/faces_houses/prepare.ts +import { overview } from './content_overview'; +import { background } from './content_background'; +import { protocol } from './content_protocol'; +import icon from './icon.png'; +import face from './stimuli/faces/Face1.jpg'; +import house from './stimuli/houses/House1.jpg'; +import type { PrepareFixture } from '../../components/PrepareSteps/PrepareSteps'; +import { flowFromStructure } from '../../components/PrepareSteps/flow'; + +/** What Prepare shows for Faces/Houses: its keys and its real 6 / 120 trials. */ +export const prepare: PrepareFixture = { + overview, + background, + protocol, + icon, + responses: [ + { key: '1', label: 'Face', stimulus: { src: face, alt: 'A face photo' } }, + { key: '9', label: 'House', stimulus: { src: house, alt: 'A house photo' } }, + ], + // faces_houses/params.ts: nbPracticeTrials 6, nbTrials 120. + flow: flowFromStructure({ practice: 6, recorded: 120 }), +}; +``` + +Stroop: the `word`/`color` stimulus variant as today (`{ word: 'green', color: 'red' }`, etc., from `stroop/experiment.ts`'s canvas text) and `flow: flowFromStructure({ practice: 8, recorded: 96 })` (comment the source lines, as #276's fixtures do). Search: its `conditionOrangeT.png` / `conditionNoOrangeT.png` stimuli and `{ practice: 8, recorded: 80 }`. + +Multitasking (`multitasking/prepare.ts`) is the exception: two rules on the same `b`/`n` keys (Open contract 3), and `flow: flowFromStructure({ blocks: [...] })` (Ruling 1) describing its block structure without a study-wide total. + +- [ ] **Step 5: Point fixtures at the real modules** + +`PrepareSteps/fixtures.ts` becomes a thin re-export plus `SACKS_STAND_IN` and `NOOP_HANDLERS`: + +```ts +export { prepare as FACES_HOUSES } from '../../experiments/faces_houses/prepare'; +export { prepare as STROOP } from '../../experiments/stroop/prepare'; +export { prepare as SEARCH } from '../../experiments/search/prepare'; +export { prepare as MULTITASKING } from '../../experiments/multitasking/prepare'; +``` + +Delete `buildFlow`, the three hand-written fixtures and now-unused imports. Keep `SACKS_STAND_IN`, `NOOP_HANDLERS` and `PrepareFixture` (or move it per Open contract 1). + +- [ ] **Step 6: Run, typecheck, commit** + +Run: `npx vitest run src/renderer/experiments/__tests__/prepareContent.test.ts` → pass. `npx tsc --noEmit` → 0 errors. `npx vitest run src/renderer/components/PrepareSteps` → stories still compile. + +```bash +git add src/renderer/experiments src/renderer/components/PrepareSteps +git commit -m "feat(prepare): per-experiment protocol data and real trial counts" +``` + +--- + +### Task 2: The built-in Design screen renders `PrepareSteps` + +**Files:** +- Modify: `src/renderer/components/DesignComponent/index.tsx` +- Modify: `src/renderer/constants/interfaces.ts`, `src/renderer/experiments/*/index.ts` +- Modify: `src/renderer/components/SecondaryNavComponent/index.tsx` (docstring) +- Test: `src/renderer/components/DesignComponent/__tests__/DesignPrepare.test.tsx` + +**Interfaces:** +- Consumes: `PrepareSteps` props (#276), `prepare` from Task 1's modules, `DesignProps`. +- Produces: `DesignComponent` renders `PrepareSteps` for the four built-ins. Custom (`CustomDesign`) and Imported (`ImportedDesign`) are unchanged in behavior. + +- [ ] **Step 1: Write the failing test** + +```tsx +// src/renderer/components/DesignComponent/__tests__/DesignPrepare.test.tsx +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import Design from '../index'; +import { EXPERIMENTS } from '../../../constants/constants'; + +vi.mock('lab.js', () => ({})); +vi.mock('../CustomDesignComponent', () => ({ default: () =>
})); +vi.mock('../ImportedDesignComponent', () => ({ default: () =>
})); +vi.mock('../../PreviewExperimentComponent', () => ({ default: () =>
})); + +const baseProps = { + navigate: vi.fn(), + type: EXPERIMENTS.N170, + title: 'Faces_Houses_1', + params: {} as never, + experimentObject: {} as never, + ExperimentActions: { SetEEGEnabled: vi.fn(), SaveWorkspace: vi.fn(), CreateNewWorkspace: vi.fn() } as never, + isEEGEnabled: true, +}; + +describe('Design — built-in Prepare', () => { + it('renders the Prepare steps with the experiment keys and the real trial counts', () => { + render(); + expect(screen.getByRole('navigation', { name: 'Prepare steps' })).toBeInTheDocument(); + expect(screen.getByText('Face')).toBeInTheDocument(); + expect(screen.getByText('House')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Next: Background/ })).toBeInTheDocument(); + }); + + it('walks Overview → Background → Protocol → Preview and offers Try the experiment', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /Next: Background/ })); + expect(screen.getByText(/BACKGROUND/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Next: Protocol/ })); + expect(screen.getByText('WHAT HAPPENS IN THIS TASK')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Try the experiment/ })); + expect(screen.getByTestId('preview-experiment')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Run & record/ })).toBeInTheDocument(); + }); + + it('keeps Custom on its authoring flow', () => { + render(); + expect(screen.getByTestId('custom-design')).toBeInTheDocument(); + expect(screen.queryByRole('navigation', { name: 'Prepare steps' })).not.toBeInTheDocument(); + }); + + it('keeps Imported on its authoring flow', () => { + render(); + expect(screen.getByTestId('imported-design')).toBeInTheDocument(); + expect(screen.queryByRole('navigation', { name: 'Prepare steps' })).not.toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run src/renderer/components/DesignComponent/__tests__/DesignPrepare.test.tsx` +Expected: FAIL — `Prepare steps` navigation absent. + +- [ ] **Step 3: Wire `PrepareSteps` into `DesignComponent`** + +For the four built-ins (N170, STROOP, SEARCH, MULTI), replace the `SecondaryNavComponent` + `renderSectionContent()` pair with `PrepareSteps`: + +```tsx + const prepare = getExperimentFromType(props.type).prepare; + return ( + setIsNewExperimentModalOpen(true)} + onStep={setActiveStep} + onCollect={() => props.navigate(SCREENS.COLLECT.route)} + onPreviewStart={() => setIsPreviewing(true)} + onPreviewStop={() => setIsPreviewing(false)} + onPreviewAgain={() => setIsPreviewing(true)} + isPreviewing={isPreviewing} + hasPreviewed={hasPreviewed} + /> + ); +``` + +- Add `prepare?: PrepareFixture` to `Experiment` in `constants/interfaces.ts`, and export `prepare` from each built-in's `index.ts` (Open contract 4). +- `DESIGN_STEPS` maps to `PrepareStepId`: `OVERVIEW → 'overview'`, `BACKGROUND → 'background'`, `PROTOCOL → 'protocol'`, `PREVIEW → 'preview'`. +- `hasPreviewed` becomes a `useState` flipped by `onPreviewStart`. +- Pass `isEEGEnabled` / `onEEGEnabledChange` / `onCustomize` to `PrepareSteps` per Ruling 2; add its stories for those states. +- Delete `renderSectionContent`, the four `case` blocks and now-unused imports (`renderConditionIcon`, `renderOverviewIcon`, `SecondaryNavComponent`, `InputModal` if only used for Customize). + +- [ ] **Step 4: Custom and Imported keep their flow** + +`CustomDesignComponent` and `ImportedDesignComponent` still render `SecondaryNavComponent` with `CUSTOM_STEPS` / `IMPORTED_STEPS`. Keep the name and add a docstring (Ruling 3). The test above covers both. + +- [ ] **Step 5: Run, typecheck, commit** + +Run: `npx vitest run src/renderer/components/DesignComponent` → pass. `npx tsc --noEmit` → 0 errors. + +```bash +git add src/renderer/components/DesignComponent src/renderer/constants/interfaces.ts src/renderer/experiments/*/index.ts +git commit -m "feat(prepare): built-in Design screen renders the approved Prepare steps" +``` + +--- + +### Task 3: One preview label app-wide + +**Files:** +- Modify: `src/renderer/components/PreviewButtonComponent.tsx` +- Test: `src/renderer/components/__tests__/PreviewButton.test.tsx` (create) + `PreviewButtonComponent.stories.tsx` (create) +- Modify: `src/renderer/components/PrepareSteps/PrepareSteps.tsx` (preview box height only) + +**Interfaces:** +- Consumes: `PreviewLabel` (#276). +- Produces: `PreviewButton` renders `PreviewLabel` instead of its inline `PREVIEW · nothing is being recorded` span. + +- [ ] **Step 1: Write the failing test** + +```tsx +// src/renderer/components/__tests__/PreviewButton.test.tsx +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import PreviewButton from '../PreviewButtonComponent'; + +describe('PreviewButton', () => { + it('shows the one shared preview label while previewing', () => { + render(); + expect(screen.getByText('PREVIEW')).toBeInTheDocument(); + expect(screen.getByText(/not recording/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Stop preview' })).toBeInTheDocument(); + }); + + it('offers Run & record once a preview has run', () => { + const onRun = vi.fn(); + const { rerender } = render(); + fireEvent.click(screen.getByRole('button', { name: 'Preview experiment' })); + rerender(); + fireEvent.click(screen.getByRole('button', { name: 'Run & record' })); + expect(onRun).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run src/renderer/components/__tests__/PreviewButton.test.tsx` +Expected: FAIL — the shared label's glyph and layout differ from today's inline span. + +- [ ] **Step 3: Use `PreviewLabel`** + +Replace the inline status span with ``. Keep the `hasPreviewed` state and the "Preview experiment" / "Preview again" / "Run & record" button order exactly as they are. Remove the now-unused inline markup. + +- [ ] **Step 4: The preview box shows a full participant screen** + +Raise the preview area so the first participant screen (title, keys, notes and the Space line) is visible without scrolling (Ruling 4): `min-h-[420px]` growing with content, instead of `h-[330px]`. Add a `PreviewRunningFull` story that renders the Preview step with a real participant screen and asserts no internal scroll at both sizes. + +- [ ] **Step 5: Run, typecheck, commit** + +Run: `npx vitest run src/renderer/components/__tests__/PreviewButton.test.tsx src/renderer/components/PrepareSteps` → pass. `npx tsc --noEmit` → 0 errors. + +```bash +git add src/renderer/components/PreviewButtonComponent.tsx src/renderer/components/PrepareSteps +git commit -m "feat(preview): one preview label; preview box shows a full participant screen" +``` + +--- + +### Task 4: Shared surfaces, Rule A, verification and docs + +**Files:** +- Modify: `src/renderer/components/CollectComponent/PreTestComponent.tsx` (preview label now shared) +- Modify: `TODOS.md`, `.llms/learnings.md` + +- [ ] **Step 1: Collect preview uses the same label** + +`PreTestComponent`'s preview area and `PreviewButton` now share `PreviewLabel`. Verify the Design and Collect previews read identically at both sizes. If `PreTestComponent` has its own copy of the "nothing is being recorded" text, replace it. + +- [ ] **Step 2: Rule A measurement** + +For Overview, Background, Protocol (×3 fixtures) and Preview (stopped/running/finished), at 1366×768 and 1280×720: the step's primary action is inside the viewport and the page doesn't scroll. Record the numbers in the PR. If Background or Protocol scrolls internally, that's acceptable: the action row must stay visible. + +- [ ] **Step 3: Docs** + +`TODOS.md`: strike "Design preview box clips participant screens" (Task 3 fixes it) and note the WS4 Prepare integration shipped (`YYYY-MM-DD, PR #N`). Add to Next: "Multitasking's Prepare protocol diagram describes two rules on the same keys; verify with a teacher whether it reads clearly." + +Append to `.llms/learnings.md`: + +```markdown +## Prepare: `PrepareSteps` owns the built-in step chrome + +`DesignComponent` renders `PrepareSteps` for the four built-in studies; +Custom and Imported keep `SecondaryNavComponent` and their authoring steps. +Per-experiment data lives in each experiment's `prepare.ts` +(`responses` + `flow`), re-exported by `PrepareSteps/fixtures.ts`, so +Storybook shows what runs. Trial counts come from `flowFromStructure` and +the experiment's real loops — never `params.nbTrials`, which is stale for +Stroop (real 8/96) and Search (real 8/80). `PreviewLabel` is the single +preview status, used by `PreviewButtonComponent`. +``` + +- [ ] **Step 4: Full checks and the playtest** + +Run: `npm run typecheck && npm run lint && npm test && node tests/electron-smoke.mjs` → all green. + +Electron playtest (Fixture headset, `skill://electron-playtest`, screenshots in `/tmp/ws4-integration/`): +1. **Faces/Houses Design.** Gold pill step bar. Overview → Background → Protocol shows the face → 1 / house → 9 diagram and a vertical timeline reading 6 practice / 120 recorded. +2. **Stroop Design.** Diagram shows R/G/B/Y with the ink-color words; timeline reads 8 / 96. +3. **Visual Search Design.** Diagram shows B/N with the T examples; timeline reads 8 / 80. +4. **Multitasking Design.** Diagram shows its rules on B/N and a block timeline with no study-wide total. +5. **Preview.** `PreviewLabel` next to `Stop preview`; the participant screen and its keys are visible without scrolling. `Run & record` appears after a preview. +6. **Custom and Imported Design** still open their authoring flows. +7. **Keyboard.** Tab order reaches the step pills, the action row and the EEG toggle. + +- [ ] **Step 5: PR** + +```bash +git add TODOS.md .llms/learnings.md +git commit -m "docs: WS4 Prepare integration notes" +gh pr create --title "feat(prepare): WS4 Prepare integration" --body "Implements docs/superpowers/plans/2026-09-25-ws4-prepare-integration.md. Verification: ." +``` From fbda3b0a5e7babfb20ea5b4c452e2fb834901b2f Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Fri, 25 Sep 2026 16:11:55 -0400 Subject: [PATCH 2/9] feat(prepare): per-experiment protocol data and real trial counts --- .../PrepareSteps/PrepareSteps.stories.tsx | 18 +- .../components/PrepareSteps/PrepareSteps.tsx | 120 ++++++++++---- .../components/PrepareSteps/fixtures.ts | 104 +----------- src/renderer/components/PrepareSteps/flow.ts | 39 +++++ .../__tests__/participantScreens.test.ts | 43 +---- .../__tests__/prepareContent.test.ts | 155 ++++++++++++++++++ .../experiments/__tests__/studyTree.ts | 49 ++++++ .../experiments/faces_houses/prepare.ts | 26 +++ .../experiments/multitasking/prepare.ts | 57 +++++++ src/renderer/experiments/search/prepare.ts | 36 ++++ src/renderer/experiments/stroop/prepare.ts | 34 ++++ 11 files changed, 506 insertions(+), 175 deletions(-) create mode 100644 src/renderer/components/PrepareSteps/flow.ts create mode 100644 src/renderer/experiments/__tests__/prepareContent.test.ts create mode 100644 src/renderer/experiments/__tests__/studyTree.ts create mode 100644 src/renderer/experiments/faces_houses/prepare.ts create mode 100644 src/renderer/experiments/multitasking/prepare.ts create mode 100644 src/renderer/experiments/search/prepare.ts create mode 100644 src/renderer/experiments/stroop/prepare.ts diff --git a/src/renderer/components/PrepareSteps/PrepareSteps.stories.tsx b/src/renderer/components/PrepareSteps/PrepareSteps.stories.tsx index cb4e2d74..7caad4da 100644 --- a/src/renderer/components/PrepareSteps/PrepareSteps.stories.tsx +++ b/src/renderer/components/PrepareSteps/PrepareSteps.stories.tsx @@ -2,11 +2,11 @@ import React, { useState } from 'react'; import type { Decorator, Meta, StoryObj } from '@storybook/react-vite'; import { fn } from 'storybook/test'; import AppShell from '../AppShell/AppShell'; -import PrepareSteps, { PrepareStepId } from './PrepareSteps'; +import PrepareSteps, { PrepareFixture, PrepareStepId } from './PrepareSteps'; import { FACES_HOUSES, + MULTITASKING, NOOP_HANDLERS, - PrepareFixture, SACKS_STAND_IN, SEARCH, STROOP, @@ -32,6 +32,12 @@ const searchWorkspace = { modality: 'eeg' as const, }; +const multitaskingWorkspace = { + name: 'Multitasking_1', + experimentType: 'Multitasking', + modality: 'eeg' as const, +}; + /** * Wrap the story in the real AppShell chrome at Prepare, with Collect * recommended. This makes the local stepper visually subordinate to the @@ -124,6 +130,14 @@ export const ProtocolSearch: Story = { render: () => , }; +/** P05b — Multitasking protocol: two rules on the same b / n keys; a block timeline with no study-wide total. */ +export const ProtocolMultitasking: Story = { + parameters: { workspace: multitaskingWorkspace }, + render: () => ( + + ), +}; + /** P06 — PreviewStopped. Nothing recorded; Try the experiment is primary. */ export const PreviewStopped: Story = { render: () => , diff --git a/src/renderer/components/PrepareSteps/PrepareSteps.tsx b/src/renderer/components/PrepareSteps/PrepareSteps.tsx index 8a3a5fe6..a7d7c34c 100644 --- a/src/renderer/components/PrepareSteps/PrepareSteps.tsx +++ b/src/renderer/components/PrepareSteps/PrepareSteps.tsx @@ -2,18 +2,14 @@ import React from 'react'; import PreviewLabel from '../PreviewLabel'; import { Button } from '../ui/button'; import { cn } from '../ui/utils'; +import type { FlowPhase } from './flow'; export type PrepareStepId = 'overview' | 'background' | 'protocol' | 'preview'; -export interface FlowPhase { - label: string; - count?: number; -} - /** One stimulus → key pair, drawn in the Protocol diagram and the Preview key legend. */ export interface ResponseMapping { key: string; - /** What the participant is looking for, e.g. "Face" or "Red ink". */ + /** What the participant is looking for, e.g. "Face" or "Red ink". Rule-dependent tasks fold the rule in, e.g. "Top: diamond". */ label: string; /** An example stimulus: an image, or a word in colored ink (Stroop). */ stimulus: { src: string; alt: string } | { word: string; color: string }; @@ -21,7 +17,11 @@ export interface ResponseMapping { export interface PrepareStepsProps { step: PrepareStepId; - overview: { title: string; overview: string; links: { address: string; name: string }[] }; + overview: { + title: string; + overview: string; + links: { address: string; name: string }[]; + }; background: { links: { address: string; name: string }[]; title?: string; @@ -48,6 +48,12 @@ export interface PrepareStepsProps { hasPreviewed: boolean; } +/** One built-in experiment's Prepare content, defined in its `experiments//prepare.ts`. */ +export type PrepareFixture = Pick< + PrepareStepsProps, + 'overview' | 'background' | 'protocol' | 'responses' | 'flow' | 'icon' +>; + /** The single centered reading column shared by the stepper, step content and action row. */ const COLUMN = 'mx-auto w-full max-w-[800px] px-6'; @@ -127,7 +133,7 @@ function KeyLegend({ responses }: { responses: ResponseMapping[] }) { return (
    {responses.map(({ label, key }) => ( -
  • +
  • {key} {label}
  • @@ -154,7 +160,7 @@ function ResponseDiagram({ responses }: { responses: ResponseMapping[] }) { Presses {responses.map(({ key, label, stimulus }) => ( -
  • +
  • {'src' in stimulus ? ( @@ -276,7 +284,9 @@ function BackgroundView({
    BACKGROUND · 2 MIN READ -

    {background.title ?? 'Background'}

    +

    + {background.title ?? 'Background'} +

    {background.first_column_statement} @@ -298,20 +308,28 @@ function BackgroundView({

)} - {(background.second_column_statement || background.second_column_question) && ( + {(background.second_column_statement || + background.second_column_question) && (
FUN FACT

- {[background.second_column_statement, background.second_column_question] + {[ + background.second_column_statement, + background.second_column_question, + ] .filter(Boolean) .join(' ')}

{background.fun_fact_image && ( - + )}
)} @@ -327,7 +345,9 @@ function BackgroundView({ {link.name} {background.link_meta && ( - {background.link_meta} + + {background.link_meta} + )}
)) @@ -337,7 +357,11 @@ function BackgroundView({ } /** The video slot in Background's column, holding the local Sacks stand-in and its transcript-length text. */ -function OliverSacksFallback({ media }: { media: { caption: string; alt: string } }) { +function OliverSacksFallback({ + media, +}: { + media: { caption: string; alt: string }; +}) { return (

{media.caption}

@@ -365,14 +389,14 @@ function OliverSacksFallback({ media }: { media: { caption: string; alt: string

Scientists call this condition prosopagnosia, from the Greek for - "face" and "not knowing." It is not poor eyesight; the - brain's face-recognition system doesn't process faces the usual - way. + "face" and "not knowing." It is not poor eyesight; + the brain's face-recognition system doesn't process faces the + usual way.

- Sacks wrote about what this feels like, and about the idea that the brain - has a dedicated "face area." That idea led researchers to - compare the brain's responses to faces and to other objects. + Sacks wrote about what this feels like, and about the idea that the + brain has a dedicated "face area." That idea led researchers + to compare the brain's responses to faces and to other objects.

Source: Oliver Sacks, "Face-Blind" (2010),{' '} @@ -396,7 +420,9 @@ function ProtocolView({

PROTOCOL

{protocol.title}

-

{protocol.protocol}

+

+ {protocol.protocol} +

@@ -515,7 +541,9 @@ export default function PrepareSteps(props: PrepareStepsProps) {
PREVIEW -

{previewIntro.title}

+

+ {previewIntro.title} +

{previewIntro.body}

@@ -523,40 +551,62 @@ export default function PrepareSteps(props: PrepareStepsProps) {
)} - {previewRunning && } + {previewRunning && ( + + )}
{step === 'overview' && ( - onStep('background')}>Next: Background → + onStep('background')}> + Next: Background → + )} {step === 'background' && ( <> - onStep('overview')}>← Back - onStep('protocol')}>Next: Protocol → + onStep('overview')}> + ← Back + + onStep('protocol')}> + Next: Protocol → + )} {step === 'protocol' && ( <> - onStep('background')}>← Back - onStep('preview')}>Try the experiment → + onStep('background')}> + ← Back + + onStep('preview')}> + Try the experiment → + )} {previewStopped && ( <> - onStep('protocol')}>← Back - Try the experiment → + onStep('protocol')}> + ← Back + + + Try the experiment → + )} {previewRunning && ( <> - Stop preview + + Stop preview + )} {previewFinished && ( <> - Preview again - Run & record → + + Preview again + + + Run & record → + )} diff --git a/src/renderer/components/PrepareSteps/fixtures.ts b/src/renderer/components/PrepareSteps/fixtures.ts index 49581cc8..d4c78539 100644 --- a/src/renderer/components/PrepareSteps/fixtures.ts +++ b/src/renderer/components/PrepareSteps/fixtures.ts @@ -1,61 +1,9 @@ -import { overview as facesOverview } from '../../experiments/faces_houses/content_overview'; -import { background as facesBackground } from '../../experiments/faces_houses/content_background'; -import { protocol as facesProtocol } from '../../experiments/faces_houses/content_protocol'; -import { params as facesParams } from '../../experiments/faces_houses/params'; +import type { PrepareStepsProps } from './PrepareSteps'; -import { overview as stroopOverview } from '../../experiments/stroop/content_overview'; -import { background as stroopBackground } from '../../experiments/stroop/content_background'; -import { protocol as stroopProtocol } from '../../experiments/stroop/content_protocol'; - -import { overview as searchOverview } from '../../experiments/search/content_overview'; -import { background as searchBackground } from '../../experiments/search/content_background'; -import { protocol as searchProtocol } from '../../experiments/search/content_protocol'; - -import facesIcon from '../../experiments/faces_houses/icon.png'; -import face from '../../experiments/faces_houses/stimuli/faces/Face1.jpg'; -import house from '../../experiments/faces_houses/stimuli/houses/House1.jpg'; -import orangeT from '../../experiments/search/stimuli/conditionOrangeT.png'; -import noOrangeT from '../../experiments/search/stimuli/conditionNoOrangeT.png'; -import brad from '../../assets/common/brad.png'; - -import { FlowPhase, PrepareStepsProps } from './PrepareSteps'; - -export type PrepareFixture = Pick< - PrepareStepsProps, - 'overview' | 'background' | 'protocol' | 'responses' | 'flow' | 'icon' ->; - -function buildFlow(params: { - includePractice?: boolean; - nbPracticeTrials?: number; - nbTrials: number; -}): FlowPhase[] { - const phases: FlowPhase[] = [{ label: 'Instructions' }]; - if (params.includePractice && (params.nbPracticeTrials ?? 0) > 0) { - phases.push({ label: 'Practice trials', count: params.nbPracticeTrials }); - } - phases.push({ label: 'Main-task reminder' }); - phases.push({ label: 'Recorded trials', count: params.nbTrials }); - phases.push({ label: 'Completion' }); - return phases; -} - -/** Keys from faces_houses/params.ts stimuli (Face → 1, House → 9). */ -export const FACES_HOUSES: PrepareFixture = { - overview: facesOverview, - background: { ...facesBackground, fun_fact_image: brad }, - protocol: facesProtocol, - icon: facesIcon, - responses: [ - { key: '1', label: 'Face', stimulus: { src: face, alt: 'A face photo' } }, - { key: '9', label: 'House', stimulus: { src: house, alt: 'A house photo' } }, - ], - flow: buildFlow({ - includePractice: facesParams.includePractice, - nbPracticeTrials: facesParams.nbPracticeTrials, - nbTrials: facesParams.nbTrials, - }), -}; +export { prepare as FACES_HOUSES } from '../../experiments/faces_houses/prepare'; +export { prepare as STROOP } from '../../experiments/stroop/prepare'; +export { prepare as SEARCH } from '../../experiments/search/prepare'; +export { prepare as MULTITASKING } from '../../experiments/multitasking/prepare'; /** Faces/Houses Background's video slot, filled with the local Oliver Sacks stand-in. */ export const SACKS_STAND_IN: PrepareStepsProps['mediaFallback'] = { @@ -63,50 +11,10 @@ export const SACKS_STAND_IN: PrepareStepsProps['mediaFallback'] = { alt: 'Illustrated portrait of Oliver Sacks', }; -/** Keys and ink colors from the Stroop screen in stroop/experiment.ts; the word never matches the ink. */ -export const STROOP: PrepareFixture = { - overview: stroopOverview, - background: stroopBackground, - protocol: stroopProtocol, - responses: [ - { key: 'r', label: 'Red ink', stimulus: { word: 'green', color: 'red' } }, - { key: 'g', label: 'Green ink', stimulus: { word: 'blue', color: 'green' } }, - { key: 'b', label: 'Blue ink', stimulus: { word: 'yellow', color: 'blue' } }, - { key: 'y', label: 'Yellow ink', stimulus: { word: 'red', color: '#ffe32a' } }, - ], - // stroop/experiment.ts: 'Practice task' loop has 8 templateParameters (~l.99); 'Stroop task' samples n: '96' (~l.484). params.nbTrials is unused. - flow: buildFlow({ includePractice: true, nbPracticeTrials: 8, nbTrials: 96 }), -}; - -/** Keys from search/experiment.ts (b = orange T present, n = absent). */ -export const SEARCH: PrepareFixture = { - overview: searchOverview, - background: searchBackground, - protocol: searchProtocol, - responses: [ - { - key: 'b', - label: 'Orange T is there', - stimulus: { src: orangeT, alt: 'Letters with one right-side-up orange T' }, - }, - { - key: 'n', - label: 'No orange T', - stimulus: { src: noOrangeT, alt: 'Letters with no right-side-up orange T' }, - }, - ], - // search/utils.ts: constructTrials makes 8 per block; practice = 1 block (l.81), main = 10 blocks (l.77). params.nbTrials is unused. - flow: buildFlow({ includePractice: true, nbPracticeTrials: 8, nbTrials: 80 }), -}; - /** Shared no-op callbacks so stories do not need to supply handlers. */ export const NOOP_HANDLERS: Pick< PrepareStepsProps, - | 'onStep' - | 'onCollect' - | 'onPreviewStart' - | 'onPreviewStop' - | 'onPreviewAgain' + 'onStep' | 'onCollect' | 'onPreviewStart' | 'onPreviewStop' | 'onPreviewAgain' > = { onStep: () => {}, onCollect: () => {}, diff --git a/src/renderer/components/PrepareSteps/flow.ts b/src/renderer/components/PrepareSteps/flow.ts new file mode 100644 index 00000000..0c6b3bf9 --- /dev/null +++ b/src/renderer/components/PrepareSteps/flow.ts @@ -0,0 +1,39 @@ +/** One phase of a task's timeline, as shown in the protocol diagram. */ +export interface FlowPhase { + label: string; + /** Omit for a phase with no fixed count (e.g. instructions). */ + count?: number; +} + +/** A task's real loop structure. Multitasking uses `blocks`. */ +export interface LoopStructure { + /** Trials the participant practices before the recorded task. */ + practice?: number; + /** Trials recorded for analysis. */ + recorded?: number; + /** Block-structured tasks (Multitasking) instead of practice/recorded. */ + blocks?: Array<{ label: string; practice?: number; recorded?: number }>; +} + +/** + * Builds the vertical timeline from a task's real loop structure. Never reads + * `params.nbTrials`, which is stale for Stroop and Search. Block tasks list + * their blocks without a study-wide trial total. + */ +export function flowFromStructure(structure: LoopStructure): FlowPhase[] { + const blocks = structure.blocks ?? [ + { label: 'Practice trials', practice: structure.practice }, + { label: 'Recorded trials', recorded: structure.recorded }, + ]; + return [ + { label: 'Instructions' }, + ...blocks.flatMap(({ label, practice }) => + practice ? [{ label, count: practice }] : [] + ), + { label: 'Main-task reminder' }, + ...blocks.flatMap(({ label, recorded }) => + recorded ? [{ label, count: recorded }] : [] + ), + { label: 'Completion' }, + ]; +} diff --git a/src/renderer/experiments/__tests__/participantScreens.test.ts b/src/renderer/experiments/__tests__/participantScreens.test.ts index 25e455cc..af20b929 100644 --- a/src/renderer/experiments/__tests__/participantScreens.test.ts +++ b/src/renderer/experiments/__tests__/participantScreens.test.ts @@ -8,50 +8,13 @@ import { customExperiment } from '../custom/experiment'; import { customInstructionsScreen } from '../../utils/labjs/customStimuli'; import { skipPracticeOnRequest } from '../../utils/labjs/functions'; import type { ExperimentParameters } from '../../constants/interfaces'; +import { acceptedKeys, findNode, StudyNode } from './studyTree'; vi.mock('lab.js', () => ({})); -type Node = { - title?: string; - content?: unknown; - responses?: Record; - hooks?: 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 first screen with this title and string content. */ -const findScreen = (node: unknown, title: string): Node | undefined => { - if (Array.isArray(node)) { - for (const child of node) { - const found = findScreen(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; - for (const child of Object.values(node)) { - const found = findScreen(child, title); - if (found) return found; - } - } - return undefined; -}; +const findScreen = (node: unknown, title: string): StudyNode | undefined => + findNode(node, title, (screen) => typeof screen.content === 'string'); /** Response keycaps on a participant screen (Space and the Q skip hint excluded). */ const shownKeys = (html: string) => diff --git a/src/renderer/experiments/__tests__/prepareContent.test.ts b/src/renderer/experiments/__tests__/prepareContent.test.ts new file mode 100644 index 00000000..723a5e39 --- /dev/null +++ b/src/renderer/experiments/__tests__/prepareContent.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from 'vitest'; +import { flowFromStructure } from '../../components/PrepareSteps/flow'; +import { prepare as faces } from '../faces_houses/prepare'; +import { prepare as stroop } from '../stroop/prepare'; +import { prepare as search } from '../search/prepare'; +import { prepare as multi } from '../multitasking/prepare'; +import { facesHousesExperiment } from '../faces_houses/experiment'; +import { params as facesParams } from '../faces_houses/params'; +import { stroopExperiment } from '../stroop/experiment'; +import { searchExperimentObject } from '../search/experiment'; +import { multitaskingExperimentObject } from '../multitasking/experiment'; +import { acceptedKeys, findNode } from './studyTree'; + +vi.mock('lab.js', () => ({})); + +/** Keys shown in the protocol diagram. */ +const shownKeys = (p: { responses: { key: string }[] }) => + new Set(p.responses.map(({ key }) => key.toLowerCase())); + +/** Parameter rows a loop really runs: built by its `before:prepare` hook, or literal. */ +const loopRows = (study: unknown, title: string, parameters: object = {}) => { + const loop = findNode(study, title, (node) => 'template' in node); + const self = { + parameters, + options: { templateParameters: loop?.templateParameters ?? [] }, + }; + ( + loop?.hooks?.['before:prepare'] as ((this: typeof self) => void) | undefined + )?.call(self); + return { rows: self.options.templateParameters, n: Number(loop?.sample?.n) }; +}; + +/** Trials a loop runs: `sample.n` when set, otherwise one per parameter row. */ +const loopCount = (study: unknown, title: string, parameters?: object) => { + const { rows, n } = loopRows(study, title, parameters); + return n || rows.length; +}; + +describe('flowFromStructure', () => { + it('describes a linear task with practice and recorded trials', () => { + expect(flowFromStructure({ practice: 6, recorded: 120 })).toEqual([ + { label: 'Instructions' }, + { label: 'Practice trials', count: 6 }, + { label: 'Main-task reminder' }, + { label: 'Recorded trials', count: 120 }, + { label: 'Completion' }, + ]); + }); + + it('describes a block task without a study-wide total', () => { + const flow = flowFromStructure({ + blocks: [ + { label: 'Practice blocks', practice: 2 }, + { label: 'Recorded blocks', recorded: 4 }, + ], + }); + expect(flow).toEqual([ + { label: 'Instructions' }, + { label: 'Practice blocks', count: 2 }, + { label: 'Main-task reminder' }, + { label: 'Recorded blocks', count: 4 }, + { label: 'Completion' }, + ]); + }); +}); + +describe('per-experiment prepare content', () => { + it('Faces/Houses shows the keys its stimuli accept, and 6 practice / 120 recorded', () => { + expect(shownKeys(faces)).toEqual(new Set(['1', '9'])); + expect(shownKeys(faces)).toEqual( + new Set([ + ...acceptedKeys(facesHousesExperiment), + ...facesParams.stimuli.map(({ response }) => response), + ]) + ); + expect(loopCount(facesHousesExperiment, 'Practice loop', facesParams)).toBe( + 6 + ); + expect( + loopCount(facesHousesExperiment, 'Experiment loop', facesParams) + ).toBe(120); + expect(faces.flow).toContainEqual({ label: 'Practice trials', count: 6 }); + expect(faces.flow).toContainEqual({ label: 'Recorded trials', count: 120 }); + }); + + it('Stroop shows r/g/b/y, and 8 practice / 96 recorded', () => { + expect(shownKeys(stroop)).toEqual(new Set(['r', 'g', 'b', 'y'])); + expect(shownKeys(stroop)).toEqual(acceptedKeys(stroopExperiment)); + expect(loopCount(stroopExperiment, 'Practice task')).toBe(8); + expect(loopCount(stroopExperiment, 'Stroop task')).toBe(96); + expect(stroop.flow).toContainEqual({ label: 'Practice trials', count: 8 }); + expect(stroop.flow).toContainEqual({ label: 'Recorded trials', count: 96 }); + }); + + it('Visual Search shows b/n, and 8 practice / 80 recorded', () => { + expect(shownKeys(search)).toEqual(new Set(['b', 'n'])); + expect(shownKeys(search)).toEqual(acceptedKeys(searchExperimentObject)); + expect(loopCount(searchExperimentObject, 'Practice task')).toBe(8); + expect(loopCount(searchExperimentObject, 'Main task')).toBe(80); + expect(search.flow).toContainEqual({ label: 'Practice trials', count: 8 }); + expect(search.flow).toContainEqual({ label: 'Recorded trials', count: 80 }); + }); + + it('Multitasking shows its rules on b/n and never claims a study-wide total', () => { + expect(shownKeys(multi)).toEqual(new Set(['b', 'n'])); + expect(shownKeys(multi)).toEqual( + acceptedKeys(multitaskingExperimentObject) + ); + expect(multi.flow.some((p) => /recorded trials/i.test(p.label))).toBe( + false + ); + + const { rows: blocks } = loopRows( + multitaskingExperimentObject, + 'Block loop' + ); + expect(multi.flow.map(({ count }) => count).filter(Boolean)).toEqual([ + blocks.filter(({ task }) => task === 'training').length, + blocks.filter(({ task }) => task === 'main').length, + ]); + }); + + it('Multitasking maps each rule to the key its trials score as correct', () => { + const correctKey = ( + block: string, + match: (trial: Record) => boolean + ) => { + const { rows } = loopRows(multitaskingExperimentObject, 'Trial loop', { + block, + num_trials: 8, + }); + return rows.find(match)?.cor_response; + }; + expect(multi.responses).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + label: 'Top: diamond', + key: correctKey('shape', (t) => t.form === 'diamond'), + }), + expect.objectContaining({ + label: 'Top: rectangle', + key: correctKey('shape', (t) => t.form === 'square'), + }), + expect.objectContaining({ + label: 'Bottom: 2 dots', + key: correctKey('filling', (t) => t.dots === 2), + }), + expect.objectContaining({ + label: 'Bottom: 3 dots', + key: correctKey('filling', (t) => t.dots === 3), + }), + ]) + ); + }); +}); diff --git a/src/renderer/experiments/__tests__/studyTree.ts b/src/renderer/experiments/__tests__/studyTree.ts new file mode 100644 index 00000000..ce3bd991 --- /dev/null +++ b/src/renderer/experiments/__tests__/studyTree.ts @@ -0,0 +1,49 @@ +/** A lab.js study node, as far as these tests read it. */ +export type StudyNode = { + title?: string; + content?: unknown; + responses?: Record; + hooks?: Record; + template?: unknown; + templateParameters?: Array>; + sample?: { n?: string }; +}; + +/** Every non-Space, non-skip key any screen in the study responds to. */ +export 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 StudyNode).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 first node with this title that satisfies `accept`. */ +export const findNode = ( + node: unknown, + title: string, + accept: (candidate: StudyNode) => boolean +): StudyNode | undefined => { + if (Array.isArray(node)) { + for (const child of node) { + const found = findNode(child, title, accept); + if (found) return found; + } + } else if (node && typeof node === 'object') { + if ((node as StudyNode).title === title && accept(node as StudyNode)) + return node as StudyNode; + for (const child of Object.values(node)) { + const found = findNode(child, title, accept); + if (found) return found; + } + } + return undefined; +}; diff --git a/src/renderer/experiments/faces_houses/prepare.ts b/src/renderer/experiments/faces_houses/prepare.ts new file mode 100644 index 00000000..2d13c455 --- /dev/null +++ b/src/renderer/experiments/faces_houses/prepare.ts @@ -0,0 +1,26 @@ +import { overview } from './content_overview'; +import { background } from './content_background'; +import { protocol } from './content_protocol'; +import icon from './icon.png'; +import face from './stimuli/faces/Face1.jpg'; +import house from './stimuli/houses/House1.jpg'; +import type { PrepareFixture } from '../../components/PrepareSteps/PrepareSteps'; +import { flowFromStructure } from '../../components/PrepareSteps/flow'; + +/** What Prepare shows for Faces/Houses: its keys (params.ts stimuli) and its real 6 / 120 trials. */ +export const prepare: PrepareFixture = { + overview, + background, + protocol, + icon, + responses: [ + { key: '1', label: 'Face', stimulus: { src: face, alt: 'A face photo' } }, + { + key: '9', + label: 'House', + stimulus: { src: house, alt: 'A house photo' }, + }, + ], + // Practice and Experiment loops are sized by params.ts nbPracticeTrials 6 / nbTrials 120 (functions.ts initPracticeLoopWithStimuli / initLoopWithStimuli). + flow: flowFromStructure({ practice: 6, recorded: 120 }), +}; diff --git a/src/renderer/experiments/multitasking/prepare.ts b/src/renderer/experiments/multitasking/prepare.ts new file mode 100644 index 00000000..939d0096 --- /dev/null +++ b/src/renderer/experiments/multitasking/prepare.ts @@ -0,0 +1,57 @@ +import { overview } from './content_overview'; +import { background } from './content_background'; +import { protocol } from './content_protocol'; +import icon from './icon.png'; +import diamond2 from './stimuli/diamond_2.png'; +import diamond3 from './stimuli/diamond_3.png'; +import rect2 from './stimuli/rectangle_2.png'; +import rect3 from './stimuli/rectangle_3.png'; +import type { PrepareFixture } from '../../components/PrepareSteps/PrepareSteps'; +import { flowFromStructure } from '../../components/PrepareSteps/flow'; + +/** + * What Prepare shows for Multitasking: two rules on the same b / n keys + * (utils.ts initTasks: top = shape rule, bottom = dots rule) and its block + * structure, with no study-wide trial total. + */ +export const prepare: PrepareFixture = { + overview, + background, + protocol, + icon, + responses: [ + { + key: 'b', + label: 'Top: diamond', + stimulus: { src: diamond2, alt: 'A diamond; on top, answer the shape' }, + }, + { + key: 'n', + label: 'Top: rectangle', + stimulus: { src: rect3, alt: 'A rectangle; on top, answer the shape' }, + }, + { + key: 'b', + label: 'Bottom: 2 dots', + stimulus: { + src: rect2, + alt: 'Two dots; on the bottom, count the dots', + }, + }, + { + key: 'n', + label: 'Bottom: 3 dots', + stimulus: { + src: diamond3, + alt: 'Three dots; on the bottom, count the dots', + }, + }, + ], + // experiment.ts 'Block loop': 3 training blocks, then 3 main blocks (shape, dots, then both rules mixed). + flow: flowFromStructure({ + blocks: [ + { label: 'Practice blocks: shape, dots, then both', practice: 3 }, + { label: 'Recorded blocks: shape, dots, then both', recorded: 3 }, + ], + }), +}; diff --git a/src/renderer/experiments/search/prepare.ts b/src/renderer/experiments/search/prepare.ts new file mode 100644 index 00000000..59e7a4d8 --- /dev/null +++ b/src/renderer/experiments/search/prepare.ts @@ -0,0 +1,36 @@ +import { overview } from './content_overview'; +import { background } from './content_background'; +import { protocol } from './content_protocol'; +import icon from './icon.png'; +import orangeT from './stimuli/conditionOrangeT.png'; +import noOrangeT from './stimuli/conditionNoOrangeT.png'; +import type { PrepareFixture } from '../../components/PrepareSteps/PrepareSteps'; +import { flowFromStructure } from '../../components/PrepareSteps/flow'; + +/** What Prepare shows for Visual Search: b = orange T present, n = absent (experiment.ts), and its real 8 / 80 trials. */ +export const prepare: PrepareFixture = { + overview, + background, + protocol, + icon, + responses: [ + { + key: 'b', + label: 'Orange T is there', + stimulus: { + src: orangeT, + alt: 'Letters with one right-side-up orange T', + }, + }, + { + key: 'n', + label: 'No orange T', + stimulus: { + src: noOrangeT, + alt: 'Letters with no right-side-up orange T', + }, + }, + ], + // utils.ts: constructTrials makes 8 trials per block; practice = 1 block, main = 10 blocks. params.nbTrials is unused. + flow: flowFromStructure({ practice: 8, recorded: 80 }), +}; diff --git a/src/renderer/experiments/stroop/prepare.ts b/src/renderer/experiments/stroop/prepare.ts new file mode 100644 index 00000000..bd9ce39b --- /dev/null +++ b/src/renderer/experiments/stroop/prepare.ts @@ -0,0 +1,34 @@ +import { overview } from './content_overview'; +import { background } from './content_background'; +import { protocol } from './content_protocol'; +import icon from './icon.png'; +import type { PrepareFixture } from '../../components/PrepareSteps/PrepareSteps'; +import { flowFromStructure } from '../../components/PrepareSteps/flow'; + +/** What Prepare shows for Stroop: the ink-color keys from experiment.ts (the word never matches the ink) and its real 8 / 96 trials. */ +export const prepare: PrepareFixture = { + overview, + background, + protocol, + icon, + responses: [ + { key: 'r', label: 'Red ink', stimulus: { word: 'green', color: 'red' } }, + { + key: 'g', + label: 'Green ink', + stimulus: { word: 'blue', color: 'green' }, + }, + { + key: 'b', + label: 'Blue ink', + stimulus: { word: 'yellow', color: 'blue' }, + }, + { + key: 'y', + label: 'Yellow ink', + stimulus: { word: 'red', color: '#ffe32a' }, + }, + ], + // experiment.ts: 'Practice task' loop has 8 templateParameters; 'Stroop task' loop samples n: '96'. params.nbTrials is unused. + flow: flowFromStructure({ practice: 8, recorded: 96 }), +}; From e428693803d0c461e6b200d1778f5e31b32d824a Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Fri, 25 Sep 2026 16:17:33 -0400 Subject: [PATCH 3/9] feat(preview): one preview label; preview box shows a full participant screen --- .../PrepareSteps/PrepareSteps.stories.tsx | 99 ++++++++++++++++++- .../components/PrepareSteps/PrepareSteps.tsx | 34 +++++-- .../PreviewButtonComponent.stories.tsx | 42 ++++++++ .../components/PreviewButtonComponent.tsx | 20 ++-- .../__tests__/PreviewButton.test.tsx | 42 ++++++++ 5 files changed, 217 insertions(+), 20 deletions(-) create mode 100644 src/renderer/components/PreviewButtonComponent.stories.tsx create mode 100644 src/renderer/components/__tests__/PreviewButton.test.tsx diff --git a/src/renderer/components/PrepareSteps/PrepareSteps.stories.tsx b/src/renderer/components/PrepareSteps/PrepareSteps.stories.tsx index 7caad4da..c0180594 100644 --- a/src/renderer/components/PrepareSteps/PrepareSteps.stories.tsx +++ b/src/renderer/components/PrepareSteps/PrepareSteps.stories.tsx @@ -1,6 +1,14 @@ import React, { useState } from 'react'; import type { Decorator, Meta, StoryObj } from '@storybook/react-vite'; -import { fn } from 'storybook/test'; +import { expect, fn } from 'storybook/test'; +import { + instructionsScreen, + STILLNESS_LINE, +} from '../../experiments/shared/participantScreens'; +import { instructions as facesScreen } from '../../experiments/faces_houses/screens'; +import { instructions as stroopScreen } from '../../experiments/stroop/screens'; +import { instructions as searchScreen } from '../../experiments/search/screens'; +import { instructions as multitaskingScreen } from '../../experiments/multitasking/screens'; import AppShell from '../AppShell/AppShell'; import PrepareSteps, { PrepareFixture, PrepareStepId } from './PrepareSteps'; import { @@ -58,7 +66,21 @@ const withPrepareChrome: Decorator = (Story, { parameters }) => ( const meta: Meta = { title: 'Domain/PrepareSteps', component: PrepareSteps, - parameters: { layout: 'fullscreen' }, + parameters: { + layout: 'fullscreen', + viewport: { + options: { + rule1366: { + name: 'Rule A 1366×768', + styles: { width: '1366px', height: '768px' }, + }, + rule1280: { + name: 'Rule A 1280×720', + styles: { width: '1280px', height: '720px' }, + }, + }, + }, + }, decorators: [withPrepareChrome], args: { ...FACES_HOUSES, @@ -156,6 +178,79 @@ export const PreviewRunning: Story = { ), }; +/** Each built-in's first participant screen with its Prepare data; EEG on, so the stillness line makes it the tallest variant. */ +const FIRST_SCREENS = { + faces: { fixture: FACES_HOUSES, screen: facesScreen }, + stroop: { fixture: STROOP, screen: stroopScreen }, + search: { fixture: SEARCH, screen: searchScreen }, + multitasking: { fixture: MULTITASKING, screen: multitaskingScreen }, +}; + +/** + * The markup `PreviewExperimentComponent` → `LabjsExperimentWindow` mounts, + * with the first screen's content as lab.js renders it into the section. + */ +function ParticipantMount({ html }: { html: string }) { + return ( +
+
${STILLNESS_LINE}` + ), + }} + /> +
+ ); +} + +type FullPreviewStory = StoryObj<{ experiment: keyof typeof FIRST_SCREENS }>; + +/** P07b — PreviewRunningFull at 1366×768: a real first participant screen, keys and Space line visible with no scrolling. */ +export const PreviewRunningFull: FullPreviewStory = { + args: { experiment: 'faces' }, + argTypes: { + experiment: { control: 'select', options: Object.keys(FIRST_SCREENS) }, + }, + globals: { viewport: { value: 'rule1366', isRotated: false } }, + render: ({ experiment }) => ( + + } + /> + ), + play: async ({ canvasElement }) => { + const participant = canvasElement.querySelector('.bw-participant main'); + const page = canvasElement.querySelector('.experiment-design-content'); + const footer = canvasElement.querySelector('.bw-participant footer'); + await expect(participant?.scrollHeight).toBeLessThanOrEqual( + (participant?.clientHeight ?? 0) + 1 + ); + await expect(page?.scrollHeight).toBeLessThanOrEqual( + (page?.clientHeight ?? 0) + 1 + ); + await expect(footer?.getBoundingClientRect().bottom).toBeLessThanOrEqual( + window.innerHeight + ); + }, +}; + +/** P07c — PreviewRunningFull at 1280×720. */ +export const PreviewRunningFull720: FullPreviewStory = { + ...PreviewRunningFull, + globals: { viewport: { value: 'rule1280', isRotated: false } }, +}; /** P08 — PreviewFinished. Run & record is primary; Preview again secondary. */ export const PreviewFinished: Story = { render: () => ( diff --git a/src/renderer/components/PrepareSteps/PrepareSteps.tsx b/src/renderer/components/PrepareSteps/PrepareSteps.tsx index a7d7c34c..7b78cc74 100644 --- a/src/renderer/components/PrepareSteps/PrepareSteps.tsx +++ b/src/renderer/components/PrepareSteps/PrepareSteps.tsx @@ -46,6 +46,8 @@ export interface PrepareStepsProps { onPreviewAgain: () => void; isPreviewing: boolean; hasPreviewed: boolean; + /** The live participant screen shown while previewing (Design passes `PreviewExperimentComponent`); a placeholder when absent. */ + preview?: React.ReactNode; } /** One built-in experiment's Prepare content, defined in its `experiments//prepare.ts`. */ @@ -444,15 +446,30 @@ const PREVIEW_INTRO = { }, }; -function PreviewRunningView({ responses }: { responses: ResponseMapping[] }) { +/** + * The running preview fills the free height (at least 420px) and draws the + * participant screen at 0.55 zoom, so a screen laid out for a ~1366px-wide run + * area shows whole, without scrolling, in the 800px lesson column. + */ +function PreviewRunningView({ + responses, + preview, +}: { + responses: ResponseMapping[]; + preview?: React.ReactNode; +}) { return (
-
+
EXPERIMENT AREA -
- - Participant screen would appear here - +
+ {preview ? ( +
{preview}
+ ) : ( + + Participant screen would appear here + + )}
@@ -552,7 +569,10 @@ export default function PrepareSteps(props: PrepareStepsProps) {
)} {previewRunning && ( - + )}
diff --git a/src/renderer/components/PreviewButtonComponent.stories.tsx b/src/renderer/components/PreviewButtonComponent.stories.tsx new file mode 100644 index 00000000..1a2b28a5 --- /dev/null +++ b/src/renderer/components/PreviewButtonComponent.stories.tsx @@ -0,0 +1,42 @@ +import React, { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { fn } from 'storybook/test'; +import PreviewButton from './PreviewButtonComponent'; + +const meta: Meta = { + title: 'Domain/PreviewButton', + component: PreviewButton, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { isPreviewing: false, onClick: fn(), onRunAndRecord: fn() }, +}; +export default meta; +type Story = StoryObj; + +/** Before any preview: Preview experiment is primary; nothing is recorded. */ +export const Idle: Story = {}; + +/** While previewing: Stop preview beside the shared PreviewLabel (used by Custom, Imported and Collect). */ +export const Previewing: Story = { args: { isPreviewing: true } }; + +/** Stateful wrapper so the toggle really starts and stops a preview. */ +function InteractivePreviewButton() { + const [isPreviewing, setIsPreviewing] = useState(false); + return ( + setIsPreviewing((previewing) => !previewing)} + onRunAndRecord={() => {}} + /> + ); +} + +/** Clickable: after a preview, Run & record becomes primary and Preview again secondary. */ +export const Interactive: Story = { + render: () => , +}; diff --git a/src/renderer/components/PreviewButtonComponent.tsx b/src/renderer/components/PreviewButtonComponent.tsx index 155767c0..d08c5dc3 100644 --- a/src/renderer/components/PreviewButtonComponent.tsx +++ b/src/renderer/components/PreviewButtonComponent.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import { Button } from './ui/button'; +import PreviewLabel from './PreviewLabel'; interface Props { isPreviewing: boolean; @@ -11,7 +12,7 @@ interface Props { onRunAndRecord?: () => void; } -/** Preview toggle plus the persistent "nothing is recorded" preview status. */ +/** Preview toggle plus the shared preview status: `PreviewLabel` while previewing, a "nothing is recorded" note otherwise. */ export default function PreviewButton({ isPreviewing, onClick, @@ -43,16 +44,13 @@ export default function PreviewButton({ )} - - {isPreviewing ? ( - <> - PREVIEW · nothing is - being recorded - - ) : ( - 'Nothing is recorded during a preview.' - )} - + {isPreviewing ? ( + + ) : ( + + Nothing is recorded during a preview. + + )} ); } diff --git a/src/renderer/components/__tests__/PreviewButton.test.tsx b/src/renderer/components/__tests__/PreviewButton.test.tsx new file mode 100644 index 00000000..6060d297 --- /dev/null +++ b/src/renderer/components/__tests__/PreviewButton.test.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import PreviewButton from '../PreviewButtonComponent'; + +describe('PreviewButton', () => { + it('shows the one shared preview label while previewing', () => { + render(); + expect(screen.getByText('Preview')).toBeInTheDocument(); + expect(screen.getByText(/not recording/)).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Stop preview' }) + ).toBeInTheDocument(); + }); + + it('offers Run & record once a preview has run', () => { + const onRun = vi.fn(); + const { rerender } = render( + + ); + expect( + screen.queryByRole('button', { name: 'Run & record' }) + ).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Preview experiment' })); + rerender( + + ); + rerender( + + ); + fireEvent.click(screen.getByRole('button', { name: 'Run & record' })); + expect(onRun).toHaveBeenCalled(); + }); +}); From 772a737becc18aa04cd4c7826da1dcaa73a374a7 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Fri, 25 Sep 2026 16:21:52 -0400 Subject: [PATCH 4/9] feat(prepare): built-in Design screen renders the approved Prepare steps --- .../__tests__/DesignPrepare.test.tsx | 94 ++++++ .../components/DesignComponent/index.tsx | 313 +++--------------- .../PrepareSteps/PrepareSteps.stories.tsx | 29 +- .../components/PrepareSteps/PrepareSteps.tsx | 58 +++- .../components/PrepareSteps/fixtures.ts | 20 +- .../SecondaryNavComponent/index.tsx | 17 +- src/renderer/constants/interfaces.ts | 11 +- .../experiments/custom/content_protocol.js | 6 - .../faces_houses/content_protocol.js | 8 - .../experiments/faces_houses/index.ts | 2 + .../experiments/faces_houses/prepare.ts | 5 + src/renderer/experiments/imported/content.ts | 6 - .../multitasking/content_protocol.js | 9 - .../experiments/multitasking/index.ts | 2 + .../stimuli/multiConditionDots.png | Bin 7470 -> 0 bytes .../stimuli/multiConditionShape.png | Bin 8014 -> 0 bytes .../experiments/search/content_protocol.js | 7 - src/renderer/experiments/search/index.ts | 2 + .../experiments/stroop/content_protocol.js | 6 - src/renderer/experiments/stroop/index.ts | 2 + 20 files changed, 250 insertions(+), 347 deletions(-) create mode 100644 src/renderer/components/DesignComponent/__tests__/DesignPrepare.test.tsx delete mode 100644 src/renderer/experiments/multitasking/stimuli/multiConditionDots.png delete mode 100644 src/renderer/experiments/multitasking/stimuli/multiConditionShape.png diff --git a/src/renderer/components/DesignComponent/__tests__/DesignPrepare.test.tsx b/src/renderer/components/DesignComponent/__tests__/DesignPrepare.test.tsx new file mode 100644 index 00000000..b85ea712 --- /dev/null +++ b/src/renderer/components/DesignComponent/__tests__/DesignPrepare.test.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import Design, { DesignProps } from '../index'; +import { EXPERIMENTS } from '../../../constants/constants'; + +vi.mock('lab.js', () => ({})); +vi.mock('../../../utils/filesystem/storage', () => ({ + readWorkspaces: () => Promise.resolve([]), +})); +vi.mock('../CustomDesignComponent', () => ({ + default: () =>
, +})); +vi.mock('../ImportedDesignComponent', () => ({ + default: () =>
, +})); +vi.mock('../../PreviewExperimentComponent', () => ({ + default: () =>
, +})); + +const baseProps = { + navigate: vi.fn(), + type: EXPERIMENTS.N170, + title: 'Faces_Houses_1', + params: {}, + experimentObject: {}, + ExperimentActions: { + SetEEGEnabled: vi.fn(), + SaveWorkspace: vi.fn(), + CreateNewWorkspace: vi.fn(), + }, + isEEGEnabled: true, +} as unknown as DesignProps; + +describe('Design — built-in Prepare', () => { + it('renders the Prepare steps with the experiment keys and the real trial counts', () => { + render(); + expect( + screen.getByRole('navigation', { name: 'Prepare steps' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /Next: Background/ }) + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Protocol/ })); + expect(screen.getByText('Face')).toBeInTheDocument(); + expect(screen.getByText('House')).toBeInTheDocument(); + expect(screen.getByText('6').parentElement).toHaveTextContent( + '6 practice trials' + ); + expect(screen.getByText('120').parentElement).toHaveTextContent( + '120 recorded trials' + ); + }); + + it('walks Overview → Background → Protocol → Preview and offers Try the experiment', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /Next: Background/ })); + expect(screen.getByText(/BACKGROUND/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Next: Protocol/ })); + expect(screen.getByText('WHAT HAPPENS IN THIS TASK')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Try the experiment/ })); + expect(screen.queryByTestId('preview-experiment')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Try the experiment/ })); + expect(screen.getByTestId('preview-experiment')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Stop preview' })); + fireEvent.click(screen.getByRole('button', { name: /Run & record/ })); + expect(baseProps.navigate).toHaveBeenCalledWith('/collect'); + }); + + it('keeps the EEG toggle, saving the workspace choice', () => { + render(); + fireEvent.click(screen.getByRole('checkbox', { name: 'EEG recording' })); + expect(baseProps.ExperimentActions.SetEEGEnabled).toHaveBeenCalledWith( + false + ); + expect(baseProps.ExperimentActions.SaveWorkspace).toHaveBeenCalled(); + }); + + it('keeps Custom on its authoring flow', () => { + render(); + expect(screen.getByTestId('custom-design')).toBeInTheDocument(); + expect( + screen.queryByRole('navigation', { name: 'Prepare steps' }) + ).not.toBeInTheDocument(); + }); + + it('keeps Imported on its authoring flow', () => { + render(); + expect(screen.getByTestId('imported-design')).toBeInTheDocument(); + expect( + screen.queryByRole('navigation', { name: 'Prepare steps' }) + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/renderer/components/DesignComponent/index.tsx b/src/renderer/components/DesignComponent/index.tsx index 0d820f5c..50b31be9 100644 --- a/src/renderer/components/DesignComponent/index.tsx +++ b/src/renderer/components/DesignComponent/index.tsx @@ -1,5 +1,4 @@ import React, { useState, useEffect } from 'react'; -import { Button } from '../ui/button'; import { toast } from 'react-toastify'; import { EXPERIMENTS, SCREENS } from '../../constants/constants'; import { readWorkspaces } from '../../utils/filesystem/storage'; @@ -7,37 +6,14 @@ import { ExperimentObject, ExperimentParameters, } from '../../constants/interfaces'; -import SecondaryNavComponent from '../SecondaryNavComponent'; import PreviewExperimentComponent from '../PreviewExperimentComponent'; -import PreviewButton from '../PreviewButtonComponent'; +import PrepareSteps, { PrepareStepId } from '../PrepareSteps/PrepareSteps'; import CustomDesign from './CustomDesignComponent'; import ImportedDesign from './ImportedDesignComponent'; - -import facesHousesOverview from '../../experiments/faces_houses/icon.png'; -import stroopOverview from '../../experiments/stroop/icon.png'; -import multitaskingOverview from '../../experiments/multitasking/icon.png'; -import searchOverview from '../../experiments/search/icon.png'; - -import multiConditionShape from '../../experiments/multitasking/stimuli/multiConditionShape.png'; -import multiConditionDots from '../../experiments/multitasking/stimuli/multiConditionDots.png'; -import conditionFace from '../../experiments/faces_houses/stimuli/faces/Face1.jpg'; -import conditionHouse from '../../experiments/faces_houses/stimuli/houses/House1.jpg'; -import conditionOrangeT from '../../experiments/search/stimuli/conditionOrangeT.png'; -import conditionNoOrangeT from '../../experiments/search/stimuli/conditionNoOrangeT.png'; -import conditionCongruent from '../../experiments/stroop/stimuli/match_g.png'; -import conditionIncongruent from '../../experiments/stroop/stimuli/mismatch6_r.png'; - import InputModal from '../InputModal'; import { ExperimentActions } from '../../actions'; import { getExperimentFromType } from '../../utils/labjs/functions'; -const DESIGN_STEPS = { - OVERVIEW: 'OVERVIEW', - BACKGROUND: 'BACKGROUND', - PROTOCOL: 'PROTOCOL', - PREVIEW: 'PREVIEW', -}; - export interface DesignProps { navigate: (path: string) => void; type: EXPERIMENTS; @@ -48,46 +24,16 @@ export interface DesignProps { isEEGEnabled: boolean; } -function renderConditionIcon(condition) { - switch (condition) { - case 'conditionCongruent': - return conditionCongruent; - case 'conditionIncongruent': - return conditionIncongruent; - case 'conditionOrangeT': - return conditionOrangeT; - case 'conditionNoOrangeT': - return conditionNoOrangeT; - case 'conditionFace': - return conditionFace; - case 'conditionHouse': - return conditionHouse; - case 'multiConditionShape': - return multiConditionShape; - case 'multiConditionDots': - default: - return multiConditionDots; - } -} - -function renderOverviewIcon(type: EXPERIMENTS): string | undefined { - switch (type) { - case EXPERIMENTS.N170: - return facesHousesOverview; - case EXPERIMENTS.STROOP: - return stroopOverview; - case EXPERIMENTS.MULTI: - return multitaskingOverview; - case EXPERIMENTS.SEARCH: - return searchOverview; - default: - return undefined; - } -} - +/** + * The Prepare area's Design screen. Built-in experiments walk the approved + * `PrepareSteps` lesson (Overview → Background → Protocol → Preview) from + * their `prepare.ts`; experiments without one (Custom, Imported) keep their + * own authoring flows. + */ export default function Design(props: DesignProps) { - const [activeStep, setActiveStep] = useState(DESIGN_STEPS.OVERVIEW); + const [activeStep, setActiveStep] = useState('overview'); const [isPreviewing, setIsPreviewing] = useState(false); + const [hasPreviewed, setHasPreviewed] = useState(false); const [isNewExperimentModalOpen, setIsNewExperimentModalOpen] = useState(false); const [recentWorkspaces, setRecentWorkspaces] = useState([]); @@ -102,12 +48,14 @@ export default function Design(props: DesignProps) { }; }, []); - if (props.type === EXPERIMENTS.CUSTOM) { - return ; - } + const { prepare } = getExperimentFromType(props.type); - if (props.type === EXPERIMENTS.IMPORTED) { - return ; + if (!prepare) { + return props.type === EXPERIMENTS.CUSTOM ? ( + + ) : ( + + ); } function handleLoadCustomExperiment(title: string) { @@ -127,225 +75,46 @@ export default function Design(props: DesignProps) { props.ExperimentActions.SaveWorkspace(); } - function handlePreview(event: React.MouseEvent) { - event.currentTarget.blur(); - setIsPreviewing((previewing) => !previewing); - } - function handleEEGEnabled(enabled: boolean) { props.ExperimentActions.SetEEGEnabled(enabled); props.ExperimentActions.SaveWorkspace(); } - function renderSectionContent() { - const { - text: { overview, protocol, background }, - } = getExperimentFromType(props.type); - const overviewIcon = renderOverviewIcon(props.type); - - switch (activeStep) { - case DESIGN_STEPS.BACKGROUND: - return ( -
-
- - BACKGROUND · 2 MIN READ - -

- {background.title ?? 'Background'} -

-
-

- {background.first_column_statement} -

- {background.first_column_question && ( -
- {overviewIcon && ( -
- -
- )} -
-

- {background.definition_title ?? 'What researchers found'} -

-

- {background.first_column_question} -

-
-
- )} - {(background.second_column_statement || - background.second_column_question) && ( -
-
- - FUN FACT - -

- {[ - background.second_column_statement, - background.second_column_question, - ] - .filter(Boolean) - .join(' ')} -

-
- {background.fun_fact_image && ( - - )} -
- )} - {background.links.map((link) => ( -
- - {background.link_meta && ( - - {background.link_meta} - - )} -
- ))} -
- ); - - case DESIGN_STEPS.PROTOCOL: - return ( -
-
- - PROTOCOL - -

{protocol.title}

-

{protocol.protocol}

-
-
- {[ - { - image: protocol.condition_first_img, - title: protocol.condition_first_title, - description: protocol.condition_first, - key: protocol.condition_first_key, - }, - { - image: protocol.condition_second_img, - title: protocol.condition_second_title, - description: protocol.condition_second, - key: protocol.condition_second_key, - }, - ].map((condition) => ( -
- {condition.title} -

{condition.title}

-

- {condition.description}{' '} - {condition.key && ( - - {condition.key} - - )} -

-
- ))} -
-
- ); - - case DESIGN_STEPS.PREVIEW: - return ( -
-
- - PREVIEW - -

See it the way they will

-
-
- setIsPreviewing(false)} - type={props.type} - /> -
- props.navigate(SCREENS.COLLECT.route)} - /> -
- ); - - case DESIGN_STEPS.OVERVIEW: - default: - return ( -
-
- - THE BIG QUESTION - -

{overview.title}

- {overview.overview.split(/\n\s*\n/).map((paragraph) => ( -

- {paragraph} -

- ))} -
- {overviewIcon && ( -
- {overview.title} -
- )} -
- ); - } - } - return ( -
- + setIsNewExperimentModalOpen(true)} + onCollect={() => props.navigate(SCREENS.COLLECT.route)} + onPreviewStart={() => { + setIsPreviewing(true); + setHasPreviewed(true); + }} + onPreviewStop={() => setIsPreviewing(false)} + onPreviewAgain={() => setIsPreviewing(true)} + isPreviewing={isPreviewing} + hasPreviewed={hasPreviewed} + preview={ + setIsPreviewing(false)} + type={props.type} + /> + } /> -
-
{renderSectionContent()}
-
setIsNewExperimentModalOpen(false)} header="Enter a title for this experiment" /> -
+ ); } diff --git a/src/renderer/components/PrepareSteps/PrepareSteps.stories.tsx b/src/renderer/components/PrepareSteps/PrepareSteps.stories.tsx index c0180594..fb6c895e 100644 --- a/src/renderer/components/PrepareSteps/PrepareSteps.stories.tsx +++ b/src/renderer/components/PrepareSteps/PrepareSteps.stories.tsx @@ -15,7 +15,6 @@ import { FACES_HOUSES, MULTITASKING, NOOP_HANDLERS, - SACKS_STAND_IN, SEARCH, STROOP, } from './fixtures'; @@ -91,6 +90,9 @@ const meta: Meta = { onPreviewStart: fn(), onPreviewStop: fn(), onPreviewAgain: fn(), + isEEGEnabled: true, + onEEGEnabledChange: fn(), + onCustomize: fn(), }, }; export default meta; @@ -107,9 +109,13 @@ function InteractiveStep({ const [step, setStep] = useState(initialStep); const [isPreviewing, setIsPreviewing] = useState(false); const [hasPreviewed, setHasPreviewed] = useState(false); + const [isEEGEnabled, setIsEEGEnabled] = useState(true); return ( {}} step={step} isPreviewing={isPreviewing} hasPreviewed={hasPreviewed} @@ -130,9 +136,10 @@ export const Overview: Story = { render: () => , }; -/** P02 — Background. Centered lesson column with Back and Next: Protocol. */ +/** P02 — Background. Centered lesson column with Back and Next: Protocol; Stroop's video link (Faces/Houses uses the Sacks stand-in, P10). */ export const Background: Story = { - render: () => , + parameters: { workspace: stroopWorkspace }, + render: () => , }; /** P03 — Protocol. Static stimulus → key diagram beside a vertical task timeline generated from `flow`. */ @@ -274,6 +281,21 @@ export const DirectCollect: Story = { render: () => , }; +/** P11 — EEGOff. The action row's EEG recording switch off (behavior-only workspace); Customize beside it. */ +export const EEGOff: Story = { + parameters: { workspace: behaviorWorkspace }, + render: () => ( + + ), +}; + /** P10 — OliverSacksFallback. Background's video slot holds the local stand-in (16:9 placeholder) and transcript-length text; no remote player. */ export const OliverSacksFallback: Story = { render: () => ( @@ -281,7 +303,6 @@ export const OliverSacksFallback: Story = { {...FACES_HOUSES} {...NOOP_HANDLERS} step="background" - mediaFallback={SACKS_STAND_IN} isPreviewing={false} hasPreviewed={false} /> diff --git a/src/renderer/components/PrepareSteps/PrepareSteps.tsx b/src/renderer/components/PrepareSteps/PrepareSteps.tsx index 7b78cc74..e33e9ac4 100644 --- a/src/renderer/components/PrepareSteps/PrepareSteps.tsx +++ b/src/renderer/components/PrepareSteps/PrepareSteps.tsx @@ -46,6 +46,11 @@ export interface PrepareStepsProps { onPreviewAgain: () => void; isPreviewing: boolean; hasPreviewed: boolean; + /** Workspace EEG setting; with `onEEGEnabledChange`, the action row shows an "EEG recording" switch. */ + isEEGEnabled?: boolean; + onEEGEnabledChange?: (enabled: boolean) => void; + /** Offers "Customize": start a new Custom experiment (Design asks for its name). */ + onCustomize?: () => void; /** The live participant screen shown while previewing (Design passes `PreviewExperimentComponent`); a placeholder when absent. */ preview?: React.ReactNode; } @@ -53,7 +58,13 @@ export interface PrepareStepsProps { /** One built-in experiment's Prepare content, defined in its `experiments//prepare.ts`. */ export type PrepareFixture = Pick< PrepareStepsProps, - 'overview' | 'background' | 'protocol' | 'responses' | 'flow' | 'icon' + | 'overview' + | 'background' + | 'protocol' + | 'responses' + | 'flow' + | 'icon' + | 'mediaFallback' >; /** The single centered reading column shared by the stepper, step content and action row. */ @@ -515,6 +526,36 @@ function ActionBack({ ); } +/** The workspace's EEG recording switch: a native checkbox named "EEG recording", drawn as a toggle. */ +function EEGSwitch({ + checked, + onChange, +}: { + checked: boolean; + onChange: (enabled: boolean) => void; +}) { + return ( + + ); +} + /** Built-in lesson steps of the Prepare area: Overview, Background, Protocol, Preview. Pure props. */ export default function PrepareSteps(props: PrepareStepsProps) { const { step, onStep, isPreviewing, hasPreviewed } = props; @@ -629,6 +670,21 @@ export default function PrepareSteps(props: PrepareStepsProps) { )} + {(props.onEEGEnabledChange || props.onCustomize) && ( +
+ {props.onEEGEnabledChange && ( + + )} + {props.onCustomize && ( + + )} +
+ )}
diff --git a/src/renderer/components/PrepareSteps/fixtures.ts b/src/renderer/components/PrepareSteps/fixtures.ts index d4c78539..31190c70 100644 --- a/src/renderer/components/PrepareSteps/fixtures.ts +++ b/src/renderer/components/PrepareSteps/fixtures.ts @@ -5,20 +5,24 @@ export { prepare as STROOP } from '../../experiments/stroop/prepare'; export { prepare as SEARCH } from '../../experiments/search/prepare'; export { prepare as MULTITASKING } from '../../experiments/multitasking/prepare'; -/** Faces/Houses Background's video slot, filled with the local Oliver Sacks stand-in. */ -export const SACKS_STAND_IN: PrepareStepsProps['mediaFallback'] = { - caption: 'Oliver Sacks on face blindness', - alt: 'Illustrated portrait of Oliver Sacks', -}; - -/** Shared no-op callbacks so stories do not need to supply handlers. */ +/** Shared no-op callbacks, with EEG on, so stories show the Design screen's full action row. */ export const NOOP_HANDLERS: Pick< PrepareStepsProps, - 'onStep' | 'onCollect' | 'onPreviewStart' | 'onPreviewStop' | 'onPreviewAgain' + | 'onStep' + | 'onCollect' + | 'onPreviewStart' + | 'onPreviewStop' + | 'onPreviewAgain' + | 'isEEGEnabled' + | 'onEEGEnabledChange' + | 'onCustomize' > = { onStep: () => {}, onCollect: () => {}, onPreviewStart: () => {}, onPreviewStop: () => {}, onPreviewAgain: () => {}, + isEEGEnabled: true, + onEEGEnabledChange: () => {}, + onCustomize: () => {}, }; diff --git a/src/renderer/components/SecondaryNavComponent/index.tsx b/src/renderer/components/SecondaryNavComponent/index.tsx index 8d96b187..b58ad947 100644 --- a/src/renderer/components/SecondaryNavComponent/index.tsx +++ b/src/renderer/components/SecondaryNavComponent/index.tsx @@ -34,7 +34,6 @@ interface SettingsDropdownProps { enableEEGToggle?: JSX.Element; isEEGEnabled?: boolean; onEEGEnabledChange?: (enabled: boolean) => void; - onCustomize?: () => void; saveButton?: JSX.Element; homeRoute: string; } @@ -43,7 +42,6 @@ function SettingsDropdown({ enableEEGToggle, isEEGEnabled, onEEGEnabledChange, - onCustomize, saveButton, homeRoute, }: SettingsDropdownProps) { @@ -131,14 +129,6 @@ function SettingsDropdown({ ) )} - {onCustomize && ( - - Customize this experiment - - )} Back to Home @@ -157,9 +147,13 @@ interface Props { enableEEGToggle?: JSX.Element; isEEGEnabled?: boolean; onEEGEnabledChange?: (enabled: boolean) => void; - onCustomize?: () => void; } +/** + * Tab bar with experiment settings for the Custom and Imported authoring + * flows (and Analyze). Built-in experiments' Prepare lesson uses + * `PrepareSteps` instead. + */ export default function SecondaryNavComponent(props: Props) { const redesigned = props.isEEGEnabled !== undefined; @@ -196,7 +190,6 @@ export default function SecondaryNavComponent(props: Props) { enableEEGToggle={props.enableEEGToggle} isEEGEnabled={props.isEEGEnabled} onEEGEnabledChange={props.onEEGEnabledChange} - onCustomize={props.onCustomize} saveButton={props.saveButton} homeRoute={SCREENS.HOME.route} /> diff --git a/src/renderer/constants/interfaces.ts b/src/renderer/constants/interfaces.ts index e34ca255..a1ad26a7 100644 --- a/src/renderer/constants/interfaces.ts +++ b/src/renderer/constants/interfaces.ts @@ -4,6 +4,7 @@ import { ChildProcess } from 'child_process'; import { EVENTS, EXPERIMENTS, SIGNAL_QUALITY } from './constants'; +import type { PrepareFixture } from '../components/PrepareSteps/PrepareSteps'; // -------------------------------------------------------------------- // Experiment @@ -133,14 +134,6 @@ interface BackgroundText { interface ProtocolText { title: string; protocol: string; - condition_first_img: string; // image URL from Vite import - condition_first_title: string; - condition_first: string; - condition_second_img: string; // image URL from Vite import - condition_second_title: string; - condition_second: string; - condition_first_key?: string; - condition_second_key?: string; /** * Pre-run pacing coaching. Only set where the protocol allows it: reaction * time is an analysed outcome in several experiments. @@ -158,6 +151,8 @@ export interface Experiment { overview: OverviewText; protocol: ProtocolText; }; + /** Built-ins only: what the Prepare lesson shows (keys, trial timeline). Custom and Imported author their own. */ + prepare?: PrepareFixture; } // -------------------------------------------------------------------- diff --git a/src/renderer/experiments/custom/content_protocol.js b/src/renderer/experiments/custom/content_protocol.js index e0fd4d82..356fd434 100644 --- a/src/renderer/experiments/custom/content_protocol.js +++ b/src/renderer/experiments/custom/content_protocol.js @@ -1,11 +1,5 @@ export const protocol = { title: `What participants are shown`, protocol: `This is custom experiment, so it's up to you`, - condition_first_img: `conditionFace`, - condition_first_title: `Faces`, - condition_first: `When participants see a face, they should press the key “1”.`, - condition_second_img: `conditionHouse`, - condition_second_title: `Houses`, - condition_second: `If participants see a house, they should press “9”.`, links: [], }; diff --git a/src/renderer/experiments/faces_houses/content_protocol.js b/src/renderer/experiments/faces_houses/content_protocol.js index 05da6f02..ba006543 100644 --- a/src/renderer/experiments/faces_houses/content_protocol.js +++ b/src/renderer/experiments/faces_houses/content_protocol.js @@ -1,14 +1,6 @@ export const protocol = { title: `What participants are shown`, protocol: `Participants see a stream of faces and houses, and answer with one of two keys.`, - condition_first_img: `conditionFace`, - condition_first_title: `Faces`, - condition_first: `Sees a face → press`, - condition_first_key: `1`, - condition_second_img: `conditionHouse`, - condition_second_title: `Houses`, - condition_second: `Sees a house → press`, - condition_second_key: `9`, pacing: `This isn't a speed test. Take about 1–1.5 seconds per picture and answer carefully.`, links: [], }; diff --git a/src/renderer/experiments/faces_houses/index.ts b/src/renderer/experiments/faces_houses/index.ts index f27adeb0..3791e510 100644 --- a/src/renderer/experiments/faces_houses/index.ts +++ b/src/renderer/experiments/faces_houses/index.ts @@ -4,11 +4,13 @@ import { background } from './content_background'; import { protocol } from './content_protocol'; import { overview } from './content_overview'; import icon from './icon.png'; +import { prepare } from './prepare'; export default { icon, experimentObject, params, + prepare, text: { protocol, background, diff --git a/src/renderer/experiments/faces_houses/prepare.ts b/src/renderer/experiments/faces_houses/prepare.ts index 2d13c455..f71adef2 100644 --- a/src/renderer/experiments/faces_houses/prepare.ts +++ b/src/renderer/experiments/faces_houses/prepare.ts @@ -13,6 +13,11 @@ export const prepare: PrepareFixture = { background, protocol, icon, + // §6.3: Oliver Sacks clip rights are unconfirmed, so Background shows the local illustrated stand-in, not a remote player. + mediaFallback: { + caption: 'Oliver Sacks on face blindness', + alt: 'Illustrated portrait of Oliver Sacks', + }, responses: [ { key: '1', label: 'Face', stimulus: { src: face, alt: 'A face photo' } }, { diff --git a/src/renderer/experiments/imported/content.ts b/src/renderer/experiments/imported/content.ts index 21b35dd7..0dcd5985 100644 --- a/src/renderer/experiments/imported/content.ts +++ b/src/renderer/experiments/imported/content.ts @@ -33,11 +33,5 @@ export const protocol = { title: `What participants are shown`, protocol: `Whatever the imported file shows them. Use the Preview tab to watch it run before you record anyone.`, - condition_first_img: ``, - condition_first_title: ``, - condition_first: ``, - condition_second_img: ``, - condition_second_title: ``, - condition_second: ``, links: [], }; diff --git a/src/renderer/experiments/multitasking/content_protocol.js b/src/renderer/experiments/multitasking/content_protocol.js index 89aafcc3..1a236005 100644 --- a/src/renderer/experiments/multitasking/content_protocol.js +++ b/src/renderer/experiments/multitasking/content_protocol.js @@ -3,15 +3,6 @@ export const protocol = { protocol: `Participants are shown either a square or diamonds with dots inside. The location of the object on the screen indicates which rule the participant needs to follow.`, - condition_first_img: `multiConditionShape`, - condition_first_title: `Rule 1`, - condition_first: `If the object is shown on top, participants need to - respond to the shape (pressing ‘n’ for square and ‘b’ for diamond).`, - condition_second_img: `multiConditionDots`, - condition_second_title: `Rule 2`, - condition_second: `If the object is shown on the bottom, they need to - respond to the number of dots inside (pressing ‘n’ for 3 dots and ‘b’ - for 2 dots). `, pacing: `Speed counts here: answer as fast as you can without making errors.`, links: [], }; diff --git a/src/renderer/experiments/multitasking/index.ts b/src/renderer/experiments/multitasking/index.ts index 8f9d4b31..456d92e3 100644 --- a/src/renderer/experiments/multitasking/index.ts +++ b/src/renderer/experiments/multitasking/index.ts @@ -5,11 +5,13 @@ import { protocol } from './content_protocol'; import { overview } from './content_overview'; import { multitaskingExperimentObject as experimentObject } from './experiment'; import icon from './icon.png'; +import { prepare } from './prepare'; export default { icon, experimentObject, params, + prepare, text: { protocol, background, diff --git a/src/renderer/experiments/multitasking/stimuli/multiConditionDots.png b/src/renderer/experiments/multitasking/stimuli/multiConditionDots.png deleted file mode 100644 index 470080c54b86f4c88c720c1b2d01ec9982192b68..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7470 zcmeHMi$Bx*|0i{n)GCRiWlE@{xm2$0QYnt(vQoLs-BK7a*O{3dA<1?sp_>&4mCM|T zHp*pmA=jzovdNtpW*cU|kIwm>?|J-wf5PwQ@p${Z_I_R8ulMEiemy@)=WH#u$*IUm zNl9(9{Pm0-@GJ&e&gP$h|EmQN;Zjo4X1-=-=Pb?4)XoJ5Uh(z!mXbPj2jk(f%ks#f z7EezPkCu~7w?ZRZj5q5fmNbYrvp!=%=xO=M|l#7PZ#JrzY_ZFBwREZwQ! zKfJ>XjQ8MHhSQqEmu%7$t?yX1SZix4$i8=XVH07k8)OYgt8k~=xQUCsH= z&EtpEqPE|BvfVRKs^p&9w7Q4E{!?uym$s=ntC^_p^!bUiRn1vzizRGd(zXhvjn(_& z%;FR`Jih$_y16<{{@h*k`3+f57PkJ9e6;J>Z@a;<&r)9wJxDLe%l5eJ;nP|#B^PpY zvfZfOv-MDO?2h*vCXKdiI_k31%Xi^tTtY@{Y{)U0hG$WmlFlXt$?B0X zhi#7;{$Bj5>X=@!?TSPaVk{$5t0~hkI3R0|+@aH8oi9 zWPP5 z3sL{e$bZ{8;~jzt_6-X44ZNndZr9T*Ff0^);J~`0fByc8(;M&mA5Ygp{vH-EK;3nY zuD*_*?mxBxRoHs3={aA#x4+{V-vA&zfDeSeks<7d{Qu(o$Kzj`mqNUQ%>n}e#Zbh5 z^82^)znTA2@rO&Nf4MX?__xb{asJkX>8_9bUzzwT%|Cj9JR{^_y8p}>LhgBL?uwMu z&t8^iPF=)H&pa`qcsG^C8`&I_J9tG}u>WAaN2t`+En8(|I(Df_MqT&60ZHAU#NCMN zP_CX^+Yr|6p1hRMhtV1eQ+X#3cFPMaPQ$K3RsH*$DKdl4+|x*UKA~mR(VWh zu8T=*NazNwwhl(w%;S1k>^($cR~~A`oBg=HFhoE`XibSnHWbybL zhl{@hoIn$C0UQ2?RZls86M*Avi^PIZYM{Bf%@;i01Dt{2 zWv3|ohRExQ78x6yZ2?YNSNoR+g%%kY?CtKpV1QHgnAEG92{;_ynOmO*a9)33Ec^dZ z{*Pp3=(5f5;zU=)x#f@z%qts2azuZ|?$0ec>+9fs_tCLF>!-_m*xfHKoG=nGAu-lt zPa*~m=5I<92RgozG>Ded2YTCR_2A=37Y!;iT~GE>FVC(mO@HZ^DLgBa*iMe_-ag|v?1Y8vg zD{AN@fIu? z2jej84pSy>q^F3k#atPpx@iA;e!d!!euR$cspV8FdG+l-K3A)9<;!KzamVAfPehA|B`~7j8UTIAub17O~r8GY+B-iD0uC zTx%z0o73A&PIVdw%$%EW?bYY`JmDD6`>_V%I4a0xI1mYL2Fz@X29c92rohZQ4z)|F zV3x3h3xuodmR#GVT^!h{zo~yL17F^T14p_@7Q0Ygc~p`q@4+LHc{cg%ap7nu z@&}^0Hi2}0@8Sx#uDwV>%hoz#AaX49J+$hiQ^1Sl``0;#ewE)V5|-#AN@G|rCzvE> z*0=}V{IHU$;XDt6A%;p84~m-{JQUe~cv^;A6{NQ}U*4DP)U_hyQ1Xlh@SLH!TeP?% zRIdUwf|us{s4k5g6>jYgrFR&I`BmmX7JBES+sPd>Jqm$5cyXiErb9Qv$QU+#+nb1O z%)mOaVQQTdC6dgk(-42ZDL!n0_?!u(KG&@~9RdMY6%8wsSHObiVb6)P_8?y`tK|{T zC!7rYe{=gx-ro8sh~1Q>mZWdV3i9695JuHoE*s>iDN{}6(-SZ}!g6xc zQsH1{GT)g6r7H(UM|;sTXvPTU{6t5mmJ$QIS+`IDd~kZ#17#OrVdL=hv~6yLiJYF@ zlt>iWa(xz{z&dCR0SGK~g)80=Wy9QrvE|%@CCtP@U2LfSTQ}3CMBG$s)W-h;&v5b2cgH(&UtI*j zDZ`HfvvKDSRO1WPM5S}QXA`f4erf%82gHIwWb~S7tSjA}WRm9|&F|@|V7WQpdnmRd z94Kcmk}r`t_N0+1g71torPj#pUae8OOK@wbn9qU`?E6SS@52d9M#tz+@D zE3GMnHj6+WB51c-Yy1r!=9|*18uB)Ca_G>GkQr>zt1F1|#l*0pv3tpiv>UuT$wjNe z=fi0*|L%w)J@hye{~{Ah`zSbZyd=%bs6VJ-I@i2gm(MXB@l*_%b{T#S;p7lB;1i+r z4K%2-sAT`cH_h9|YqWYB?%3y3D{#Kcb`YNyP+HF|+;Vfr z?e5Zj7jip{8kFxgQcLzi&s$z4fr!nQVy-FP<~={7uaXoVz?k)%+?o~Er08I+==Mzw!)SX(6Ds@Es9H;!D*Fvi-XZ>m zx9>)0c5S=9;F#!8j#q#CB4F1B?$<@Z%2lP!m$y*T`mC3e6HzYwSpI+?F&N9UkERV* zv?ecizi|3~CoaL+Ys7*f7^3$G4dBAYOIPzi3wQ-Gy?z&Y@1M`m+YQiyV?Xa zx5JE){GJ6c&Jin6;$sjseJxCG_zTFG2{|HA=k?rM6Bt@|lqs%Y4`c_8Fpa^VpA4Q( zJ;Qq;4fWrqK-rslZzf6Dnb)z~tlh5m*_a*5-b$Yc|J}#4Lrn>}Ct&hTLnXU2*37oN zu-0fX3syd!XE8YSEdEl#4PFzWNW;2cyl7arAxdi6(&O$$WvXy!z}TP|yI4&X^*FeH z4CDJA|E7`!#51}0#Xy`oSuu}+KuOxFd^U@74~2LT6Zk2zpFZ5>ql)d>n-T>bc7wO8 z=Plne)~YIQEHB1g->vS}xuC4PmmliE-}w&hLJIz^A`4VBt&@x>K2OVo1Yd&O4w$H5 z1L5&Fso1M0seSfHjp*R?C2nN5aL>Z1m-a+jv2pv8I zvUXwQR2WjDJ)HSNo))6%t=g`~D^Pm89J=J!!OeGtiVtw zW+SjDG5A>pduV|k)i{pZ%N_Wn`>OP>+sxcWRE7pQtpIpEy~QCJaWRdN(pqw6&9}5) zr*c&0idD@ZHm-3I-@EpTn+Y0XG;}${`5nbzRVAQ+`RVKh){v_RC$XZMcYWLI*Y<9zcF+2=@e*a%10sa*Nw$S3)ScOaY^s))~WZ$Ikb7WsWd zaP0f-)#pl6O#BB5(JysX!@ZmG*ijcaWk!=}@hL3@CPA%0^DP|) zj+K>ZUiw>bTwbyAAdn#%{WIqFc=);YtnKzDy5p~!J?7}_!Pr5{EYn{ z>{}hWME~hJgQv1f;H6~=*-h3IuREnwujH3Q9FMVTeW`e z!%;Z{#;_yw@ywhxNO|=Lhjiuk#FwZYWYChX71W=S$qKUGCom~$YT}*MrHdnriYGl@ zOxV}d%=S$rBA z^xyqoYW@lvRb4U=9DC62808pH{8mn z#5oK(-q8Fm`@ZxyW%u@k;jFPVlD6Aa_9(X_ z+sO9}$wnz=+MUFb!MM6tDItYJaTBWx?bJmDD=1~E=1<)i?&P|hIVpd;*aSq6bMOFb z*d4j4X3|mgJ?f)MZUXx}aN8 zwm>F%{!M4MP9d0$7&vQC)?KCWtZv%{cUNw#WpkzB$k`iB5qo9zWM^LFpdgM`Pdv4e z&A|4fnD%8rMCVaNs6Plop06);seSTfX+(U!>uo+m`t^3cE7cf5lgX9!?+3w-pzol- z+E0KeA3NR}S2^;&;;TvmQtR`D!c)SIN}(paAwyRS!JK`hAR+kmsDq{+lHrJ~!AWKX zMr;QX7VFntqQDARq0=~8i9Lj5iQ&oq>~|8h@iPC@ThpSZv5iq)m~rhJ?LM-9C=+{j z&K6qG=&Kccb3Q0aB0ujkh+Oe-`+{PP#frDy+Kg!IH!nC1Hue&R?o$FLBG^4f%%6x? za?4O9j6V|@-Ejx%3`^>{E?qogsSndd7Ys!08H{=?b3YFapU=K8k6>z0+0bYlu|nAW zrc&sGwbx|`*^5AZV+-8GMT>I{AQT88^e974dgM5~)u2|>#Yy>>IMk%IF(>}0%yj&g zTAJqOJjKMsOWT-*MD}vSz_RXX6cquFmWTyit@pI@fSAJ8^3S%z1^tU$pmUKX5wTux zV##9G4+Z5s#u0sD%}*@$xL3y?J$~P-03v6?k+rOGU5iQ#?v&>Xa>6pGF>4~rI(wSr z<;nLU;Ht?gwvQ*K&>q2i)moQ12W7$YYYf0}3DQv}h(0Ta2wXG?Cp~)ROqMK)}0v((7 zWoxWgahp*iUaecoJf1%V?gu0X1 z{s>cctZI<-h#_A3DkI6KF-#K|P5i!n?6@0l8MFP1!k1EVO6g{}g`+`9&a%*D_uzvz zxL3w$x09ZCOJ>U!Wqu7Ypxdv-o|Ap5Re((=YP8zd0Vqea4xw=MZtlvsjB{sK9Vipe zPO^-EELP=)={l~!!X{I#J_=pFNZ5HDX+N=R5ihqzecVaBC_Y-}({qB+i9n0*2csEL zv#+kM4N0&xx}n4qOb__vnp8VmGgaVla^ln8-sl|DLQ=<@*_RFl;sy{BOl%Q+Pj^QQ z!IsCfW${p-0G~p)}4pk?p;(X)yCk zF>vJ?JK{+j25=_>08PS*0l4WQSuj-HOd-*RjpsuPk#em87Cm2b+$>qq196x!V*qwK zk(W?AD^vTN^Kcwp7ubojVMYbHa4hQ9)9ezpjoD~X2$;y7f&++G_8``n3@ZW<6LxlQ zR8W8CC0$oqz0PE{eLVogHM>sDc107D#^nfekH-k%R(Xi*F5?Sg6Jc>+)+ z7AxWUFL)@CL9IBO_a@}wD8PDE!I<&2?=va_U`!JxOnoBvvpz444`vp=cI5!zp$5H3 z#^twAsuFJcu|Z$rNOkTom}#)aSjQr3dwG9p;Zo`X>()WbFd~R-MTjz~cWcz&6n%l5 zM4jpdVvZJN?o&Xr3IW^e0H`LyODQH5c3}Edj?Hk$08+8cp6f^< z#riH*J4$pSh%U<~rV#*g3aXvmvurUhN9^9qIk7K)nM|pnW*0HtN+csUa1$ru!BtDoZpyd`H1Jb<2qM+_T#TXDNQb=KzXdr_S>s{r<#WwKSF6uDD%7LPE~$ z(giEvSqhYQTekrJ(!1RHBqStFJWWilnwglWUG+!Z_4Ia=kT~(&)!BKs*{>(Qxwtqx zfBW&v$?g8ZR*xP%v2qS?{_^9CWNrP9wAjYF&GC|wP@`izPATrgR@Hym`U=c^(EQzX zn9=1Y93yCmmy34j^)%O9PuNs$Wfhkg!BthWw9q{9Ghpn4ly^Z%6ep_99dXtUm(zB@&K=r2E zEwj5877{-L^{o<;F`g3AKur=Tia?Q&ka`g z40u|*`n$Oy(H^LPp6ziufT?cJ>$d`KSzIx2MfqsD+(F%T(+u(P-B6J*3^4$zK5hXn zY9T(}NVGu+XK9%`=*(4nw!Asi_(I-??jG zb>X+a)qyw2VUK_SUjr~WI5=1{SX&e2?+!k#udfe2r3Kc~(f}kh(4oixmkRP*H?*t2r*D8K3aPeX*X1@UFaUD+@P?y*et)&oEyVL*Pe}CNVF3YxH#p$any0}3 z*alP$H>d_zJwx2QZ(Z>80pJ0AKu_zPGW=8i|Kj}X@h{CAXg7ZolnfA|BCOJh%5sy`{9e@ygMT z6DLyd?B3dPd-r*fPTK|J-Qiqvk43oCO%ilEE35$7MZzp7Gsw$*VXU&1zCghnr@(1m z83-m?oS#~&3}W?9%@0*6js3pa=4}!mO}EwNX=MrD6PCL1BbZd8xOBV^!PfqNP{O*Z zvX3GtD4mJWs@~2`B~JG~jkiZ6R#>la4@2#YNyoS{*+RtI&37Tw-;s3$MuO92UUKV- z_BC1W%%XjS7P;s5egQpuAgnK$Z^!hZy1iH{th^*5&t7!XkXJa)Cug;q?=Ujy8ZEN| zBc4UQ`mie$x4El>zGp8s;*EiJC4h#qm{R} z1}TjUVpayqB?QtXYYt)@-*$vHw|oP-{xr^JA?vH!{9pxr9=WnDV@?`5R9V(F5IC}Q z)E_|>N?V3*GJM#TGdBh)Mcg67`D)c(a)*7Ne(#xM zAuk-;rs0x2u=?eu)B&5}6qQOfhn?&DTgKkVSG*iY5>}x`-qD2)*opX=uaaXARvUcf z&jxtm-+j_@|UjMvO{wc7j)d*t0!nr z378msk#dJsLX1JrV-b4Fw=Q$`>z<$tkRs-l@_tI;nTr$nt&-*r2`-Vg;UkbAG+#{amctc3r?($dTxijf?$Y4QtS zrk^ey-N~`5)dMMi2&CL}FwpU-V{8r}&UL2^P`RJG2SpR$Ks+pOMSWcg&N%w0_F2iH zI{#3pZm)s!y|PtOIrlB=lknr4hBu6a$(+qCU0zG}ez1}OZesO0)8)P5PG28y-$JLS zM4h@0tIVlAimyK0bnc1Ht)RWqJfT6hJ}0D=`;V3Xlvew&cF8lrF%D!Vi_ZpMZAZH2 z&w=URqv&=Xy9m5etjzCO>~Ag$ zC(bF9O{fFQ1733X z+X0hZ!uIwi(M0<7w&peKgyAopG|^4vgQu8oi5k_-aQy6K*4hxUS}uIR(sI*Fb(aE3 zjqHKi#>{sFEc@PDgNbi9K@L;l>%tKwaeEzpWhgi%rF{G)f$z~ZJ}zWySIM~-I{>RKNTli`0$LZN)MCd(+&Ycp`U`rzvc%`E%RkmxNzXm@%b0x%zl1}Dofns27?t<5JQ>UEF@lC zg~~<8N$-}bt5JCph8T?b8m}<=o~Jy68Wpkhk>?tGrpqDZ zEU!vd8Zr-vC?m%4abuocr`H5m7PqgXX8Yg5mSAIi?#uZ{r8oLvP zuysDIF3}E3xptxIVJ>F1HV@v4L-ve&Y#XC1DKACcdJ4}Z4`aN(c`vOzH4RHI3KugK zw68tE8?7`VyA&F1o-Qc&b?ymgx}MvVHA=CQ)FbH`wx&NH`rhNF2}uo1ZF@0P*;yu7 zJ%2`KDGXsJE*ob&FrT5W37DlJ*E^%8nty(x(b=xQR2+Oo38_IWXWyE)t|qPL?X-IJ z;*xaeHKR#q*`eRy6?P04P)iuXu)(K}*X=T=m2+sqiZG0CjpTUgItXsSKSuQt&kRoM z3V?kA+LuNDblYwoEu=FH5Hqfq!!vF$bTe~~1)QVLRUfC=5={!W?R#~=`|%MwlaSHt zkaZ8s{>ry&x>As$rd*XUVcWX|&ED}a@r$SOdNxJ%D|QJ`!CRC9d4073hV*RjNG|y} zHmFu%tifMOI(oU7E2ogQ=}~g6;Z6Dx&4&p^uVdve#iJWE(Xk5^APBdWF#wuRXy$X-ckc)Kemj{=F(dlFJ{9Jqm#zBS z#S_v$tMlhgI{fC8jHbK1yQa)CQLxvfi*TK%r(qSwVk^iqbfl9kMR+-|Hj*9`YtO!B z{}#d3EyJ62;D!^utlB1($n3^ znq|WY1zR8|L8N@agvz*RR-M;Hz*-9vql-GNcM#{$`c#Zto3@aoUaBf@+-SO^*ipuz ziOANCq!{t8cUZp<$mhHI*Oj&It`z%mzZSXjRPMkSsp-I2&-~sV=rcZ#PA~VWv@mSb z8MJirS!jkz}@=pJx(xa5~6>Mtj0?=kO(ir3Pd-NE_B?M+tI|f4ON=*7RQXQP#c2LXD!aAojGwZ1cf^4kdAmez@6LUhP+# zak}+TF{j2}amI(lVK&!9K|3Yb_Z8Kks#CpgQ&E2SAHStXJdOdQ?-A1Auou*(tbS|3 zbEx~!3QsSny5BnpT3&Ql zwaSSE5}}2MXsQw=!~V@0-+ZgppgJ9u&1W2HD#X7BTqX#vk662}XN-uC5gS&D&HIn3 z>V%NIieR5{*C4G8?`0w6Qx`w0QE*_+pi})ST9cPzS+D0cBWkREBGhT2^7I3|-kOW! zg$v>9RiV5t8}J&mWERTnw61ZlBdILMCh&^PNh-GlRo%1Ksz27V$1=3$2Vi`B{RW~U^?5#pt(=VN1$CNxGUNAmmU)kyU1H9|plv@j@ zARKF+G1;l3SJ28jCWOvLEswbMBKY5J(%`elKy!8tpPSz`rA8sc;Do+q?W*dD)C5en z*Hd_?@LJPQSNL4hXD=cIH7sp8Zd@?w>NJZ_opyYIzD;b7f_CT56Zjd0(;_GMtg))V zYNg3={XvF(eWN48#1RmrvF^=xFPJ$oa>0Sr<|@Z{bhAO9Y<1^`nsU+PJOm{^@nOhK zp3q3_u3ql?OTFeX_PAj71 zbL%~7Kt4?WCbbzFS8#{;<7`x}!bL(q_1eP@V)K>p*)dz7bq=BVLN^MA&8o(?)&AO=Wf(|fy^&pM|LLu>j4k8lDNu_x^ zVPC&00m5fZGz|kSqiGOwD7#S|bn0Ex5k7$`a^o~1*8DkB=_n*}_;Uq+ZG}n= zol14rtdpd%jl&ne%A`}*ctz<0WE5&Rd|f<74DFyQ3-e%NPtJH87>VcRsQOTr%ZeNy zD|pP9V7YCk>8ij6Q3KC!S69L86aj8Wu*)UHA?)?v{PEkuOf=)+-n9^!sd#zc?1NW?+|~gf7%wopoOn- z(pA?MV`^wbVLL&k56ej(QI;2ysbU{)tBwBK1eR0CSkzc}d@GG5{0hRKdP2_(9zL*! zjuxW-`fU3rA^WwSLia*BZL6MASR)5QE!} zVn+cKy72xG{04sp3kF z;1GnQ+F5cXm;R26+MaN3!HM-{4?{ulMQWbikZDA1ZO9BR3L4!QGozBhe7faJ1cTmE zWpi#(I*sVRYI;;=KDTLQ+Z-ff?cs3PL&wqIH%8UTYQ<_owR$VIscrqW@4|uLh$!__ z%ev*TYz37df_?BNv2oG4btl{yV7GeFsA{kh@QE4XuguEA0S8es{gd4r&Y3M#8$<{o z4K{4mB+p9=qMU-)ss(>+NRLGXRRpR7&MG+zT?$!%v@EE!iF7UkpWTxgUPM{kkPdn2 zh#cB*GA|0_MQup`A;t1%ZbT6{JyHNR1f&&IJpMmzQc6Jf!feY_C12}hcdd!_`{l{;88k4 z)Pu@jKkQJ)fn$J0IqCvYUGfBVVDZi2ict#2nmPYH2ktw9Zc(mr?(Cj>deLjRn?>Tjy0^C!&VyyIvw0X8%vQo z87xP(4>hk7HzRwvK$77lN>-Jd&P6ee-LJ+TBg)42;XTL={`I#D$AEMrW54^pN{DGq z#c<9*OR8&C^Z@!yefh@T2S@-kSAEyhXEG8gB-zaK$C9y{A(uZRveKx! zG~h;~lLG8~@E?fuJZ+StNA&2^By4T$wxQ#AZ?3{uRz7a^z+mCzxdxWvdMh4ir!`aX zA3|m*(^y*??7-foemo#Oh1WCfPyMBT8ZS2bG3DxMq z<5>*lKKvHJCT*Lr+s0ylxt`-^`Gt}VKsFc@KhWYg0Gwk?KM}u%@s!D#^oo}3Xzch7 zqDkVTjy;3XGMYbD?2e^ZIv3#0YH*DEKrTJnj$I(#u~zWz0(+K&4t~g$0Xd{CR3~{C znH%ElQTl4E$0FpeO2`cw`rq&u06YRA4Jtub`OJCx}iYY8}bT0_!nv!CW45iuYEGrZw5RnX@6V&(^3n@mPXSL3<6TH zVEz3P7hD=mGxn!oMb7nI4{x7|bfjA=Jd}9@{ha?uzPCM6=C~>>WeL9~b_#d0AuhaD zFWo}RmD+_qs+$BqVoYxHo?$8FS<2Z9QqK|=+=G;cl7}gNvz;GuiJ@pdFud9_->It% z0;zn))~dI{!Dy-7Qj@_-xW~UlgXB#8zjoGuTAqe!^l`Pii9O%3w}E>lcw4Z=zYJ>u z$1WAV)dff{DtI1!ihZPjSf<9WiL2jB{d;>&G2RT%wj3)?IcczpYP zx~?+sa8);A`TkG*!WrGAWV|_;ISu9H%VOZjJt$t!yXiC}yh|a$kSsI(5d1wA$Vczp zldyCCQCJr+aVY5Oz;lg`tRCP$WcyXFRgc@q5$>Wy-hQ(9(Q$B+!NoreD!?2A8nRxKF~5MUXJS z^@piK#%>yL>t62ZYhy0H@>~v%5lKz91XUqZl)Q` zZjQg*TZZAv|J=a3iD#Fo+6!mh@ZAPI9HBmTjXmNUGjP<;gH>#9I3Dlmwcs`XC5c%U z+_)mhkI_vLu<7Opdk&A_jzR7IX#cqj&2nUy#4}wY^T9AmXIqwG>pvVHf5Y(ZJs6}^ zBW;VP{Oj`#CyN!;nN@|}lV96Z?B8}h1|1c)kGngBX|%r9Y~Enf(1$Q1S5=fNKF}sb z84Wbp%(l!}O`{#${kpx1`xQsC`R`0nNCLL3TtvBCu_}+tsu0Yk4-abc@A>g@N&ZVe zSimV8gN%b=txuSkM-^v2%G|sdIYHBxaPWM+<^U=Eg5&8aq+nLRR)~L2aB|E0o7S0F z)X7Pis}R44w4CkXj>FsEiFG#Grz=tn(L(D zYY*6yL{IW=cITY+y@#LuNNDK}^Vp>G`qT-h#vr1XTg~_wS|#Y@e1Gpt_TX>!mv)Sd zZiP}5U&%eT`8YIf*Qvs6;I|)=p?bMSj+a}^--1kA=S2H{eIKKGCG(^>BtGEVS44Zz zh!fo7@%=uDAHV9fcIIfN@bpil@Ec9@qEm^VJI(R+NpMvDjs83*e=Aj=Q=CL1rh*1E z%89LAh{c$b+EpM4jSVll7a!J}o!|OL@q@)o`X}b^-iog&dPsNqe&>2?xFL4@h0N}p zyKIQM*DjMtz5cm|XAZM*%%G`Pa(4}RY{Q8N4GMScuEuSTovQR2$;N#j2kT{$ONa6X zWwKf=jUF9-+!!5cp$tvI@*4vOa1-0DVZ5n&NBf}JvB+Q#T#KBQLWVtzmdIy3Xe5k; zHHs$Ol^sv<^WhrL3{Uaej>tdG%UuPqlUCb)s0x(+n-SYf|3Kb(b{pwXrSr*=vJYQh z6gGT>E#>PZ$NhG4grikxh;1q8<8Df3&s4 zBgtjsw7TiA+7lO`85Mr_?&rshJhx-JL8dK=S4aeYAN-dr$Z;6GKd$CGW;Jh|!qb8Y zJ?lzn6Tmd^yY-IDduaJ!Pi))KV%)R4Mx{}?U60`hjLdp)+{1`&dt>VLxHMZ@MJ`^8 zav_{+^^W|IvT{Ykcb4yHqW|lRHza$T-f#uFfjl(7M=yWdV)~OepR!s-@`rkh;iH}E zqro?q1H4;_J0x%!6q}UWwCkB9;|H&A6bpXKMSW)dM|FvdseS=0Bto!7wOr z)n<#NOC`cCCOEjn5&c+=d+v%4c_8dWo!}YAWe!SgcF3gc05X*nfqX8j6us@Z+jrS< zeGPm)#AbO=;WIIO81<$7b81+9DXph~D+qClrScwX?pmyPHc-|yqwHP!FgHo&629JA zYNKauzN?#__IB}kTBntF1I9cCqaVZZ3)y_~8snTyveXYr0C3>L2Vr(;{F1Cvv* Date: Fri, 25 Sep 2026 16:34:47 -0400 Subject: [PATCH 5/9] docs: WS4 Prepare integration notes --- .llms/learnings.md | 24 ++++++++++++++++++++++++ TODOS.md | 6 ++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/.llms/learnings.md b/.llms/learnings.md index 05ef0913..92e7a45f 100644 --- a/.llms/learnings.md +++ b/.llms/learnings.md @@ -431,3 +431,27 @@ deadlock the flip (the previous screen's `lock` frame is cancelled), so wait ~700 ms before each automated response. Native `showMessageBox` dialogs can be auto-answered by re-registering `dialog:showMessage` from that inspector (`process.getBuiltinModule('module').createRequire(...)('electron')`). + +## Prepare: `PrepareSteps` owns the built-in step chrome + +`DesignComponent` renders `PrepareSteps` for the four built-in studies; +Custom and Imported keep `SecondaryNavComponent` and their authoring steps. +The switch is `Experiment.prepare`: an experiment with a `prepare.ts` gets the +lesson, one without authors its own. Per-experiment data lives in each +experiment's `prepare.ts` (`responses` + `flow`, plus Faces/Houses' +`mediaFallback`), re-exported by `PrepareSteps/fixtures.ts`, so Storybook shows +what runs. Trial counts come from `flowFromStructure` and the experiment's real +loops — never `params.nbTrials`, which is stale for Stroop (real 8/96) and +Search (real 8/80). `experiments/__tests__/prepareContent.test.ts` runs each +loop's `before:prepare` hook to check the counts, and checks the diagram's keys +against every key the study accepts. `PreviewLabel` is the single preview +status, used by `PreviewButtonComponent` (Custom, Imported, Collect) and +`PrepareSteps`. Multitasking puts the same key under two rules, so response +rows are keyed by `label`, not `key`. + +The running preview draws the participant screen at `zoom: 0.55` inside an +`absolute inset-0` stage. Both halves matter: the lab.js mount sizes itself with +`height: 100%`, which only resolves against a definite height (inside the +`min-h-full` scroll column it fell back to content height, so the box grew and +the page scrolled), and at the 800px column width an unzoomed screen is +550–750px tall. diff --git a/TODOS.md b/TODOS.md index cff65cfc..ae7b1573 100644 --- a/TODOS.md +++ b/TODOS.md @@ -25,7 +25,7 @@ Deferred and in-flight work. Keep this current — when something ships, delete - 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). - - 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) + - ~~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. - ~~Early exit and incomplete runs (WS5)~~ — shipped 2026-09-24 (branch `feat/ws5-early-exit`). `End experiment early` and a held Escape both end the run with no confirm; what was recorded is kept as `*.incomplete.csv` and left out of Clean, Analyze and the badges; the result screen says `Experiment ended early`. @@ -33,9 +33,11 @@ Deferred and in-flight work. Keep this current — when something ships, delete - ~~BrainWaves-owned participant screens (§7.2)~~ — shipped 2026-09-25 (WS5b, PR #279). Built-ins and Custom show the #273 instruction / practice→main / end screens; the stillness line follows the run's EEG setting. - ~~Q never skips practice (lab.js 23)~~ — fixed 2026-09-25 (WS5b, PR #279). A `skip` template on the practice block was parsed before the instruction screen's response was committed, so it was always false. Instruction screens now have an `end` hook, `skipPracticeOnRequest`, that sets `skip` on the block that follows. - Accepted (WS5b): `isEEGEnabled` is now a column in every lab.js behavior CSV, imported lab.js studies included, the same way `title` is. - - Design preview box (`h-[330px]`) clips the participant screens, so the keycaps need scrolling. Fix during the WS4 Prepare integration. + - ~~Design preview box (`h-[330px]`) clips the participant screens, so the keycaps need scrolling.~~ — fixed 2026-09-25 (WS4 Prepare integration, PR #281). The running preview fills the free height (at least 420px) and draws the participant screen at 0.55 zoom; all four built-ins' first screens show whole at 1366×768 and 1280×720. ## Next (V1.5: Visual Polish and Juice) +- [ ] **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. +- [ ] **Preview zoom readability** — the Design preview draws participant screens at 0.55 zoom so they fit without scrolling; the small print (notes, Q hint) is ~10px. Check on a classroom laptop whether it needs a "full size" toggle. - [ ] **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. From 225caab6545e52ebd8fab38bd751ddc0e22db6a5 Mon Sep 17 00:00:00 2001 From: jdpigeon <morrisondano@gmail.com> Date: Fri, 25 Sep 2026 16:42:31 -0400 Subject: [PATCH 6/9] fix(runtime): lab.js window waits instead of crashing when params is null --- .../components/LabjsExperimentWindow.tsx | 9 +++++---- .../__tests__/LabjsExperimentWindow.test.tsx | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/renderer/components/LabjsExperimentWindow.tsx b/src/renderer/components/LabjsExperimentWindow.tsx index 29bfd899..db3054c0 100644 --- a/src/renderer/components/LabjsExperimentWindow.tsx +++ b/src/renderer/components/LabjsExperimentWindow.tsx @@ -33,10 +33,11 @@ export const LabjsExperimentWindow: React.FC<LabjsExperimentWindowProps> = ({ onProgress, }) => { useEffect(() => { - // experimentObject starts as {} in Redux initial state — bail out until a - // real experiment is loaded, otherwise lab.core.deserialize crashes on - // the missing `type` field. - if (!experimentObject?.type) return; + // experimentObject starts as {} and params as null in Redux initial state + // (and again after ExperimentCleanup) — bail out until a real experiment is + // loaded, otherwise lab.core.deserialize crashes on the missing `type` + // field and lab.js's parameter proxy throws on null params. + if (!experimentObject?.type || !params) return; // TODO: move this study mutation into Redux? const experimentClone = clonedeep(experimentObject); diff --git a/src/renderer/components/__tests__/LabjsExperimentWindow.test.tsx b/src/renderer/components/__tests__/LabjsExperimentWindow.test.tsx index 566a33dd..1bc2fa71 100644 --- a/src/renderer/components/__tests__/LabjsExperimentWindow.test.tsx +++ b/src/renderer/components/__tests__/LabjsExperimentWindow.test.tsx @@ -69,6 +69,26 @@ describe('LabjsExperimentWindow', () => { expect(onFinish).not.toHaveBeenCalled(); }); + it('waits without starting a study while params is null (workspace cleared)', async () => { + const onFinish = vi.fn(); + render( + <LabjsExperimentWindow + title="Study" + experimentObject={study as never} + params={null as never} + eventCallback={vi.fn()} + onFinish={onFinish} + /> + ); + const settled = Promise.withResolvers<void>(); + setTimeout(settled.resolve, 100); + await settled.promise; + + expect(screen.getByText('Loading Experiment')).toBeInTheDocument(); + expect(screen.queryByText('first')).not.toBeInTheDocument(); + expect(onFinish).not.toHaveBeenCalled(); + }); + it.each([ [true, true], [false, false], From f5b2cc9a04c95f3c51825ac1988598502c662208 Mon Sep 17 00:00:00 2001 From: jdpigeon <morrisondano@gmail.com> Date: Fri, 25 Sep 2026 16:44:29 -0400 Subject: [PATCH 7/9] feat(prepare): Background's Sacks stand-in is a real face-crowd illustration --- .../PrepareSteps/PrepareSteps.stories.tsx | 2 +- .../components/PrepareSteps/PrepareSteps.tsx | 78 +++++++++++++++---- .../experiments/faces_houses/prepare.ts | 6 +- 3 files changed, 67 insertions(+), 19 deletions(-) diff --git a/src/renderer/components/PrepareSteps/PrepareSteps.stories.tsx b/src/renderer/components/PrepareSteps/PrepareSteps.stories.tsx index fb6c895e..0ba8c891 100644 --- a/src/renderer/components/PrepareSteps/PrepareSteps.stories.tsx +++ b/src/renderer/components/PrepareSteps/PrepareSteps.stories.tsx @@ -296,7 +296,7 @@ export const EEGOff: Story = { ), }; -/** P10 — OliverSacksFallback. Background's video slot holds the local stand-in (16:9 placeholder) and transcript-length text; no remote player. */ +/** P10 — OliverSacksFallback. Background's Sacks stand-in: a local face-crowd illustration (one familiar face blank, with a "?") and transcript-length text; no remote player. */ export const OliverSacksFallback: Story = { render: () => ( <PrepareSteps diff --git a/src/renderer/components/PrepareSteps/PrepareSteps.tsx b/src/renderer/components/PrepareSteps/PrepareSteps.tsx index e33e9ac4..50f6fa1b 100644 --- a/src/renderer/components/PrepareSteps/PrepareSteps.tsx +++ b/src/renderer/components/PrepareSteps/PrepareSteps.tsx @@ -369,7 +369,18 @@ function BackgroundView({ ); } -/** The video slot in Background's column, holding the local Sacks stand-in and its transcript-length text. */ +/** Hair shapes for the face-crowd illustration, drawn above a head centred at (0, 0). */ +const HAIR = [ + 'M-34 -4a34 34 0 0 1 68 0c-10-12-24-16-34-16s-24 4-34 16z', + 'M-34 0a34 34 0 0 1 68 0l-6-10-10 6-8-10-10 8-10-8-8 10-10-6z', + 'M-36 6a36 38 0 0 1 72 0c-6-20-20-26-36-26S-30-14-36 6z', +]; + +/** + * Background's local stand-in for the Oliver Sacks clip (§6.3): a flat + * two-tone illustration of a crowd in which one familiar face reads as blank + * with a "?", then the transcript-length explanation and its source. + */ function OliverSacksFallback({ media, }: { @@ -378,22 +389,59 @@ function OliverSacksFallback({ return ( <section className="flex flex-col gap-3 pt-2"> <h2 className="m-0 text-[22px] font-normal">{media.caption}</h2> - <div + <svg role="img" - aria-label={`Illustration placeholder: ${media.alt}`} - className="flex aspect-video w-full flex-col items-center justify-center gap-1 rounded-lg border-2 border-dashed border-[#d4d4de] bg-white" + aria-label={media.alt} + viewBox="0 0 520 150" + className="h-auto w-full rounded-lg border border-[#f6ead3] bg-[#fffaf0] stroke-ink text-ink" + strokeWidth="1.5" > - <span - aria-hidden - className="flex h-12 w-12 items-center justify-center rounded-full bg-brand-light text-xl" - > - 🎬 - </span> - <span className="text-[15px] font-bold text-ink-muted"> - Illustration placeholder (video slot, 16:9) - </span> - <span className="text-[14px] text-ink-muted">{media.alt}</span> - </div> + {[0, 1, 2, 3, 4].map((i) => { + const unknown = i === 2; + return ( + <g key={i} transform={`translate(${60 + i * 100} 86)`}> + <path + d="M-46 64c0-24 20-36 46-36s46 12 46 36z" + className="fill-accent" + /> + <circle r="34" className="fill-accent-light" /> + <path d={HAIR[i % HAIR.length]} className="fill-accent" /> + {unknown ? ( + <> + <path + d="M-24 -78h48a10 10 0 0 1 10 10v20a10 10 0 0 1-10 10H6l-6 8-6-8h-18a10 10 0 0 1-10-10v-20a10 10 0 0 1 10-10z" + className="fill-white" + /> + <text + x="0" + y="-50" + textAnchor="middle" + className="fill-ink stroke-none text-[24px] font-bold" + > + ? + </text> + </> + ) : ( + <> + <circle + cx="-11" + cy="2" + r="2.5" + className="fill-ink stroke-none" + /> + <circle + cx="11" + cy="2" + r="2.5" + className="fill-ink stroke-none" + /> + <path d="M-9 15q9 7 18 0" fill="none" /> + </> + )} + </g> + ); + })} + </svg> <p className="experiment-design-copy m-0 !leading-snug"> Some people cannot recognize faces — even faces they have seen thousands of times. Neurologist Oliver Sacks described this in himself: he might diff --git a/src/renderer/experiments/faces_houses/prepare.ts b/src/renderer/experiments/faces_houses/prepare.ts index f71adef2..a95fd1ae 100644 --- a/src/renderer/experiments/faces_houses/prepare.ts +++ b/src/renderer/experiments/faces_houses/prepare.ts @@ -13,10 +13,10 @@ export const prepare: PrepareFixture = { background, protocol, icon, - // §6.3: Oliver Sacks clip rights are unconfirmed, so Background shows the local illustrated stand-in, not a remote player. + // §6.3: Oliver Sacks clip rights are unconfirmed, so Background shows a local illustration and transcript-length text, not a remote player. mediaFallback: { - caption: 'Oliver Sacks on face blindness', - alt: 'Illustrated portrait of Oliver Sacks', + caption: 'Oliver Sacks and face blindness', + alt: 'Illustration: a row of people; one familiar face looks blank, with a question mark above it', }, responses: [ { key: '1', label: 'Face', stimulus: { src: face, alt: 'A face photo' } }, From c9bb7c748eda8fb521df57d340b16a247bd9e4e8 Mon Sep 17 00:00:00 2001 From: jdpigeon <morrisondano@gmail.com> Date: Fri, 25 Sep 2026 16:44:29 -0400 Subject: [PATCH 8/9] docs: defer expanded preview and Collect-without-workspace --- TODOS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/TODOS.md b/TODOS.md index ae7b1573..5a1108b5 100644 --- a/TODOS.md +++ b/TODOS.md @@ -37,7 +37,8 @@ Deferred and in-flight work. Keep this current — when something ships, delete ## Next (V1.5: Visual Polish and Juice) - [ ] **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. -- [ ] **Preview zoom readability** — the Design preview draws participant screens at 0.55 zoom so they fit without scrolling; the small print (notes, Q hint) is ~10px. Check on a classroom laptop whether it needs a "full size" toggle. +- [ ] **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. - [ ] **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/<title>/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. From 0455e015b738eaf760a5aa8caebabd07d5653748 Mon Sep 17 00:00:00 2001 From: jdpigeon <morrisondano@gmail.com> Date: Sun, 27 Sep 2026 15:12:48 -0400 Subject: [PATCH 9/9] docs: remove ws4 integration plan --- .../2026-09-25-ws4-prepare-integration.md | 536 ------------------ 1 file changed, 536 deletions(-) delete mode 100644 docs/superpowers/plans/2026-09-25-ws4-prepare-integration.md diff --git a/docs/superpowers/plans/2026-09-25-ws4-prepare-integration.md b/docs/superpowers/plans/2026-09-25-ws4-prepare-integration.md deleted file mode 100644 index 795cdd35..00000000 --- a/docs/superpowers/plans/2026-09-25-ws4-prepare-integration.md +++ /dev/null @@ -1,536 +0,0 @@ -# WS4 Prepare 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:** The approved Prepare screens (#276) become the real Prepare surface for the four built-in experiments. The protocol diagram's keys and its trial timeline come from each experiment's real code, not hand-copied fixtures or the stale `nbTrials` param. - -**Architecture:** -- `PrepareSteps` (#276) becomes the shell of the built-in Design screen: it owns the gold step pill, the centered column, the step content and the sticky action row. `DesignComponent/index.tsx` supplies the content and wiring. Custom and Imported keep their own authoring flows. -- Per-experiment data moves from `PrepareSteps/fixtures.ts` into each experiment as `prepare.ts`, next to WS5b's `screens.ts`. Story fixtures then read those modules, so Storybook shows what runs. -- Trial counts are derived from each experiment's real loop structure, because `params.nbTrials` is stale for Stroop and Search (says 150, unused; real counts are 96 and 80). -- One preview label app-wide: `PreviewLabel` (#276) replaces `PreviewButtonComponent`'s inline status, and the preview box is tall enough to show a participant screen without scrolling. - -**Tech Stack:** React 18, Redux Toolkit, lab.js study objects (read-only), TypeScript, Vitest, Storybook 9. - -**Spec:** -- `docs/uxr/playtest_naive_1_design_implementation_plan.md` §1.2, §1.4, §3.2, §3.3, §6, §11 WS4. -- Approved design: PR #276 (`src/renderer/components/PrepareSteps/`, `PreviewLabel.tsx`). - -## Global Constraints - -- "Keep `Prepare` stable in the global workflow while local headings use Learn, Design, or Configure." (§11 WS4). The global bar is untouched. -- "Add action-specific Back and forward controls without turning visited sections into Collect prerequisites." (§11 WS4). No lesson or preview completion gates Collect (§1.4, §13). -- "Generate built-in experiment flow descriptions from known parameters where possible." (§11 WS4) — here, from the real loops. -- "Do not persist lesson-completed or preview-completed flags solely for gating." (§11 WS4) -- "Package a licensed local Oliver Sacks clip only when rights and captions are available; otherwise implement the illustrated fallback." (§11 WS4) The fallback ships; no remote player. -- The stillness line's source of truth is WS5b's `isEEGEnabled` parameter. The EEG toggle keeps setting the workspace's `isEEGEnabled` exactly as today. -- Real trial counts: Faces/Houses 6 practice / 120 recorded; Stroop 8 / 96; Visual Search 8 / 80; Multitasking is block-based with no study-wide total (per #270's known gap). -- `params.nbTrials` / `nbPracticeTrials` are NOT the source of counts. -- Rule A: at 1366×768 and 1280×720, each step's content and its primary action are visible without a page scroll. -- Comments go on definitions, not inside bodies. No one-expression wrapper functions. No new dependencies. -- Subagents: skip formatters, project-wide lint and the full suite. Run only the files named per task. Task 4 runs everything once. - -## Open contracts (verify before coding) - -Four details are not pinned in the task text. Each implementer resolves them by reading the code and records the answer in its report. - -1. **`PrepareFixture` location.** It is exported from `PrepareSteps.tsx` today. If `experiments/*/prepare.ts` importing it creates an import cycle (experiments → components → experiments), move `PrepareFixture`, `ResponseMapping` and `FlowPhase` into `PrepareSteps/flow.ts` and re-export them. Check with `lsp` references before moving. -2. **`ResponseMapping.when`.** The type has no `when` field, but the approved Protocol diagram shows per-rule labels ("Shape on top: answer the shape"). Either add `when?: string` and render it, or fold the rule label into `label` strings. Do the former if `PrepareSteps` already has a slot for it; otherwise the latter, and say so. -3. **Multitasking's keys.** `multitasking/content_protocol.js` has no `condition_first_key` / `condition_second_key`. Derive `responses` from the real `b`/`n` mapping in `multitasking/experiment.ts` and the rule wording from `content_protocol.js`'s `condition_first` / `condition_second`. Do not invent keys. -4. **`Experiment.prepare` on the type.** `constants/interfaces.ts`'s `Experiment` interface has `icon`, `experimentObject`, `params`, `text`. Adding `prepare?: PrepareFixture` is the intent; confirm `imported/` and `custom/` compile without it (they should, since it's optional). - -### Rulings - -- **Ruling 1 (Multitasking).** Its `prepare.ts` describes a block task with two rules on the same keys, not a linear trial count. `flowFromStructure` has a `blocks` variant, and the timeline never claims a study-wide total. Cost if wrong: Multitasking's Protocol reads like a linear task and its counts are wrong. -- **Ruling 2 (EEG toggle + Customize).** They move into `PrepareSteps`'s action row as optional props (`isEEGEnabled`, `onEEGEnabledChange`, `onCustomize`), since `PrepareSteps` owns the step chrome now. Cost if wrong: one extra prop pass-through and a story to remove. -- **Ruling 3 (SecondaryNavComponent name).** Keep the name and add a docstring saying it's now the Custom/Imported authoring nav. Renaming risks touching unrelated callers for no user-visible gain. Cost if wrong: a later rename PR. -- **Ruling 4 (preview box height).** `min-h-[420px]`, growing with content, instead of `h-[330px]`. Cost if wrong: one line of CSS. - -## Review Focus - -1. **A key or count that drifts** between the diagram and the task. WS5b added `experiments/__tests__/participantScreens.test.ts` for participant screens; Task 1 adds the same style of test for Prepare. -2. **Multitasking.** Two rules on the same keys is the hardest diagram; verify it reads clearly and never claims a study-wide total. -3. **The preview box.** At 330px tall the participant screen scrolled (logged in TODOS). Task 3 must prove the whole first participant screen, keys included, is visible without scrolling at both sizes. -4. **Custom and Imported must not regress.** They keep their own steps and the `SecondaryNavComponent` header. Task 2's test covers both. -5. **Workspaces already on disk** (`appState.json`) carry no prepare data. It's derived at render time from `type`, so no migration is needed. Note it for the reviewer. - -## File Structure - -| File | Change | Responsibility | -|---|---|---| -| `src/renderer/experiments/{faces_houses,stroop,search,multitasking}/prepare.ts` | create | per-experiment `responses` + `flow` (real counts) | -| `src/renderer/components/PrepareSteps/flow.ts` | create | `flowFromStructure`, `FlowPhase`, `LoopStructure` | -| `src/renderer/components/PrepareSteps/fixtures.ts` | modify | re-export the per-experiment modules | -| `src/renderer/experiments/__tests__/prepareContent.test.ts` | create | keys and counts match the real studies | -| `src/renderer/constants/interfaces.ts`, `experiments/*/index.ts` | modify | `prepare?: PrepareFixture` on `Experiment` | -| `src/renderer/components/DesignComponent/index.tsx` | modify | built-ins render `PrepareSteps` | -| `src/renderer/components/DesignComponent/__tests__/DesignPrepare.test.tsx` | create | step flow + Custom/Imported untouched | -| `src/renderer/components/SecondaryNavComponent/index.tsx` | modify | docstring: Custom/Imported authoring nav | -| `src/renderer/components/PreviewButtonComponent.tsx` + `__tests__/PreviewButton.test.tsx` | modify / create | one preview label | -| `src/renderer/components/PrepareSteps/PrepareSteps.tsx` | modify | optional EEG/Customize props; preview box height | -| `TODOS.md`, `.llms/learnings.md` | modify | notes | - -## Dispatch waves - -Worktree: `git worktree add .worktrees/ws4-integration -b feat/ws4-prepare-integration origin/main`, from `main` after #279 merges (Task 4 touches `RunComponent` only via `PreviewExperimentComponent`; if #279 is still open, base on `origin/main` and rebase before the PR). - -| Wave | Tasks | Why | -|---|---|---| -| 1 | Task 1 ∥ Task 3 | per-experiment modules vs PreviewButton; disjoint | -| 2 | Task 2 | needs Task 1's modules | -| 3 | Task 4 | needs Task 2's shell | -| 4 | verification, docs, PR | | - ---- - -### Task 1: Per-experiment Prepare data and the real trial counts - -**Files:** -- Create: `src/renderer/experiments/{faces_houses,stroop,search,multitasking}/prepare.ts` -- Create: `src/renderer/components/PrepareSteps/flow.ts` -- Test: `src/renderer/experiments/__tests__/prepareContent.test.ts` -- Modify: `src/renderer/components/PrepareSteps/fixtures.ts` (re-export) - -**Interfaces:** -- Consumes: each experiment's `content_overview.js` / `content_background.js` / `content_protocol.js`, its stimulus images, and its real loop structure. -- Produces: - -```ts -// flow.ts -export interface FlowPhase { label: string; count?: number } -export interface LoopStructure { - /** Trials the participant practices before the recorded task. */ - practice?: number; - /** Trials recorded for analysis. */ - recorded?: number; - /** Block-structured tasks (Multitasking) instead of practice/recorded. */ - blocks?: Array<{ label: string; practice?: number; recorded?: number }>; -} -export function flowFromStructure(s: LoopStructure): FlowPhase[] -``` - -```ts -// each experiments/<name>/prepare.ts -export const prepare: PrepareFixture; // { overview, background, protocol, responses, flow, icon } -``` - -- [ ] **Step 1: Write the failing test** - -```ts -// src/renderer/experiments/__tests__/prepareContent.test.ts -import { describe, expect, it, vi } from 'vitest'; -import { flowFromStructure } from '../../components/PrepareSteps/flow'; -import { prepare as faces } from '../faces_houses/prepare'; -import { prepare as stroop } from '../stroop/prepare'; -import { prepare as search } from '../search/prepare'; -import { prepare as multi } from '../multitasking/prepare'; - -vi.mock('lab.js', () => ({})); - -/** Keys shown in the protocol diagram. */ -const shownKeys = (p: { responses: { key: string }[] }) => - new Set(p.responses.map(({ key }) => key.toLowerCase())); - -describe('flowFromStructure', () => { - it('describes a linear task with practice and recorded trials', () => { - expect(flowFromStructure({ practice: 6, recorded: 120 })).toEqual([ - { label: 'Instructions' }, - { label: 'Practice trials', count: 6 }, - { label: 'Main-task reminder' }, - { label: 'Recorded trials', count: 120 }, - { label: 'Completion' }, - ]); - }); - - it('describes a block task without a study-wide total', () => { - const flow = flowFromStructure({ - blocks: [ - { label: 'Practice blocks', practice: 2 }, - { label: 'Recorded blocks', recorded: 4 }, - ], - }); - expect(flow).toEqual([ - { label: 'Instructions' }, - { label: 'Practice blocks', count: 2 }, - { label: 'Main-task reminder' }, - { label: 'Recorded blocks', count: 4 }, - { label: 'Completion' }, - ]); - }); -}); - -describe('per-experiment prepare content', () => { - it('Faces/Houses shows the keys its stimuli accept, and 6 practice / 120 recorded', () => { - expect(shownKeys(faces)).toEqual(new Set(['1', '9'])); - expect(faces.flow).toContainEqual({ label: 'Practice trials', count: 6 }); - expect(faces.flow).toContainEqual({ label: 'Recorded trials', count: 120 }); - }); - - it('Stroop shows r/g/b/y, and 8 practice / 96 recorded', () => { - expect(shownKeys(stroop)).toEqual(new Set(['r', 'g', 'b', 'y'])); - expect(stroop.flow).toContainEqual({ label: 'Practice trials', count: 8 }); - expect(stroop.flow).toContainEqual({ label: 'Recorded trials', count: 96 }); - }); - - it('Visual Search shows b/n, and 8 practice / 80 recorded', () => { - expect(shownKeys(search)).toEqual(new Set(['b', 'n'])); - expect(search.flow).toContainEqual({ label: 'Practice trials', count: 8 }); - expect(search.flow).toContainEqual({ label: 'Recorded trials', count: 80 }); - }); - - it('Multitasking shows its rules on b/n and never claims a study-wide total', () => { - expect(shownKeys(multi)).toEqual(new Set(['b', 'n'])); - expect(multi.flow.some((p) => /recorded trials/i.test(p.label))).toBe(false); - }); -}); -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `npx vitest run src/renderer/experiments/__tests__/prepareContent.test.ts` -Expected: FAIL — cannot find `../faces_houses/prepare` and `../../components/PrepareSteps/flow`. - -- [ ] **Step 3: Implement `flow.ts`** - -```ts -// src/renderer/components/PrepareSteps/flow.ts -/** One phase of a task's timeline, as shown in the protocol diagram. */ -export interface FlowPhase { - label: string; - /** Omit for a phase with no fixed count (e.g. Multitasking's blocks). */ - count?: number; -} - -/** A task's real loop structure. Multitasking uses `blocks`. */ -export interface LoopStructure { - practice?: number; - recorded?: number; - blocks?: Array<{ label: string; practice?: number; recorded?: number }>; -} - -/** - * Builds the vertical timeline from a task's real loop structure. Never reads - * `params.nbTrials`, which is stale for Stroop and Search. - */ -export function flowFromStructure(structure: LoopStructure): FlowPhase[] { - const phases: FlowPhase[] = [{ label: 'Instructions' }]; - if (structure.blocks) { - for (const block of structure.blocks) { - if (block.practice) phases.push({ label: block.label, count: block.practice }); - } - phases.push({ label: 'Main-task reminder' }); - for (const block of structure.blocks) { - if (block.recorded) phases.push({ label: block.label, count: block.recorded }); - } - phases.push({ label: 'Completion' }); - return phases; - } - if (structure.practice) phases.push({ label: 'Practice trials', count: structure.practice }); - phases.push({ label: 'Main-task reminder' }); - if (structure.recorded) phases.push({ label: 'Recorded trials', count: structure.recorded }); - phases.push({ label: 'Completion' }); - return phases; -} -``` - -- [ ] **Step 4: Implement the four `prepare.ts`** - -Each file is one self-contained object importing its own content and images, exactly as `PrepareSteps/fixtures.ts` does today. Faces/Houses: - -```ts -// src/renderer/experiments/faces_houses/prepare.ts -import { overview } from './content_overview'; -import { background } from './content_background'; -import { protocol } from './content_protocol'; -import icon from './icon.png'; -import face from './stimuli/faces/Face1.jpg'; -import house from './stimuli/houses/House1.jpg'; -import type { PrepareFixture } from '../../components/PrepareSteps/PrepareSteps'; -import { flowFromStructure } from '../../components/PrepareSteps/flow'; - -/** What Prepare shows for Faces/Houses: its keys and its real 6 / 120 trials. */ -export const prepare: PrepareFixture = { - overview, - background, - protocol, - icon, - responses: [ - { key: '1', label: 'Face', stimulus: { src: face, alt: 'A face photo' } }, - { key: '9', label: 'House', stimulus: { src: house, alt: 'A house photo' } }, - ], - // faces_houses/params.ts: nbPracticeTrials 6, nbTrials 120. - flow: flowFromStructure({ practice: 6, recorded: 120 }), -}; -``` - -Stroop: the `word`/`color` stimulus variant as today (`{ word: 'green', color: 'red' }`, etc., from `stroop/experiment.ts`'s canvas text) and `flow: flowFromStructure({ practice: 8, recorded: 96 })` (comment the source lines, as #276's fixtures do). Search: its `conditionOrangeT.png` / `conditionNoOrangeT.png` stimuli and `{ practice: 8, recorded: 80 }`. - -Multitasking (`multitasking/prepare.ts`) is the exception: two rules on the same `b`/`n` keys (Open contract 3), and `flow: flowFromStructure({ blocks: [...] })` (Ruling 1) describing its block structure without a study-wide total. - -- [ ] **Step 5: Point fixtures at the real modules** - -`PrepareSteps/fixtures.ts` becomes a thin re-export plus `SACKS_STAND_IN` and `NOOP_HANDLERS`: - -```ts -export { prepare as FACES_HOUSES } from '../../experiments/faces_houses/prepare'; -export { prepare as STROOP } from '../../experiments/stroop/prepare'; -export { prepare as SEARCH } from '../../experiments/search/prepare'; -export { prepare as MULTITASKING } from '../../experiments/multitasking/prepare'; -``` - -Delete `buildFlow`, the three hand-written fixtures and now-unused imports. Keep `SACKS_STAND_IN`, `NOOP_HANDLERS` and `PrepareFixture` (or move it per Open contract 1). - -- [ ] **Step 6: Run, typecheck, commit** - -Run: `npx vitest run src/renderer/experiments/__tests__/prepareContent.test.ts` → pass. `npx tsc --noEmit` → 0 errors. `npx vitest run src/renderer/components/PrepareSteps` → stories still compile. - -```bash -git add src/renderer/experiments src/renderer/components/PrepareSteps -git commit -m "feat(prepare): per-experiment protocol data and real trial counts" -``` - ---- - -### Task 2: The built-in Design screen renders `PrepareSteps` - -**Files:** -- Modify: `src/renderer/components/DesignComponent/index.tsx` -- Modify: `src/renderer/constants/interfaces.ts`, `src/renderer/experiments/*/index.ts` -- Modify: `src/renderer/components/SecondaryNavComponent/index.tsx` (docstring) -- Test: `src/renderer/components/DesignComponent/__tests__/DesignPrepare.test.tsx` - -**Interfaces:** -- Consumes: `PrepareSteps` props (#276), `prepare` from Task 1's modules, `DesignProps`. -- Produces: `DesignComponent` renders `PrepareSteps` for the four built-ins. Custom (`CustomDesign`) and Imported (`ImportedDesign`) are unchanged in behavior. - -- [ ] **Step 1: Write the failing test** - -```tsx -// src/renderer/components/DesignComponent/__tests__/DesignPrepare.test.tsx -import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; -import Design from '../index'; -import { EXPERIMENTS } from '../../../constants/constants'; - -vi.mock('lab.js', () => ({})); -vi.mock('../CustomDesignComponent', () => ({ default: () => <div data-testid="custom-design" /> })); -vi.mock('../ImportedDesignComponent', () => ({ default: () => <div data-testid="imported-design" /> })); -vi.mock('../../PreviewExperimentComponent', () => ({ default: () => <div data-testid="preview-experiment" /> })); - -const baseProps = { - navigate: vi.fn(), - type: EXPERIMENTS.N170, - title: 'Faces_Houses_1', - params: {} as never, - experimentObject: {} as never, - ExperimentActions: { SetEEGEnabled: vi.fn(), SaveWorkspace: vi.fn(), CreateNewWorkspace: vi.fn() } as never, - isEEGEnabled: true, -}; - -describe('Design — built-in Prepare', () => { - it('renders the Prepare steps with the experiment keys and the real trial counts', () => { - render(<Design {...baseProps} />); - expect(screen.getByRole('navigation', { name: 'Prepare steps' })).toBeInTheDocument(); - expect(screen.getByText('Face')).toBeInTheDocument(); - expect(screen.getByText('House')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Next: Background/ })).toBeInTheDocument(); - }); - - it('walks Overview → Background → Protocol → Preview and offers Try the experiment', () => { - render(<Design {...baseProps} />); - fireEvent.click(screen.getByRole('button', { name: /Next: Background/ })); - expect(screen.getByText(/BACKGROUND/)).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: /Next: Protocol/ })); - expect(screen.getByText('WHAT HAPPENS IN THIS TASK')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: /Try the experiment/ })); - expect(screen.getByTestId('preview-experiment')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Run & record/ })).toBeInTheDocument(); - }); - - it('keeps Custom on its authoring flow', () => { - render(<Design {...baseProps} type={EXPERIMENTS.CUSTOM} />); - expect(screen.getByTestId('custom-design')).toBeInTheDocument(); - expect(screen.queryByRole('navigation', { name: 'Prepare steps' })).not.toBeInTheDocument(); - }); - - it('keeps Imported on its authoring flow', () => { - render(<Design {...baseProps} type={EXPERIMENTS.IMPORTED} />); - expect(screen.getByTestId('imported-design')).toBeInTheDocument(); - expect(screen.queryByRole('navigation', { name: 'Prepare steps' })).not.toBeInTheDocument(); - }); -}); -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `npx vitest run src/renderer/components/DesignComponent/__tests__/DesignPrepare.test.tsx` -Expected: FAIL — `Prepare steps` navigation absent. - -- [ ] **Step 3: Wire `PrepareSteps` into `DesignComponent`** - -For the four built-ins (N170, STROOP, SEARCH, MULTI), replace the `SecondaryNavComponent` + `renderSectionContent()` pair with `PrepareSteps`: - -```tsx - const prepare = getExperimentFromType(props.type).prepare; - return ( - <PrepareSteps - step={activeStep} - overview={prepare.overview} - background={prepare.background} - protocol={prepare.protocol} - responses={prepare.responses} - flow={prepare.flow} - icon={prepare.icon} - mediaFallback={props.type === EXPERIMENTS.N170 ? SACKS_STAND_IN : undefined} - isEEGEnabled={props.isEEGEnabled} - onEEGEnabledChange={handleEEGEnabled} - onCustomize={() => setIsNewExperimentModalOpen(true)} - onStep={setActiveStep} - onCollect={() => props.navigate(SCREENS.COLLECT.route)} - onPreviewStart={() => setIsPreviewing(true)} - onPreviewStop={() => setIsPreviewing(false)} - onPreviewAgain={() => setIsPreviewing(true)} - isPreviewing={isPreviewing} - hasPreviewed={hasPreviewed} - /> - ); -``` - -- Add `prepare?: PrepareFixture` to `Experiment` in `constants/interfaces.ts`, and export `prepare` from each built-in's `index.ts` (Open contract 4). -- `DESIGN_STEPS` maps to `PrepareStepId`: `OVERVIEW → 'overview'`, `BACKGROUND → 'background'`, `PROTOCOL → 'protocol'`, `PREVIEW → 'preview'`. -- `hasPreviewed` becomes a `useState` flipped by `onPreviewStart`. -- Pass `isEEGEnabled` / `onEEGEnabledChange` / `onCustomize` to `PrepareSteps` per Ruling 2; add its stories for those states. -- Delete `renderSectionContent`, the four `case` blocks and now-unused imports (`renderConditionIcon`, `renderOverviewIcon`, `SecondaryNavComponent`, `InputModal` if only used for Customize). - -- [ ] **Step 4: Custom and Imported keep their flow** - -`CustomDesignComponent` and `ImportedDesignComponent` still render `SecondaryNavComponent` with `CUSTOM_STEPS` / `IMPORTED_STEPS`. Keep the name and add a docstring (Ruling 3). The test above covers both. - -- [ ] **Step 5: Run, typecheck, commit** - -Run: `npx vitest run src/renderer/components/DesignComponent` → pass. `npx tsc --noEmit` → 0 errors. - -```bash -git add src/renderer/components/DesignComponent src/renderer/constants/interfaces.ts src/renderer/experiments/*/index.ts -git commit -m "feat(prepare): built-in Design screen renders the approved Prepare steps" -``` - ---- - -### Task 3: One preview label app-wide - -**Files:** -- Modify: `src/renderer/components/PreviewButtonComponent.tsx` -- Test: `src/renderer/components/__tests__/PreviewButton.test.tsx` (create) + `PreviewButtonComponent.stories.tsx` (create) -- Modify: `src/renderer/components/PrepareSteps/PrepareSteps.tsx` (preview box height only) - -**Interfaces:** -- Consumes: `PreviewLabel` (#276). -- Produces: `PreviewButton` renders `PreviewLabel` instead of its inline `<b>PREVIEW</b> · nothing is being recorded` span. - -- [ ] **Step 1: Write the failing test** - -```tsx -// src/renderer/components/__tests__/PreviewButton.test.tsx -import React from 'react'; -import { fireEvent, render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; -import PreviewButton from '../PreviewButtonComponent'; - -describe('PreviewButton', () => { - it('shows the one shared preview label while previewing', () => { - render(<PreviewButton isPreviewing onClick={vi.fn()} />); - expect(screen.getByText('PREVIEW')).toBeInTheDocument(); - expect(screen.getByText(/not recording/)).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Stop preview' })).toBeInTheDocument(); - }); - - it('offers Run & record once a preview has run', () => { - const onRun = vi.fn(); - const { rerender } = render(<PreviewButton isPreviewing={false} onClick={vi.fn()} onRunAndRecord={onRun} />); - fireEvent.click(screen.getByRole('button', { name: 'Preview experiment' })); - rerender(<PreviewButton isPreviewing={false} onClick={vi.fn()} onRunAndRecord={onRun} />); - fireEvent.click(screen.getByRole('button', { name: 'Run & record' })); - expect(onRun).toHaveBeenCalled(); - }); -}); -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `npx vitest run src/renderer/components/__tests__/PreviewButton.test.tsx` -Expected: FAIL — the shared label's glyph and layout differ from today's inline span. - -- [ ] **Step 3: Use `PreviewLabel`** - -Replace the inline status span with `<PreviewLabel />`. Keep the `hasPreviewed` state and the "Preview experiment" / "Preview again" / "Run & record" button order exactly as they are. Remove the now-unused inline markup. - -- [ ] **Step 4: The preview box shows a full participant screen** - -Raise the preview area so the first participant screen (title, keys, notes and the Space line) is visible without scrolling (Ruling 4): `min-h-[420px]` growing with content, instead of `h-[330px]`. Add a `PreviewRunningFull` story that renders the Preview step with a real participant screen and asserts no internal scroll at both sizes. - -- [ ] **Step 5: Run, typecheck, commit** - -Run: `npx vitest run src/renderer/components/__tests__/PreviewButton.test.tsx src/renderer/components/PrepareSteps` → pass. `npx tsc --noEmit` → 0 errors. - -```bash -git add src/renderer/components/PreviewButtonComponent.tsx src/renderer/components/PrepareSteps -git commit -m "feat(preview): one preview label; preview box shows a full participant screen" -``` - ---- - -### Task 4: Shared surfaces, Rule A, verification and docs - -**Files:** -- Modify: `src/renderer/components/CollectComponent/PreTestComponent.tsx` (preview label now shared) -- Modify: `TODOS.md`, `.llms/learnings.md` - -- [ ] **Step 1: Collect preview uses the same label** - -`PreTestComponent`'s preview area and `PreviewButton` now share `PreviewLabel`. Verify the Design and Collect previews read identically at both sizes. If `PreTestComponent` has its own copy of the "nothing is being recorded" text, replace it. - -- [ ] **Step 2: Rule A measurement** - -For Overview, Background, Protocol (×3 fixtures) and Preview (stopped/running/finished), at 1366×768 and 1280×720: the step's primary action is inside the viewport and the page doesn't scroll. Record the numbers in the PR. If Background or Protocol scrolls internally, that's acceptable: the action row must stay visible. - -- [ ] **Step 3: Docs** - -`TODOS.md`: strike "Design preview box clips participant screens" (Task 3 fixes it) and note the WS4 Prepare integration shipped (`YYYY-MM-DD, PR #N`). Add to Next: "Multitasking's Prepare protocol diagram describes two rules on the same keys; verify with a teacher whether it reads clearly." - -Append to `.llms/learnings.md`: - -```markdown -## Prepare: `PrepareSteps` owns the built-in step chrome - -`DesignComponent` renders `PrepareSteps` for the four built-in studies; -Custom and Imported keep `SecondaryNavComponent` and their authoring steps. -Per-experiment data lives in each experiment's `prepare.ts` -(`responses` + `flow`), re-exported by `PrepareSteps/fixtures.ts`, so -Storybook shows what runs. Trial counts come from `flowFromStructure` and -the experiment's real loops — never `params.nbTrials`, which is stale for -Stroop (real 8/96) and Search (real 8/80). `PreviewLabel` is the single -preview status, used by `PreviewButtonComponent`. -``` - -- [ ] **Step 4: Full checks and the playtest** - -Run: `npm run typecheck && npm run lint && npm test && node tests/electron-smoke.mjs` → all green. - -Electron playtest (Fixture headset, `skill://electron-playtest`, screenshots in `/tmp/ws4-integration/`): -1. **Faces/Houses Design.** Gold pill step bar. Overview → Background → Protocol shows the face → 1 / house → 9 diagram and a vertical timeline reading 6 practice / 120 recorded. -2. **Stroop Design.** Diagram shows R/G/B/Y with the ink-color words; timeline reads 8 / 96. -3. **Visual Search Design.** Diagram shows B/N with the T examples; timeline reads 8 / 80. -4. **Multitasking Design.** Diagram shows its rules on B/N and a block timeline with no study-wide total. -5. **Preview.** `PreviewLabel` next to `Stop preview`; the participant screen and its keys are visible without scrolling. `Run & record` appears after a preview. -6. **Custom and Imported Design** still open their authoring flows. -7. **Keyboard.** Tab order reaches the step pills, the action row and the EEG toggle. - -- [ ] **Step 5: PR** - -```bash -git add TODOS.md .llms/learnings.md -git commit -m "docs: WS4 Prepare integration notes" -gh pr create --title "feat(prepare): WS4 Prepare integration" --body "Implements docs/superpowers/plans/2026-09-25-ws4-prepare-integration.md. Verification: <checks output, Rule A numbers, playtest screenshots>." -```