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 d91576f6..bc744090 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,12 @@ 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. +- [ ] **Expanded preview** — the Design preview is fit-to-box at zoom 0.55 (small print ~10px). Add an expanded/full-size preview with the same Preview label and Stop action. +- [ ] **Collect without a workspace** — Collect actions (Preview, Run & record) should be unavailable when there is no workspace (`params` is null after `ExperimentCleanup`, e.g. cancelling headset setup while connecting). `LabjsExperimentWindow` now waits instead of crashing (PR #281), but the buttons still look usable. - [ ] **Cleaning UXR playtest** (added 2026-09-25, after the WS6 Clean design #280) — a naive user, with no facilitator help, cleans one real recording in the redesigned Clean screen. Can they find the job (leave out noisy trials, flag a bad sensor, review auto-flag suggestions, watch the Live ERP change, save), and do they understand why cleaning comes before Analyze? Run after WS6 integration, before WS8's full second playtest. - [ ] **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. 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: () => <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: {}, + 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(<Design {...baseProps} />); + 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(<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.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(<Design {...baseProps} />); + 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(<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(); + }); +}); 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<PrepareStepId>('overview'); const [isPreviewing, setIsPreviewing] = useState(false); + const [hasPreviewed, setHasPreviewed] = useState(false); const [isNewExperimentModalOpen, setIsNewExperimentModalOpen] = useState(false); const [recentWorkspaces, setRecentWorkspaces] = useState<string[]>([]); @@ -102,12 +48,14 @@ export default function Design(props: DesignProps) { }; }, []); - if (props.type === EXPERIMENTS.CUSTOM) { - return <CustomDesign {...props} />; - } + const { prepare } = getExperimentFromType(props.type); - if (props.type === EXPERIMENTS.IMPORTED) { - return <ImportedDesign {...props} />; + if (!prepare) { + return props.type === EXPERIMENTS.CUSTOM ? ( + <CustomDesign {...props} /> + ) : ( + <ImportedDesign {...props} /> + ); } function handleLoadCustomExperiment(title: string) { @@ -127,225 +75,46 @@ export default function Design(props: DesignProps) { props.ExperimentActions.SaveWorkspace(); } - function handlePreview(event: React.MouseEvent<HTMLButtonElement>) { - 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 ( - <section className="flex flex-col gap-5"> - <header className="flex max-w-[680px] flex-col gap-2.5"> - <span className="text-[13px] font-bold tracking-[0.5px] text-ink-muted"> - BACKGROUND · 2 MIN READ - </span> - <h1 className="[text-wrap:pretty]"> - {background.title ?? 'Background'} - </h1> - </header> - <p className="experiment-design-copy max-w-[680px]"> - {background.first_column_statement} - </p> - {background.first_column_question && ( - <div className="flex max-w-[680px] items-start gap-5 rounded-lg border border-[#ececf1] bg-white p-6"> - {overviewIcon && ( - <div className="flex h-[72px] w-[72px] flex-none items-center justify-center rounded-lg border border-[#f6ead3] bg-[#fffaf0]"> - <img src={overviewIcon} alt="" className="max-w-11" /> - </div> - )} - <div className="flex flex-col gap-2"> - <h2 className="text-xl font-normal"> - {background.definition_title ?? 'What researchers found'} - </h2> - <p className="experiment-design-card-copy"> - {background.first_column_question} - </p> - </div> - </div> - )} - {(background.second_column_statement || - background.second_column_question) && ( - <div className="flex max-w-[680px] items-center gap-5 rounded-lg border border-[#e3def7] bg-[#f4f2ff] px-6 py-5"> - <div className="flex min-w-0 flex-1 flex-col gap-2"> - <span className="text-[13px] font-bold tracking-[0.5px] text-[#4a3fa8]"> - FUN FACT - </span> - <p className="experiment-design-card-copy"> - {[ - background.second_column_statement, - background.second_column_question, - ] - .filter(Boolean) - .join(' ')} - </p> - </div> - {background.fun_fact_image && ( - <img - src={background.fun_fact_image} - alt="" - className="w-[88px] flex-none" - /> - )} - </div> - )} - {background.links.map((link) => ( - <div - key={link.address} - className="flex flex-wrap items-center gap-4" - > - <Button - variant="outline-brand" - onClick={() => window.open(link.address, '_blank')} - > - {link.name} - </Button> - {background.link_meta && ( - <span className="text-[15px] text-ink-muted"> - {background.link_meta} - </span> - )} - </div> - ))} - </section> - ); - - case DESIGN_STEPS.PROTOCOL: - return ( - <section className="flex flex-col gap-5"> - <header className="flex max-w-[680px] flex-col gap-2.5"> - <span className="text-[13px] font-bold tracking-[0.5px] text-ink-muted"> - PROTOCOL - </span> - <h1 className="[text-wrap:pretty]">{protocol.title}</h1> - <p className="experiment-design-copy">{protocol.protocol}</p> - </header> - <div className="grid max-w-[1100px] grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-5"> - {[ - { - 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) => ( - <article - key={condition.title} - className="flex flex-col items-start gap-3.5 rounded-lg border border-[#ececf1] bg-white p-5" - > - <img - className="h-[150px] w-full rounded-md object-cover" - src={renderConditionIcon(condition.image)} - alt={condition.title} - /> - <h2 className="text-xl font-normal">{condition.title}</h2> - <p className="experiment-design-card-copy"> - {condition.description}{' '} - {condition.key && ( - <kbd className="inline-flex h-[26px] min-w-[26px] items-center justify-center rounded-md border-2 border-ink text-[15px] font-bold"> - {condition.key} - </kbd> - )} - </p> - </article> - ))} - </div> - </section> - ); - - case DESIGN_STEPS.PREVIEW: - return ( - <section className="flex flex-col gap-5"> - <header className="flex max-w-[680px] flex-col gap-2.5"> - <span className="text-[13px] font-bold tracking-[0.5px] text-ink-muted"> - PREVIEW - </span> - <h1 className="[text-wrap:pretty]">See it the way they will</h1> - </header> - <div className="h-[330px] w-full max-w-[1100px] rounded-lg border-2 border-brand bg-white"> - <PreviewExperimentComponent - title={props.title} - params={props.params} - experimentObject={props.experimentObject} - isPreviewing={isPreviewing} - onEnd={() => setIsPreviewing(false)} - type={props.type} - /> - </div> - <PreviewButton - isPreviewing={isPreviewing} - onClick={handlePreview} - onRunAndRecord={() => props.navigate(SCREENS.COLLECT.route)} - /> - </section> - ); - - case DESIGN_STEPS.OVERVIEW: - default: - return ( - <section className="flex flex-wrap-reverse items-start gap-12"> - <div className="flex min-w-0 max-w-[680px] flex-[1_1_420px] flex-col gap-3.5"> - <span className="text-[13px] font-bold tracking-[0.5px] text-ink-muted"> - THE BIG QUESTION - </span> - <h1 className="[text-wrap:pretty]">{overview.title}</h1> - {overview.overview.split(/\n\s*\n/).map((paragraph) => ( - <p key={paragraph} className="experiment-design-copy"> - {paragraph} - </p> - ))} - </div> - {overviewIcon && ( - <div className="flex flex-[0_1_260px] items-center justify-center pt-2"> - <img - src={overviewIcon} - alt={overview.title} - className="h-auto w-full max-w-60" - /> - </div> - )} - </section> - ); - } - } - return ( - <div className="flex h-[calc(100vh-60px)] flex-col overflow-hidden bg-white"> - <SecondaryNavComponent - title="Experiment Design" - steps={DESIGN_STEPS} - activeStep={activeStep} - onStepClick={setActiveStep} + <> + <PrepareSteps + {...prepare} + step={activeStep} + onStep={setActiveStep} isEEGEnabled={props.isEEGEnabled} onEEGEnabledChange={handleEEGEnabled} onCustomize={() => setIsNewExperimentModalOpen(true)} + onCollect={() => props.navigate(SCREENS.COLLECT.route)} + onPreviewStart={() => { + setIsPreviewing(true); + setHasPreviewed(true); + }} + onPreviewStop={() => setIsPreviewing(false)} + onPreviewAgain={() => setIsPreviewing(true)} + isPreviewing={isPreviewing} + hasPreviewed={hasPreviewed} + preview={ + <PreviewExperimentComponent + title={props.title} + params={props.params} + experimentObject={props.experimentObject} + isPreviewing={isPreviewing} + onEnd={() => setIsPreviewing(false)} + type={props.type} + /> + } /> - <main className="experiment-design-content min-h-0 flex-1 overflow-y-auto bg-app"> - <div className="w-full px-9 pb-12 pt-9">{renderSectionContent()}</div> - </main> <InputModal open={isNewExperimentModalOpen} onClose={handleLoadCustomExperiment} onExit={() => setIsNewExperimentModalOpen(false)} header="Enter a title for this experiment" /> - </div> + </> ); } 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/PrepareSteps/PrepareSteps.stories.tsx b/src/renderer/components/PrepareSteps/PrepareSteps.stories.tsx index cb4e2d74..0ba8c891 100644 --- a/src/renderer/components/PrepareSteps/PrepareSteps.stories.tsx +++ b/src/renderer/components/PrepareSteps/PrepareSteps.stories.tsx @@ -1,13 +1,20 @@ 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, { PrepareStepId } from './PrepareSteps'; +import PrepareSteps, { PrepareFixture, PrepareStepId } from './PrepareSteps'; import { FACES_HOUSES, + MULTITASKING, NOOP_HANDLERS, - PrepareFixture, - SACKS_STAND_IN, SEARCH, STROOP, } from './fixtures'; @@ -32,6 +39,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 @@ -52,7 +65,21 @@ const withPrepareChrome: Decorator = (Story, { parameters }) => ( const meta: Meta<typeof PrepareSteps> = { 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, @@ -63,6 +90,9 @@ const meta: Meta<typeof PrepareSteps> = { onPreviewStart: fn(), onPreviewStop: fn(), onPreviewAgain: fn(), + isEEGEnabled: true, + onEEGEnabledChange: fn(), + onCustomize: fn(), }, }; export default meta; @@ -79,9 +109,13 @@ function InteractiveStep({ const [step, setStep] = useState<PrepareStepId>(initialStep); const [isPreviewing, setIsPreviewing] = useState(false); const [hasPreviewed, setHasPreviewed] = useState(false); + const [isEEGEnabled, setIsEEGEnabled] = useState(true); return ( <PrepareSteps {...fixture} + isEEGEnabled={isEEGEnabled} + onEEGEnabledChange={setIsEEGEnabled} + onCustomize={() => {}} step={step} isPreviewing={isPreviewing} hasPreviewed={hasPreviewed} @@ -102,9 +136,10 @@ export const Overview: Story = { render: () => <InteractiveStep initialStep="overview" />, }; -/** 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: () => <InteractiveStep initialStep="background" />, + parameters: { workspace: stroopWorkspace }, + render: () => <InteractiveStep initialStep="background" fixture={STROOP} />, }; /** P03 — Protocol. Static stimulus → key diagram beside a vertical task timeline generated from `flow`. */ @@ -124,6 +159,14 @@ export const ProtocolSearch: Story = { render: () => <InteractiveStep initialStep="protocol" fixture={SEARCH} />, }; +/** 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: () => ( + <InteractiveStep initialStep="protocol" fixture={MULTITASKING} /> + ), +}; + /** P06 — PreviewStopped. Nothing recorded; Try the experiment is primary. */ export const PreviewStopped: Story = { render: () => <InteractiveStep initialStep="preview" />, @@ -142,6 +185,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 ( + <div className="flex h-full w-full"> + <div + className="container" + data-labjs-section="main" + dangerouslySetInnerHTML={{ + __html: html.replace( + /\$\{this\.parameters\.isEEGEnabled[^}]*\}/, + `<li>${STILLNESS_LINE}</li>` + ), + }} + /> + </div> + ); +} + +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 }) => ( + <PrepareSteps + {...FIRST_SCREENS[experiment].fixture} + {...NOOP_HANDLERS} + step="preview" + isPreviewing + hasPreviewed + preview={ + <ParticipantMount + html={instructionsScreen(FIRST_SCREENS[experiment].screen)} + /> + } + /> + ), + 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: () => ( @@ -165,14 +281,28 @@ export const DirectCollect: Story = { render: () => <InteractiveStep initialStep="overview" fixture={STROOP} />, }; -/** P10 — OliverSacksFallback. Background's video slot holds the local stand-in (16:9 placeholder) and transcript-length text; no remote player. */ +/** P11 — EEGOff. The action row's EEG recording switch off (behavior-only workspace); Customize beside it. */ +export const EEGOff: Story = { + parameters: { workspace: behaviorWorkspace }, + render: () => ( + <PrepareSteps + {...STROOP} + {...NOOP_HANDLERS} + step="overview" + isEEGEnabled={false} + isPreviewing={false} + hasPreviewed={false} + /> + ), +}; + +/** 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 {...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 8a3a5fe6..50f6fa1b 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; @@ -46,8 +46,27 @@ 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; } +/** One built-in experiment's Prepare content, defined in its `experiments/<name>/prepare.ts`. */ +export type PrepareFixture = Pick< + PrepareStepsProps, + | 'overview' + | 'background' + | 'protocol' + | 'responses' + | 'flow' + | 'icon' + | 'mediaFallback' +>; + /** 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 +146,7 @@ function KeyLegend({ responses }: { responses: ResponseMapping[] }) { return ( <ul className="m-0 flex flex-wrap items-center gap-x-5 gap-y-2 p-0"> {responses.map(({ label, key }) => ( - <li key={key} className="flex items-center gap-2"> + <li key={label} className="flex items-center gap-2"> <kbd className={KEYCAP}>{key}</kbd> <span className="text-[16px] text-ink">{label}</span> </li> @@ -154,7 +173,7 @@ function ResponseDiagram({ responses }: { responses: ResponseMapping[] }) { <span className={rail}>Presses</span> </li> {responses.map(({ key, label, stimulus }) => ( - <li key={key} className="contents"> + <li key={label} className="contents"> {'src' in stimulus ? ( <img src={stimulus.src} @@ -219,7 +238,9 @@ function FlowTimeline({ phases }: { phases: FlowPhase[] }) { aria-hidden className={cn( 'mt-[6px] h-[12px] w-[12px] flex-none rounded-full border-2', - phase.count ? 'border-brand bg-brand' : 'border-ink-muted bg-white' + phase.count + ? 'border-brand bg-brand' + : 'border-ink-muted bg-white' )} /> <span className="text-[16px] leading-[24px] text-ink"> @@ -276,7 +297,9 @@ function BackgroundView({ <section className="flex flex-col gap-4"> <header className="flex flex-col gap-1"> <span className={EYEBROW}>BACKGROUND · 2 MIN READ</span> - <h1 className="m-0 [text-wrap:pretty]">{background.title ?? 'Background'}</h1> + <h1 className="m-0 [text-wrap:pretty]"> + {background.title ?? 'Background'} + </h1> </header> <p className="experiment-design-copy m-0 !leading-snug"> {background.first_column_statement} @@ -298,20 +321,28 @@ function BackgroundView({ </div> </div> )} - {(background.second_column_statement || background.second_column_question) && ( + {(background.second_column_statement || + background.second_column_question) && ( <div className="flex items-center gap-4 rounded-lg border border-[#e3def7] bg-[#f4f2ff] px-4 py-3"> <div className="flex min-w-0 flex-1 flex-col gap-0.5"> <span className="text-[13px] font-bold tracking-[0.5px] text-[#4a3fa8]"> FUN FACT </span> <p className="experiment-design-card-copy m-0 !leading-snug"> - {[background.second_column_statement, background.second_column_question] + {[ + background.second_column_statement, + background.second_column_question, + ] .filter(Boolean) .join(' ')} </p> </div> {background.fun_fact_image && ( - <img src={background.fun_fact_image} alt="" className="w-[60px] flex-none" /> + <img + src={background.fun_fact_image} + alt="" + className="w-[60px] flex-none" + /> )} </div> )} @@ -327,7 +358,9 @@ function BackgroundView({ {link.name} </Button> {background.link_meta && ( - <span className="text-[15px] text-ink-muted">{background.link_meta}</span> + <span className="text-[15px] text-ink-muted"> + {background.link_meta} + </span> )} </div> )) @@ -336,27 +369,79 @@ 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 } }) { +/** 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, +}: { + media: { caption: string; alt: string }; +}) { 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 @@ -365,14 +450,14 @@ function OliverSacksFallback({ media }: { media: { caption: string; alt: string </p> <p className="experiment-design-copy m-0 !leading-snug"> Scientists call this condition <b>prosopagnosia</b>, 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. </p> <p className="experiment-design-copy m-0 !leading-snug"> - 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. </p> <p className="experiment-design-copy m-0 !leading-snug"> <b>Source:</b> Oliver Sacks, "Face-Blind" (2010),{' '} @@ -396,7 +481,9 @@ function ProtocolView({ <header className="flex flex-col gap-1"> <span className={EYEBROW}>PROTOCOL</span> <h1 className="m-0 [text-wrap:pretty]">{protocol.title}</h1> - <p className="experiment-design-copy m-0 !leading-snug">{protocol.protocol}</p> + <p className="experiment-design-copy m-0 !leading-snug"> + {protocol.protocol} + </p> </header> <div className="grid grid-cols-[minmax(0,1fr)_240px] gap-4"> <ResponseDiagram responses={responses} /> @@ -418,15 +505,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 ( <section className="flex flex-1 flex-col gap-3"> - <div className="flex flex-1 flex-col gap-3 rounded-lg border-2 border-dashed border-[#d4d4de] bg-white p-4"> + <div className="flex min-h-[420px] flex-1 flex-col gap-3 rounded-lg border-2 border-dashed border-[#d4d4de] bg-white p-4"> <span className={EYEBROW}>EXPERIMENT AREA</span> - <div className="flex flex-1 items-center justify-center rounded-md bg-[#f9f9f9]"> - <span className="text-[15px] text-ink-muted"> - Participant screen would appear here - </span> + <div className="relative min-h-0 flex-1 overflow-hidden rounded-md bg-[#f9f9f9]"> + {preview ? ( + <div className="absolute inset-0 flex [zoom:0.55]">{preview}</div> + ) : ( + <span className="absolute inset-0 m-auto h-fit w-fit text-[15px] text-ink-muted"> + Participant screen would appear here + </span> + )} </div> </div> <KeyLegend responses={responses} /> @@ -472,6 +574,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 ( + <label className="flex cursor-pointer items-center gap-2 rounded-full p-1 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-brand"> + <input + type="checkbox" + checked={checked} + onChange={(event) => onChange(event.target.checked)} + className="sr-only" + /> + <span + aria-hidden + className={cn( + 'flex h-[22px] w-10 flex-none items-center rounded-full px-[3px]', + checked ? 'justify-end bg-brand' : 'justify-start bg-ink-faint' + )} + > + <span className="h-4 w-4 rounded-full bg-white" /> + </span> + <span className="text-[14px] text-ink">EEG recording</span> + </label> + ); +} + /** 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; @@ -515,7 +647,9 @@ export default function PrepareSteps(props: PrepareStepsProps) { <section className="flex flex-col gap-4"> <header className="flex flex-col gap-1"> <span className={EYEBROW}>PREVIEW</span> - <h1 className="m-0 [text-wrap:pretty]">{previewIntro.title}</h1> + <h1 className="m-0 [text-wrap:pretty]"> + {previewIntro.title} + </h1> <p className="experiment-design-copy m-0 !leading-snug"> {previewIntro.body} </p> @@ -523,42 +657,82 @@ export default function PrepareSteps(props: PrepareStepsProps) { <KeyLegend responses={props.responses} /> </section> )} - {previewRunning && <PreviewRunningView responses={props.responses} />} + {previewRunning && ( + <PreviewRunningView + responses={props.responses} + preview={props.preview} + /> + )} </div> <StepActions> {step === 'overview' && ( - <ActionNext onClick={() => onStep('background')}>Next: Background →</ActionNext> + <ActionNext onClick={() => onStep('background')}> + Next: Background → + </ActionNext> )} {step === 'background' && ( <> - <ActionBack onClick={() => onStep('overview')}>← Back</ActionBack> - <ActionNext onClick={() => onStep('protocol')}>Next: Protocol →</ActionNext> + <ActionBack onClick={() => onStep('overview')}> + ← Back + </ActionBack> + <ActionNext onClick={() => onStep('protocol')}> + Next: Protocol → + </ActionNext> </> )} {step === 'protocol' && ( <> - <ActionBack onClick={() => onStep('background')}>← Back</ActionBack> - <ActionNext onClick={() => onStep('preview')}>Try the experiment →</ActionNext> + <ActionBack onClick={() => onStep('background')}> + ← Back + </ActionBack> + <ActionNext onClick={() => onStep('preview')}> + Try the experiment → + </ActionNext> </> )} {previewStopped && ( <> - <ActionBack onClick={() => onStep('protocol')}>← Back</ActionBack> - <ActionNext onClick={props.onPreviewStart}>Try the experiment →</ActionNext> + <ActionBack onClick={() => onStep('protocol')}> + ← Back + </ActionBack> + <ActionNext onClick={props.onPreviewStart}> + Try the experiment → + </ActionNext> </> )} {previewRunning && ( <> - <ActionBack onClick={props.onPreviewStop}>Stop preview</ActionBack> + <ActionBack onClick={props.onPreviewStop}> + Stop preview + </ActionBack> <PreviewLabel /> </> )} {previewFinished && ( <> - <ActionBack onClick={props.onPreviewAgain}>Preview again</ActionBack> - <ActionNext onClick={props.onCollect}>Run & record →</ActionNext> + <ActionBack onClick={props.onPreviewAgain}> + Preview again + </ActionBack> + <ActionNext onClick={props.onCollect}> + Run & record → + </ActionNext> </> )} + {(props.onEEGEnabledChange || props.onCustomize) && ( + <div className="ml-auto flex items-center gap-2"> + {props.onEEGEnabledChange && ( + <EEGSwitch + checked={Boolean(props.isEEGEnabled)} + onChange={props.onEEGEnabledChange} + /> + )} + {props.onCustomize && ( + <Button variant="link" onClick={props.onCustomize}> + Customize + </Button> + )} + </div> + )} </StepActions> </div> </main> diff --git a/src/renderer/components/PrepareSteps/fixtures.ts b/src/renderer/components/PrepareSteps/fixtures.ts index 49581cc8..31190c70 100644 --- a/src/renderer/components/PrepareSteps/fixtures.ts +++ b/src/renderer/components/PrepareSteps/fixtures.ts @@ -1,105 +1,11 @@ -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'; +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'; -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, - }), -}; - -/** 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', -}; - -/** 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. */ +/** Shared no-op callbacks, with EEG on, so stories show the Design screen's full action row. */ export const NOOP_HANDLERS: Pick< PrepareStepsProps, | 'onStep' @@ -107,10 +13,16 @@ export const NOOP_HANDLERS: Pick< | 'onPreviewStart' | 'onPreviewStop' | 'onPreviewAgain' + | 'isEEGEnabled' + | 'onEEGEnabledChange' + | 'onCustomize' > = { onStep: () => {}, onCollect: () => {}, onPreviewStart: () => {}, onPreviewStop: () => {}, onPreviewAgain: () => {}, + isEEGEnabled: true, + onEEGEnabledChange: () => {}, + onCustomize: () => {}, }; 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/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<typeof PreviewButton> = { + title: 'Domain/PreviewButton', + component: PreviewButton, + decorators: [ + (Story) => ( + <div className="p-6"> + <Story /> + </div> + ), + ], + args: { isPreviewing: false, onClick: fn(), onRunAndRecord: fn() }, +}; +export default meta; +type Story = StoryObj<typeof PreviewButton>; + +/** 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 ( + <PreviewButton + isPreviewing={isPreviewing} + onClick={() => setIsPreviewing((previewing) => !previewing)} + onRunAndRecord={() => {}} + /> + ); +} + +/** Clickable: after a preview, Run & record becomes primary and Preview again secondary. */ +export const Interactive: Story = { + render: () => <InteractivePreviewButton />, +}; 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({ </Button> </> )} - <span role="status" className="text-[15px] text-ink-muted"> - {isPreviewing ? ( - <> - <b className="tracking-[0.5px] text-ink">PREVIEW</b> · nothing is - being recorded - </> - ) : ( - 'Nothing is recorded during a preview.' - )} - </span> + {isPreviewing ? ( + <PreviewLabel /> + ) : ( + <span className="text-[15px] text-ink-muted"> + Nothing is recorded during a preview. + </span> + )} </div> ); } 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({ ) )} <DropdownMenuSeparator className="mx-1 my-2 bg-[#ececf1]" /> - {onCustomize && ( - <DropdownMenuItem - onSelect={onCustomize} - className="p-2.5 text-[15px]" - > - Customize this experiment - </DropdownMenuItem> - )} <DropdownMenuItem asChild className="p-2.5 text-[15px]"> <NavLink to={homeRoute}>Back to Home</NavLink> </DropdownMenuItem> @@ -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/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], 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(<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} + /> + ); + expect( + screen.queryByRole('button', { name: 'Run & record' }) + ).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Preview experiment' })); + rerender( + <PreviewButton isPreviewing onClick={vi.fn()} onRunAndRecord={onRun} /> + ); + rerender( + <PreviewButton + isPreviewing={false} + onClick={vi.fn()} + onRunAndRecord={onRun} + /> + ); + fireEvent.click(screen.getByRole('button', { name: 'Run & record' })); + expect(onRun).toHaveBeenCalled(); + }); +}); 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/__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<string, string>; - hooks?: Record<string, unknown>; -}; - -/** Every non-Space, non-skip key any screen in the study responds to. */ -const acceptedKeys = (node: unknown, out = new Set<string>()): Set<string> => { - if (Array.isArray(node)) node.forEach((child) => acceptedKeys(child, out)); - else if (node && typeof node === 'object') { - for (const key of Object.keys((node as Node).responses ?? {})) { - const match = /^key(?:press|down)\((.+)\)$/.exec(key); - if (match && match[1] !== 'Space' && match[1] !== 'q') - out.add(match[1].toLowerCase()); - } - Object.values(node).forEach((child) => acceptedKeys(child, out)); - } - return out; -}; - /** The 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<string, unknown>) => 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<string, string>; + hooks?: Record<string, unknown>; + template?: unknown; + templateParameters?: Array<Record<string, unknown>>; + sample?: { n?: string }; +}; + +/** Every non-Space, non-skip key any screen in the study responds to. */ +export const acceptedKeys = ( + node: unknown, + out = new Set<string>() +): Set<string> => { + if (Array.isArray(node)) node.forEach((child) => acceptedKeys(child, out)); + else if (node && typeof node === 'object') { + for (const key of Object.keys((node as 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/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 new file mode 100644 index 00000000..a95fd1ae --- /dev/null +++ b/src/renderer/experiments/faces_houses/prepare.ts @@ -0,0 +1,31 @@ +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, + // §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 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' } }, + { + 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/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/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/multitasking/stimuli/multiConditionDots.png b/src/renderer/experiments/multitasking/stimuli/multiConditionDots.png deleted file mode 100644 index 470080c5..00000000 Binary files a/src/renderer/experiments/multitasking/stimuli/multiConditionDots.png and /dev/null differ diff --git a/src/renderer/experiments/multitasking/stimuli/multiConditionShape.png b/src/renderer/experiments/multitasking/stimuli/multiConditionShape.png deleted file mode 100644 index 25273c16..00000000 Binary files a/src/renderer/experiments/multitasking/stimuli/multiConditionShape.png and /dev/null differ diff --git a/src/renderer/experiments/search/content_protocol.js b/src/renderer/experiments/search/content_protocol.js index 5aa870e1..fc1b9ee9 100644 --- a/src/renderer/experiments/search/content_protocol.js +++ b/src/renderer/experiments/search/content_protocol.js @@ -3,13 +3,6 @@ export const protocol = { protocol: `In the Visual Search Task, participants' goal is to find the right-side up orange T while ignoring upside-down orange T’s or T’s in other colors.`, - condition_first_img: `conditionOrangeT`, - condition_first_title: `Orange T`, - condition_first: `If participants find the orange T, they should press the ‘b’ key. `, - condition_second_img: `conditionNoOrangeT`, - condition_second_title: `No orange T`, - condition_second: `If the orange T is not on the screen, participants - should press the ‘n’ key instead.`, pacing: `Speed counts here: find the orange T as quickly as you can, without guessing.`, links: [], }; diff --git a/src/renderer/experiments/search/index.ts b/src/renderer/experiments/search/index.ts index db751ed8..ab6eb996 100644 --- a/src/renderer/experiments/search/index.ts +++ b/src/renderer/experiments/search/index.ts @@ -5,11 +5,13 @@ import { protocol } from './content_protocol'; import { overview } from './content_overview'; import { searchExperimentObject } from './experiment'; import icon from './icon.png'; +import { prepare } from './prepare'; export default { icon, experimentObject: searchExperimentObject, params, + prepare, text: { protocol, background, 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/content_protocol.js b/src/renderer/experiments/stroop/content_protocol.js index 7b512223..fbe08c7b 100644 --- a/src/renderer/experiments/stroop/content_protocol.js +++ b/src/renderer/experiments/stroop/content_protocol.js @@ -7,11 +7,5 @@ export const protocol = { ignoring the meaning of the word. If the font is red, they should press the key ‘r’; if yellow, press ‘y’; if blue, press ‘b’; and if green, press ‘g’.`, - condition_first_img: `conditionCongruent`, - condition_first_title: `"Green" written in green`, - condition_first: `The color is green, so the correct response is ‘g’.`, - condition_second_img: `conditionIncongruent`, - condition_second_title: `"Green" written in red`, - condition_second: `The color is red, so the correct response is ‘r’.`, links: [], }; diff --git a/src/renderer/experiments/stroop/index.ts b/src/renderer/experiments/stroop/index.ts index 1fd6e91c..d7d24eb9 100644 --- a/src/renderer/experiments/stroop/index.ts +++ b/src/renderer/experiments/stroop/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/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 }), +};