Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .llms/learnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,3 +386,48 @@ session-taken prompt) and file pickers cannot be driven over CDP, and a covered
window reports `visibilityState: hidden`, which stalls lab.js and CDP
screenshots. Use a subject/session that needs no prompt, and keep the window
uncovered.

## Participant screens: built-ins own `screens.ts`; Custom builds at prepare

Built-in lab.js studies take their instruction / practice→main / end screen
content from `experiments/shared/participantScreens.ts` builders fed by each
experiment's `screens.ts`; `experiments/__tests__/participantScreens.test.ts`
fails if the keys a screen shows ever drift from the keys its trials accept.
Custom studies can't be static (keys and names are the teacher's), so their
screens are built in `before:prepare` hooks from `this.parameters` —
setting `this.options.content` there is still run through lab.js's `${…}`
templating (options proxy parses on set/arm). The teacher's intro stays a
`${this.parameters.intro}` placeholder so its text is never parsed as a
template. The stillness line reads `parameters.isEEGEnabled`, which
`LabjsExperimentWindow` sets from the `isEEGEnabled` runtime prop (it also
lands as an `isEEGEnabled` column in behavior CSVs, like `title`).

Preview mounts lab.js with `fullScreen={false}` (class `container false`), so
the `.bw-participant` fit rule in `app.global.css` must match `.container`,
not only `.container.fullscreen`, or the footer spills over Stop preview.

## lab.js 23: a `skip` template can't see the response that led to it

`flipIterable.js` computes and prepares the *next* stack before
`stopOutgoing` ends and commits the current screen, so
`skip: "${ state.response === 'skipPractice' }"` on the loop after an
instruction screen is parsed with stale state and is always false —
`tardy: true` does not help, because the stack walk calls `prepare()`
directly. Confirmed with real lab.js under jsdom. Skip from the screen that
decides instead: `skipPracticeOnRequest` (`utils/labjs/functions.ts`) is an
instruction-screen `end` hook that sets `options.skip = true` on the next
sibling, which lab.js checks in `run()` right after `stopOutgoing`. Don't
keep a `skip` template string on that block: its parsed value is an own
property on the options proxy and would shadow the raw `true`.

## CDP playtest traps: hidden window and fast key presses

A hub-launched Electron window is often occluded: `document.visibilityState`
is `hidden`, rAF runs at ~10 fps or stops, and lab.js stalls. Enable the main
inspector (`kill -USR1 <electron pid>`, port 9229), then
`webContents.setBackgroundThrottling(false)` / `showInactive()`. Pressing a
response key within a couple of throttled frames of a stimulus appearing can
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')`).
6 changes: 5 additions & 1 deletion TODOS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,13 @@ Deferred and in-flight work. Keep this current — when something ships, delete
- ~~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`.
- QA (WS5): the agent playtest could not drive native dialogs or file pickers. Still to check by hand: a complete Faces/Houses run (keeps `-behavior.csv`/`-raw.csv`, shows `Clean this recording →`), the #270 "session 2" prompt after an ended-early session 1, ending an imported jsPsych study early, and a behavior-only run (complete → `Analyze results →`; ended early → only `-behavior.incomplete.csv`).
- ~~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.

## Next (V1.5: Visual Polish and Juice)
- [ ] **WS5 participant screens (#273)** — integrate the `participantScreens.ts` builders into each built-in `experiment.ts`; pass `isEEGEnabled` to lab.js params.
- [ ] **Participant screens for imported jsPsych/lab.js studies** (deferred 2026-09-25): wrap author timelines with BrainWaves instruction/transition/end screens.
- [ ] **Import stimuli into the workspace?** — today custom experiments load images/sounds straight from wherever the student keeps them (Documents/Downloads) via the `bwfile://` allowlist; moving/renaming that folder silently breaks the study, and a workspace can't be zipped up and shared as a self-contained bundle. Alternative: copy stimuli into `BrainWaves_Workspaces/<title>/stimuli/<condition>/` at selection time (single pre-authorized root, portable study bundles; costs disk duplication + stale copies if the source folder is edited later). **Contingent on user testing** — students may actually prefer managing their own folders in Documents/Downloads, since workspace folders are semi-private territory full of mysterious things like `appState.json`. Decide after watching a class use the current flow.
- [ ] **Hooks / function-component migration (AI-friendly).** Most student screens are still class components + `react-redux` `connect()` / `bindActionCreators` containers (`src/renderer/containers/`). Newer work (`EpochReviewer`, `LiveErpPane`, `RunComponent`, `TopNavComponent`) is hooks. Incremental, screen-by-screen; do not big-bang. Pattern to copy: hooks + `useDispatch`/`useSelector` like `App.tsx`. Not a V1 blocker.
- [ ] **Epoch reviewer Phase 3 — onboarding layer.** Plain-language explanations of epochs + each artifact type, a **guided mode** (step through auto-flagged epochs with "why we flagged this," student confirms/overrides), channel legend tied to head position (Muse 10-20), student-facing tone. Builds on the Phase 0-2 reviewer (PRs #223/#224/#225). **Open question OQ3 (onboarding depth) is still unresolved** — how much curriculum (tooltips only vs. a real walkthrough), guided-mode-as-default? This is product-shaped, not architecture.
Expand Down
8 changes: 6 additions & 2 deletions src/renderer/app.global.css
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,13 @@ p {
}

/* Beats lab.css's viewport-sized fullscreen mount (and Tailwind's
`.container` width) so the screen fits the space it is given. */
.container.fullscreen:has(> .bw-participant) {
`.container` width) so the screen fits the space it is given, including
Preview's non-fullscreen mount. */
.container:has(> .bw-participant) {
box-sizing: border-box;
display: flex;
flex: 1;
flex-direction: column;
width: auto;
max-width: none;
height: 100%;
Expand Down
1 change: 1 addition & 0 deletions src/renderer/components/CollectComponent/RunComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ const Run: React.FC<Props> = ({
title={title}
experimentObject={experimentObject}
params={params}
isEEGEnabled={isEEGEnabled}
eventCallback={eventCallback}
onFinish={onFinish}
onAbort={onAbort}
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/components/ExperimentRuntime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export interface ExperimentProgress {
export interface ExperimentRuntimeProps {
title: string;
fullScreen?: boolean;
/** EEG is being recorded; lab.js screens read it as `parameters.isEEGEnabled` (the stillness line). */
isEEGEnabled?: boolean;
/** Emitted at stimulus onset with the trial's condition label and one clock reading. */
eventCallback: (label: string, time: number) => void;
onFinish: (csv: string) => void;
Expand Down
3 changes: 3 additions & 0 deletions src/renderer/components/LabjsExperimentWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const LabjsExperimentWindow: React.FC<LabjsExperimentWindowProps> = ({
experimentObject,
params,
fullScreen = true,
isEEGEnabled,
eventCallback,
onFinish,
onAbort,
Expand All @@ -44,6 +45,7 @@ export const LabjsExperimentWindow: React.FC<LabjsExperimentWindowProps> = ({
const experimentToRun = lab.core.deserialize(experimentClone, lab);

experimentToRun.parameters.title = title;
experimentToRun.parameters.isEEGEnabled = Boolean(isEEGEnabled);
if (params.stimuli) {
experimentToRun.options.media.images = params.stimuli.reduce<string[]>(
(images, stimulus) => {
Expand Down Expand Up @@ -135,6 +137,7 @@ export const LabjsExperimentWindow: React.FC<LabjsExperimentWindowProps> = ({
}, [
eventCallback,
experimentObject,
isEEGEnabled,
onAbort,
onFinish,
onProgress,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,16 @@ import {
instructionsScreen,
transitionScreen,
} from '../../experiments/shared/participantScreens';
import { instructions as FACES_HOUSES } from '../../experiments/faces_houses/screens';
import { instructions as STROOP } from '../../experiments/stroop/screens';
import { instructions as VISUAL_SEARCH } from '../../experiments/search/screens';
import { instructions as MULTITASKING } from '../../experiments/multitasking/screens';
import { customInstructionsScreen } from '../../utils/labjs/customStimuli';
import {
CUSTOM,
CUSTOM_FOUR_KEYS,
CUSTOM_FOUR_KEYS_PARAMS,
CUSTOM_INTRO,
CUSTOM_LONG_INTRO,
FACES_HOUSES,
MULTITASKING,
STROOP,
VISUAL_SEARCH,
CUSTOM_PARAMS,
} from './fixtures';

interface LabjsScreenProps {
Expand Down Expand Up @@ -110,20 +111,20 @@ export const MultitaskingIntro: Story = {
args: { content: instructionsScreen(MULTITASKING) },
};

/** R05 — Teacher's intro via `parameters.intro`; a condition with no key reads "No key". No pacing line. */
/** R05 — Teacher's intro via `parameters.intro`; the watched-only condition reads its own title ("Bird") next to a "No key" cap. No pacing line. */
export const InstructionsCustom: Story = {
parameters: { experimentType: 'Custom' },
args: {
content: instructionsScreen(CUSTOM),
content: customInstructionsScreen(CUSTOM_PARAMS),
parameters: { isEEGEnabled: true, intro: CUSTOM_INTRO },
},
};

/** R05b — Stress: a textarea-full teacher intro and four keys. `main` scrolls; Space stays put. */
/** R05b — Stress: a textarea-full teacher intro and four keyed conditions (1, 4, 6, 9). `main` scrolls; Space stays put. */
export const InstructionsCustomLongIntro: Story = {
parameters: { experimentType: 'Custom' },
args: {
content: instructionsScreen(CUSTOM_FOUR_KEYS),
content: customInstructionsScreen(CUSTOM_FOUR_KEYS_PARAMS),
parameters: { isEEGEnabled: true, intro: CUSTOM_LONG_INTRO },
},
};
Expand All @@ -139,10 +140,7 @@ export const InstructionsEegOff: Story = {
/** R07 — "Data collection" tag at the top with the RunBar's red dot; the same mapping again. */
export const Transition: Story = {
args: {
content: transitionScreen({
rules: FACES_HOUSES.rules,
pacing: FACES_HOUSES.pacing,
}),
content: transitionScreen(FACES_HOUSES),
},
};

Expand Down
196 changes: 33 additions & 163 deletions src/renderer/components/ParticipantScreens/fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,170 +1,40 @@
import {
keycap,
stimulusExamples,
type InstructionsScreenParams,
} from '../../experiments/shared/participantScreens';

/** A search-task letter with the same `letter` class and inline styles the task's run hook sets. */
const searchLetter = (style: string) =>
`<span class="letter" style="${style}">T</span>`;

/** Keys and pacing from faces_houses/content_protocol.js and params.ts. */
export const FACES_HOUSES: InstructionsScreenParams = {
title: 'Faces and houses',
summary:
'You will see a series of face and house images. Press the right key when an image appears',
rules: [
{
keys: [
{ key: '1', meaning: 'Face' },
{ key: '9', meaning: 'House' },
],
},
],
pacing:
"This isn't a speed test. Take about 1–1.5 seconds per picture and answer carefully.",
canSkipPractice: true,
};

/**
* Keys from the Stroop screen responses in stroop/experiment.ts; pacing from its
* instruction screen. The example uses the task's own ink colors.
*/
export const STROOP: InstructionsScreenParams = {
title: 'Stroop task',
summary:
'You will see color words printed in colored ink. Press the key for the ink color, not the word.',
example: stimulusExamples([
{
stimulus: '<span style="color: red">green</span>',
label: 'Red ink',
detail: `The word says “green”. Press ${keycap('r')}`,
},
{
stimulus: '<span style="color: blue">yellow</span>',
label: 'Blue ink',
detail: `The word says “yellow”. Press ${keycap('b')}`,
},
]),
rules: [
{
keys: [
{ key: 'r', meaning: 'Red' },
{ key: 'g', meaning: 'Green' },
{ key: 'b', meaning: 'Blue' },
{ key: 'y', meaning: 'Yellow' },
],
},
],
pacing: 'Answer quickly, and as accurately as you can.',
canSkipPractice: true,
};

/** Keys and pacing from search/content_protocol.js; example letters as the task draws them. */
export const VISUAL_SEARCH: InstructionsScreenParams = {
title: 'Visual search',
summary:
'Look for the right-side-up orange T. Ignore upside-down orange Ts and blue Ts.',
example: stimulusExamples([
{
stimulus: searchLetter('color: orange'),
label: 'Find this',
detail: 'Orange T, right side up',
},
{
stimulus: searchLetter('color: orange; transform: rotate(-180deg)'),
label: 'Ignore',
detail: 'Upside-down orange T',
},
{
stimulus: searchLetter('color: lightblue'),
label: 'Ignore',
detail: 'Blue T',
},
]),
rules: [
{
keys: [
{ key: 'b', meaning: 'Orange T is there' },
{ key: 'n', meaning: 'No orange T' },
],
},
],
pacing:
'Speed counts here: find the orange T as quickly as you can, without guessing.',
canSkipPractice: true,
};

/**
* Multitasking's intro screen. Space continues to its own instruction
* screens (skip-practice lives there), so no Q hint. Keys from
* multitasking/content_protocol.js and its instruction screens.
*/
export const MULTITASKING: InstructionsScreenParams = {
title: 'Multitasking',
summary:
'You will see a shape with dots inside. Where it appears tells you which rule to follow. The next screens explain each rule with examples.',
rules: [
{
when: 'Shape on top: answer the shape',
keys: [
{ key: 'b', meaning: 'Diamond' },
{ key: 'n', meaning: 'Rectangle' },
],
},
{
when: 'Shape on the bottom: count the dots',
keys: [
{ key: 'b', meaning: '2 dots' },
{ key: 'n', meaning: '3 dots' },
],
},
],
pacing: 'Speed counts here: answer as fast as you can without making errors.',
start: 'see the instructions',
};

/**
* A teacher-built custom experiment. The summary is the teacher's intro,
* filled in by lab.js from `parameters.intro`. Custom has no protocol pacing.
*/
export const CUSTOM: InstructionsScreenParams = {
title: 'Animals',
summary: '${this.parameters.intro}',
rules: [
{
keys: [
{ key: '1', meaning: 'Dog' },
{ key: '9', meaning: 'Cat' },
{ meaning: 'Bird: just watch' },
],
},
],
canSkipPractice: true,
};
import { EVENTS } from '../../constants/constants';
import type { ExperimentParameters } from '../../constants/interfaces';

const slot = (type: EVENTS, title: string, response: string) => ({
type,
title,
dir: `/pics/${title.toLowerCase()}`,
audioDir: '',
response,
});
const emptySlot = (type: EVENTS) => ({
type,
title: '',
dir: '',
audioDir: '',
response: '',
});

/** Two keyed conditions and one watched-only condition. */
export const CUSTOM_PARAMS = {
stimulus1: slot(EVENTS.STIMULUS_1, 'Dog', '1'),
stimulus2: slot(EVENTS.STIMULUS_2, 'Cat', '9'),
stimulus3: slot(EVENTS.STIMULUS_3, 'Bird', ''),
stimulus4: emptySlot(EVENTS.STIMULUS_4),
} as unknown as ExperimentParameters;

export const CUSTOM_INTRO =
"You'll see pictures of animals. Press the key for the animal you see.";

/**
* Stress case: a teacher intro as long as the four-row intro textarea holds,
* and four conditions on the default keys for four (1, 4, 6, 9).
*/
export const CUSTOM_FOUR_KEYS: InstructionsScreenParams = {
title: 'Animals of the rainforest',
summary: '${this.parameters.intro}',
rules: [
{
keys: [
{ key: '1', meaning: 'Monkey' },
{ key: '4', meaning: 'Parrot' },
{ key: '6', meaning: 'Frog' },
{ key: '9', meaning: 'Jaguar' },
],
},
],
canSkipPractice: true,
};
/** Four conditions on the default keys for four (1, 4, 6, 9). */
export const CUSTOM_FOUR_KEYS_PARAMS = {
stimulus1: slot(EVENTS.STIMULUS_1, 'Monkey', '1'),
stimulus2: slot(EVENTS.STIMULUS_2, 'Parrot', '4'),
stimulus3: slot(EVENTS.STIMULUS_3, 'Frog', '6'),
stimulus4: slot(EVENTS.STIMULUS_4, 'Jaguar', '9'),
} as unknown as ExperimentParameters;

/** Stress case: a teacher intro as long as the four-row intro textarea holds. */
export const CUSTOM_LONG_INTRO =
"In this experiment you'll see photos of four rainforest animals, one at a time, in the middle of the screen. Each photo stays up for about a second. As soon as you recognize the animal, press its key. Keep your fingers resting on the four keys the whole time so you don't have to look down. If you're not sure, make your best guess and get ready for the next photo.";
Loading
Loading