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//` 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: () =>
,
+}));
+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) => (
-
- window.open(link.address, '_blank')}
- >
- {link.name}
-
- {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.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 && (
-
-
-
- )}
-
- );
- }
- }
-
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/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 = ({
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 = {
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 = {
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(initialStep);
const [isPreviewing, setIsPreviewing] = useState(false);
const [hasPreviewed, setHasPreviewed] = useState(false);
+ const [isEEGEnabled, setIsEEGEnabled] = useState(true);
return (
{}}
step={step}
isPreviewing={isPreviewing}
hasPreviewed={hasPreviewed}
@@ -102,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`. */
@@ -124,6 +159,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: () => ,
@@ -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 (
+
+
${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: () => (
@@ -165,14 +281,28 @@ export const DirectCollect: Story = {
render: () =>
,
};
-/** 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: () => (
+
+ ),
+};
+
+/** 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: () => (
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/
/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 (
{responses.map(({ label, key }) => (
-
+
{key}
{label}
@@ -154,7 +173,7 @@ function ResponseDiagram({ responses }: { responses: ResponseMapping[] }) {
Presses
{responses.map(({ key, label, stimulus }) => (
-
+
{'src' in stimulus ? (
@@ -276,7 +297,9 @@ function BackgroundView({
BACKGROUND · 2 MIN READ
- {background.title ?? 'Background'}
+
+ {background.title ?? 'Background'}
+
{background.first_column_statement}
@@ -298,20 +321,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 +358,9 @@ function BackgroundView({
{link.name}
{background.link_meta && (
- {background.link_meta}
+
+ {background.link_meta}
+
)}
))
@@ -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 (
{media.caption}
-
-
- 🎬
-
-
- Illustration placeholder (video slot, 16:9)
-
- {media.alt}
-
+ {[0, 1, 2, 3, 4].map((i) => {
+ const unknown = i === 2;
+ return (
+
+
+
+
+ {unknown ? (
+ <>
+
+
+ ?
+
+ >
+ ) : (
+ <>
+
+
+
+ >
+ )}
+
+ );
+ })}
+
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
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 +481,9 @@ function ProtocolView({
PROTOCOL
{protocol.title}
- {protocol.protocol}
+
+ {protocol.protocol}
+
@@ -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 (
-
+
EXPERIMENT AREA
-
-
- Participant screen would appear here
-
+
+ {preview ? (
+
{preview}
+ ) : (
+
+ Participant screen would appear here
+
+ )}
@@ -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 (
+
+ onChange(event.target.checked)}
+ className="sr-only"
+ />
+
+
+
+ EEG recording
+
+ );
+}
+
/** 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) {
PREVIEW
- {previewIntro.title}
+
+ {previewIntro.title}
+
{previewIntro.body}
@@ -523,42 +657,82 @@ 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 →
+
>
)}
+ {(props.onEEGEnabledChange || props.onCustomize) && (
+
+ {props.onEEGEnabledChange && (
+
+ )}
+ {props.onCustomize && (
+
+ Customize
+
+ )}
+
+ )}
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 = {
+ 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/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/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(
+
+ );
+ const settled = Promise.withResolvers();
+ 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( );
+ 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();
+ });
+});
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;
- 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/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 }),
+};