From a951071649524ca4f4944dc59749583e49a14c49 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Fri, 25 Sep 2026 12:42:55 -0400 Subject: [PATCH 1/7] feat(experiments): built-in studies show the approved participant screens --- .../ParticipantScreens.stories.tsx | 13 +- .../components/ParticipantScreens/fixtures.ts | 126 +----------------- src/renderer/constants/interfaces.ts | 3 +- .../__tests__/participantScreens.test.ts | 94 +++++++++++++ .../experiments/faces_houses/experiment.ts | 15 ++- .../experiments/faces_houses/params.ts | 4 - .../experiments/faces_houses/screens.ts | 19 +++ .../experiments/multitasking/experiment.ts | 8 +- .../experiments/multitasking/params.ts | 4 - .../experiments/multitasking/screens.ts | 29 ++++ src/renderer/experiments/search/experiment.ts | 15 ++- src/renderer/experiments/search/params.ts | 4 - src/renderer/experiments/search/screens.ts | 43 ++++++ src/renderer/experiments/stroop/experiment.ts | 15 ++- src/renderer/experiments/stroop/params.ts | 3 - src/renderer/experiments/stroop/screens.ts | 36 +++++ 16 files changed, 260 insertions(+), 171 deletions(-) create mode 100644 src/renderer/experiments/__tests__/participantScreens.test.ts create mode 100644 src/renderer/experiments/faces_houses/screens.ts create mode 100644 src/renderer/experiments/multitasking/screens.ts create mode 100644 src/renderer/experiments/search/screens.ts create mode 100644 src/renderer/experiments/stroop/screens.ts diff --git a/src/renderer/components/ParticipantScreens/ParticipantScreens.stories.tsx b/src/renderer/components/ParticipantScreens/ParticipantScreens.stories.tsx index 0f8841bc..5603d126 100644 --- a/src/renderer/components/ParticipantScreens/ParticipantScreens.stories.tsx +++ b/src/renderer/components/ParticipantScreens/ParticipantScreens.stories.tsx @@ -7,15 +7,15 @@ 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 { CUSTOM, CUSTOM_FOUR_KEYS, CUSTOM_INTRO, CUSTOM_LONG_INTRO, - FACES_HOUSES, - MULTITASKING, - STROOP, - VISUAL_SEARCH, } from './fixtures'; interface LabjsScreenProps { @@ -139,10 +139,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), }, }; diff --git a/src/renderer/components/ParticipantScreens/fixtures.ts b/src/renderer/components/ParticipantScreens/fixtures.ts index b0d99e55..15e223af 100644 --- a/src/renderer/components/ParticipantScreens/fixtures.ts +++ b/src/renderer/components/ParticipantScreens/fixtures.ts @@ -1,128 +1,4 @@ -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) => - `T`; - -/** 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: 'green', - label: 'Red ink', - detail: `The word says “green”. Press ${keycap('r')}`, - }, - { - stimulus: 'yellow', - label: 'Blue ink', - detail: `The word says “yellow”. Press ${keycap('b')}`, - }, - ]), - rules: [ - { - keys: [ - { key: 'r', meaning: 'Red' }, - { key: 'g', meaning: 'Green' }, - { key: 'b', meaning: 'Blue' }, - { key: 'y', meaning: 'Yellow' }, - ], - }, - ], - pacing: 'Answer quickly, and as accurately as you can.', - canSkipPractice: true, -}; - -/** 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', -}; +import type { InstructionsScreenParams } from '../../experiments/shared/participantScreens'; /** * A teacher-built custom experiment. The summary is the teacher's intro, diff --git a/src/renderer/constants/interfaces.ts b/src/renderer/constants/interfaces.ts index 0aaa0c94..e34ca255 100644 --- a/src/renderer/constants/interfaces.ts +++ b/src/renderer/constants/interfaces.ts @@ -67,7 +67,8 @@ export type StimulusCondition = { export type ExperimentParameters = { // TODO: consider refactoring to expose lab.js sample.mode description?: ExperimentDescription; - intro: string; + /** Teacher-written intro for custom experiments; built-ins' screens carry their own copy. */ + intro?: string; iti: number; nbPracticeTrials?: number; nbTrials: number; diff --git a/src/renderer/experiments/__tests__/participantScreens.test.ts b/src/renderer/experiments/__tests__/participantScreens.test.ts new file mode 100644 index 00000000..601f24ea --- /dev/null +++ b/src/renderer/experiments/__tests__/participantScreens.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from 'vitest'; +import { facesHousesExperiment } from '../faces_houses/experiment'; +import { params as facesHousesParams } from '../faces_houses/params'; +import { stroopExperiment } from '../stroop/experiment'; +import { searchExperimentObject } from '../search/experiment'; +import { multitaskingExperimentObject } from '../multitasking/experiment'; + +vi.mock('lab.js', () => ({})); + +type Node = { + title?: string; + content?: unknown; + responses?: Record; +}; + +/** Every non-Space, non-skip key any screen in the study responds to. */ +const acceptedKeys = (node: unknown, out = new Set()): Set => { + if (Array.isArray(node)) node.forEach((child) => acceptedKeys(child, out)); + else if (node && typeof node === 'object') { + for (const key of Object.keys((node as Node).responses ?? {})) { + const match = /^key(?:press|down)\((.+)\)$/.exec(key); + if (match && match[1] !== 'Space' && match[1] !== 'q') + out.add(match[1].toLowerCase()); + } + Object.values(node).forEach((child) => acceptedKeys(child, out)); + } + return out; +}; + +/** The `content` of the first screen with this title. */ +const screenContent = (node: unknown, title: string): string | undefined => { + if (Array.isArray(node)) { + for (const child of node) { + const found = screenContent(child, title); + if (found) return found; + } + } else if (node && typeof node === 'object') { + if ( + (node as Node).title === title && + typeof (node as Node).content === 'string' + ) + return (node as Node).content as string; + for (const child of Object.values(node)) { + const found = screenContent(child, title); + if (found) return found; + } + } + return undefined; +}; + +/** Response keycaps on a participant screen (Space and the Q skip hint excluded). */ +const shownKeys = (html: string) => + new Set( + [...html.matchAll(/([^<]+)<\/kbd>/g)] + .map(([, key]) => key.toLowerCase()) + .filter((key) => key !== 'q') + ); + +describe.each([ + [ + 'Faces/Houses', + facesHousesExperiment, + 'Instruction', + 'Main task', + facesHousesParams.stimuli.map(({ response }) => response), + ], + ['Stroop', stroopExperiment, 'Instruction', 'Main task', []], + [ + 'Visual Search', + searchExperimentObject, + 'Instruction', + 'Main task instruction', + [], + ], + ['Multitasking', multitaskingExperimentObject, 'Intro', undefined, []], +])( + '%s participant screens', + (_, study, instructionTitle, transitionTitle, dynamicKeys) => { + const accepted = new Set([...acceptedKeys(study), ...dynamicKeys]); + + it('show exactly the keys the trials accept before practice', () => { + const html = screenContent(study, instructionTitle as string) ?? ''; + expect(shownKeys(html)).toEqual(accepted); + }); + + it.runIf(transitionTitle)( + 'show the same keys again before the recorded task', + () => { + const html = screenContent(study, transitionTitle as string) ?? ''; + expect(shownKeys(html)).toEqual(accepted); + } + ); + } +); diff --git a/src/renderer/experiments/faces_houses/experiment.ts b/src/renderer/experiments/faces_houses/experiment.ts index f48c0d9c..b257ba9d 100644 --- a/src/renderer/experiments/faces_houses/experiment.ts +++ b/src/renderer/experiments/faces_houses/experiment.ts @@ -5,6 +5,12 @@ import { triggerEEGCallback, resetCorrectResponse, } from '../../utils/labjs/functions'; +import { + endScreen, + instructionsScreen, + transitionScreen, +} from '../shared/participantScreens'; +import { instructions } from './screens'; // Define study export const facesHousesExperiment = { @@ -34,8 +40,7 @@ export const facesHousesExperiment = { }, hooks: {}, title: 'Instruction', - content: - '\u003Cheader class="content-vertical-center content-horizontal-center"\u003E\n \u003Ch1\u003EThe face-house task\u003C\u002Fh1\u003E\n\u003C\u002Fheader\u003E\n\n\u003Cmain\u003E\n\n \u003Cp\u003E\n ${this.parameters.intro}\n \u003C\u002Fp\u003E\n \n\u003C\u002Fmain\u003E\n\n\u003Cfooter class="content-vertical-center content-horizontal-center"\u003E\n \n\u003C\u002Ffooter\u003E', + content: instructionsScreen(instructions), }, { type: 'lab.flow.Loop', @@ -155,8 +160,7 @@ export const facesHousesExperiment = { }, hooks: {}, title: 'Main task', - content: - '\u003Cheader class="content-vertical-center content-horizontal-center"\u003E\n \u003Ch1\u003EReady for the real data collection?\u003C\u002Fh1\u003E\n\u003C\u002Fheader\u003E\n\u003Cmain\u003E\n\n \u003Cp\u003E\n Press the the space bar to start the main task.\n \u003C\u002Fp\u003E\n\n\u003C\u002Fmain\u003E\n\u003Cfooter class="content-vertical-center content-horizontal-center"\u003E\n \n\u003C\u002Ffooter\u003E', + content: transitionScreen(instructions), }, { type: 'lab.flow.Loop', @@ -243,8 +247,7 @@ export const facesHousesExperiment = { }, hooks: {}, title: 'End', - content: - '\u003Cheader class="content-vertical-center content-horizontal-center"\u003E\n \n\u003C\u002Fheader\u003E\n\n\u003Cmain\u003E\n \u003Ch1\u003E\n Thank you!\n \u003C\u002Fh1\u003E\n \u003Ch1\u003E\n Press the space bar to finish the task.\n \u003C\u002Fh1\u003E\n\u003C\u002Fmain\u003E\n\n', + content: endScreen(), }, ], }, diff --git a/src/renderer/experiments/faces_houses/params.ts b/src/renderer/experiments/faces_houses/params.ts index e35fd466..72d1a6d8 100644 --- a/src/renderer/experiments/faces_houses/params.ts +++ b/src/renderer/experiments/faces_houses/params.ts @@ -40,10 +40,6 @@ export const params = { presentationTime: 1000, selfPaced: true, sampleType: 'with-replacement', - intro: `You will view a series of faces and houses. Press 1 when a face appears - and 9 for a house. Press the the space bar on your keyboard to start doing the - practice trials. If you want to skip the practice trials and go directly to - the task, press the "q" button on your keyboard.`, taskHelp: `Press 1 for a face and 9 for a house`, showProgressBar: false, description: { diff --git a/src/renderer/experiments/faces_houses/screens.ts b/src/renderer/experiments/faces_houses/screens.ts new file mode 100644 index 00000000..907b6a94 --- /dev/null +++ b/src/renderer/experiments/faces_houses/screens.ts @@ -0,0 +1,19 @@ +import type { InstructionsScreenParams } from '../shared/participantScreens'; + +/** What participants are told before practice and reminded of before the recorded trials. */ +export const instructions: InstructionsScreenParams = { + title: 'Faces and houses', + summary: + 'You will see a series of face and house images. Press the right key when an image appears', + rules: [ + { + keys: [ + { key: '1', meaning: 'Face' }, + { key: '9', meaning: 'House' }, + ], + }, + ], + pacing: + "This isn't a speed test. Take about 1–1.5 seconds per picture and answer carefully.", + canSkipPractice: true, +}; diff --git a/src/renderer/experiments/multitasking/experiment.ts b/src/renderer/experiments/multitasking/experiment.ts index 0b1c6e1d..4af72beb 100644 --- a/src/renderer/experiments/multitasking/experiment.ts +++ b/src/renderer/experiments/multitasking/experiment.ts @@ -7,6 +7,8 @@ import { initTaskScreen, triggerEEGCallback, } from './utils'; +import { endScreen, instructionsScreen } from '../shared/participantScreens'; +import { instructions } from './screens'; const assetsDirectory = path.join( RESOURCE_PATH, @@ -45,8 +47,7 @@ export const multitaskingExperimentObject = { }, hooks: {}, title: 'Intro', - content: - '\u003Cheader class="content-vertical-center content-horizontal-center"\u003E\n \u003Ch1\u003EThe multi-tasking test\u003C\u002Fh1\u003E\n\u003C\u002Fheader\u003E\n\n\u003Cmain\u003E\n\n \u003Cp\u003E\n ${this.parameters.intro}\n \u003C\u002Fp\u003E\n \n\u003C\u002Fmain\u003E\n\n\u003Cfooter class="content-vertical-center content-horizontal-center"\u003E\n \n\u003C\u002Ffooter\u003E', + content: instructionsScreen(instructions), }, { type: 'lab.html.Screen', @@ -2508,8 +2509,7 @@ export const multitaskingExperimentObject = { }, hooks: {}, title: 'End', - content: - '\u003Cheader class="content-vertical-center content-horizontal-center"\u003E\n \n\u003C\u002Fheader\u003E\n\n\u003Cmain\u003E\n \u003Ch1\u003E\n Thank you!\n \u003C\u002Fh1\u003E\n \u003Ch1\u003E\n Press the space bar to finish the task.\n \u003C\u002Fh1\u003E\n\u003C\u002Fmain\u003E\n\n', + content: endScreen(), }, ], }, diff --git a/src/renderer/experiments/multitasking/params.ts b/src/renderer/experiments/multitasking/params.ts index 66300072..91f8b921 100644 --- a/src/renderer/experiments/multitasking/params.ts +++ b/src/renderer/experiments/multitasking/params.ts @@ -6,10 +6,6 @@ export const params = { nbTrials: 150, iti: 1000, sampleType: 'with-replacement', - intro: `In this task you will learn about multitasking difficulties using a - task mixing and switching paradigm. You will go through several instruction - and training blocks and then several blocks of real data collection will - follow. Press the space bar to continue with the instructions.`, showProgressBar: false, stimulus1: { title: 'No switching', diff --git a/src/renderer/experiments/multitasking/screens.ts b/src/renderer/experiments/multitasking/screens.ts new file mode 100644 index 00000000..6733279d --- /dev/null +++ b/src/renderer/experiments/multitasking/screens.ts @@ -0,0 +1,29 @@ +import type { InstructionsScreenParams } from '../shared/participantScreens'; + +/** + * The intro screen. Space continues to Multitasking's own instruction screens + * (skip-practice lives there), so no Q hint. + */ +export const instructions: InstructionsScreenParams = { + title: 'Multitasking', + summary: + 'You will see a shape with dots inside. Where it appears tells you which rule to follow. The next screens explain each rule with examples.', + rules: [ + { + when: 'Shape on top: answer the shape', + keys: [ + { key: 'b', meaning: 'Diamond' }, + { key: 'n', meaning: 'Rectangle' }, + ], + }, + { + when: 'Shape on the bottom: count the dots', + keys: [ + { key: 'b', meaning: '2 dots' }, + { key: 'n', meaning: '3 dots' }, + ], + }, + ], + pacing: 'Speed counts here: answer as fast as you can without making errors.', + start: 'see the instructions', +}; diff --git a/src/renderer/experiments/search/experiment.ts b/src/renderer/experiments/search/experiment.ts index 2d92676d..cf5f2827 100644 --- a/src/renderer/experiments/search/experiment.ts +++ b/src/renderer/experiments/search/experiment.ts @@ -7,6 +7,12 @@ import { initGrid, initResponses, } from './utils'; +import { + endScreen, + instructionsScreen, + transitionScreen, +} from '../shared/participantScreens'; +import { instructions } from './screens'; // Define study export const searchExperimentObject = { @@ -36,8 +42,7 @@ export const searchExperimentObject = { }, hooks: {}, title: 'Instruction', - content: - '\u003Cstyle\u003E\n .letter{\n font-size: 90px;\n font-weight: bold;\n }\n\u003C\u002Fstyle\u003E\n\n\u003Cheader\u003E\n \u003Ch1\u003EVisual search task\u003C\u002Fh1\u003E\n\u003C\u002Fheader\u003E\n\n\u003Cmain\u003E\n \n \u003Cp\u003E\n ${this.parameters.intro}\n \u003C\u002Fp\u003E\n \n \u003Cp\u003E\n Again, all you need to do is to find an \u003Cb\u003Eorange T\u003C\u002Fb\u003E. If you see the \u003Cb\u003Eorange T\u003C\u002Fb\u003E, press \u003Ckbd\u003Eb\u003C\u002Fkbd\u003E. Ignore the upside-down orange T, as well as blue Ts! IF THERE IS NO ORANGE T, press \u003Ckbd\u003En\u003C\u002Fkbd\u003E.\n It is very important to respond \u003Cb\u003EAS FAST AS YOU CAN\u003C\u002Fb\u003E.\n \u003C\u002Fp\u003E\n\n \u003Cdiv style="display:grid; grid-template-columns:1fr 1fr;"\u003E\n \u003Cdiv\u003E\n \u003Cp\u003E\n Find \n \u003C\u002Fp\u003E\n \u003Cbr\u003E\n \u003Cdiv class="letter" style="color:orange; height: 100px;"\u003E\n T\n \u003C\u002Fdiv\u003E\n \u003C\u002Fdiv\u003E\n \u003Cdiv\u003E\n \u003Cp\u003E\n But do not respond to any of these distractors:\n \u003C\u002Fp\u003E\n \u003Cbr\u003E\n \u003Cdiv style="display:grid; grid-template-columns: 100px 50px; justify-content: center; "\u003E\n \u003Cdiv class="letter" style="color:lightblue;"\u003E\n T\n \u003C\u002Fdiv\u003E\n \u003Cdiv class="letter" style="color:orange; transform: rotate(-180deg);"\u003E\n T\n \u003C\u002Fdiv\u003E\n \u003C\u002Fdiv\u003E\n \u003C\u002Fdiv\u003E\n \u003C\u002Fdiv\u003E\n\n \u003Cp\u003E\n Press the space bar on your keyboard to start doing the practice trials.\n If you want to skip the practice trials and go directly to the task, press the "q" button on your keyboard.\n \u003C\u002Fp\u003E\n\u003C\u002Fmain\u003E', + content: instructionsScreen(instructions), }, { type: 'lab.flow.Loop', @@ -213,8 +218,7 @@ export const searchExperimentObject = { }, hooks: {}, title: 'Main task instruction', - content: - '\u003Cheader class="content-vertical-center content-horizontal-center"\u003E\n \u003Ch1\u003EReady for the real data collection?\u003C\u002Fh1\u003E\n\u003C\u002Fheader\u003E\n\u003Cmain\u003E\n\n \u003Cp\u003E\n Press the the space bar to start the main task.\n \u003C\u002Fp\u003E\n\n\u003C\u002Fmain\u003E\n\u003Cfooter class="content-vertical-center content-horizontal-center"\u003E\n \n\u003C\u002Ffooter\u003E\n', + content: transitionScreen(instructions), }, { type: 'lab.flow.Loop', @@ -388,8 +392,7 @@ export const searchExperimentObject = { }, hooks: {}, title: 'End', - content: - '\u003Cheader class="content-vertical-center content-horizontal-center"\u003E\n \n\u003C\u002Fheader\u003E\n\n\u003Cmain\u003E\n \u003Ch1\u003E\n Thank you!\n \u003C\u002Fh1\u003E\n \u003Ch1\u003E\n Press the space bar to finish the task.\n \u003C\u002Fh1\u003E\n\u003C\u002Fmain\u003E\n\n', + content: endScreen(), }, ], }, diff --git a/src/renderer/experiments/search/params.ts b/src/renderer/experiments/search/params.ts index 8a9f93c7..2e5dd856 100644 --- a/src/renderer/experiments/search/params.ts +++ b/src/renderer/experiments/search/params.ts @@ -6,10 +6,6 @@ export const params = { nbTrials: 150, iti: 500, sampleType: 'with-replacement', - intro: `You know how difficult it is to find your keys in a messy room. We - want to know how good you are in quickly finding your keys. Instead of keys, - we just want to know how quickly you can find an orange T amongst blue Ts - and upside-down orange Ts. Sounds easy! But it is not at all that easy!`, showProgressBar: false, stimuli: [ { diff --git a/src/renderer/experiments/search/screens.ts b/src/renderer/experiments/search/screens.ts new file mode 100644 index 00000000..8b46e50c --- /dev/null +++ b/src/renderer/experiments/search/screens.ts @@ -0,0 +1,43 @@ +import { + stimulusExamples, + type InstructionsScreenParams, +} from '../shared/participantScreens'; + +/** A search-task letter with the same `letter` class and inline styles the task's run hook sets. */ +const searchLetter = (style: string) => + `T`; + +/** What participants are told before practice and reminded of before the recorded trials. */ +export const instructions: InstructionsScreenParams = { + title: 'Visual search', + summary: + 'Look for the right-side-up orange T. Ignore upside-down orange Ts and blue Ts.', + example: stimulusExamples([ + { + stimulus: searchLetter('color: orange'), + label: 'Find this', + detail: 'Orange T, right side up', + }, + { + stimulus: searchLetter('color: orange; transform: rotate(-180deg)'), + label: 'Ignore', + detail: 'Upside-down orange T', + }, + { + stimulus: searchLetter('color: lightblue'), + label: 'Ignore', + detail: 'Blue T', + }, + ]), + rules: [ + { + keys: [ + { key: 'b', meaning: 'Orange T is there' }, + { key: 'n', meaning: 'No orange T' }, + ], + }, + ], + pacing: + 'Speed counts here: find the orange T as quickly as you can, without guessing.', + canSkipPractice: true, +}; diff --git a/src/renderer/experiments/stroop/experiment.ts b/src/renderer/experiments/stroop/experiment.ts index ce510ad1..c3423690 100644 --- a/src/renderer/experiments/stroop/experiment.ts +++ b/src/renderer/experiments/stroop/experiment.ts @@ -3,6 +3,12 @@ import { initStroopTrial, emitStroopCondition, } from '../../utils/labjs/functions'; +import { + endScreen, + instructionsScreen, + transitionScreen, +} from '../shared/participantScreens'; +import { instructions } from './screens'; export const stroopExperiment = { hooks: {}, @@ -30,8 +36,7 @@ export const stroopExperiment = { 'keypress(q)': 'skipPractice', }, title: 'Instruction', - content: - '\u003Cheader class="content-vertical-center content-horizontal-center"\u003E\n \u003Ch1\u003EStroop Task\u003C\u002Fh1\u003E\n\u003C\u002Fheader\u003E\n\n\u003Cmain\u003E\n \u003Cp\u003E\n Welcome to the \u003Cstrong\u003EStroop experiment\u003C\u002Fstrong\u003E!\n \u003C\u002Fp\u003E\n \u003Cp\u003E\n ${this.parameters.intro}\n \u003C\u002Fp\u003E\n \u003Cp\u003E\n To indicate the color of the word, please use the keys \u003Cstrong\u003Er\u003C\u002Fstrong\u003E, \u003Cstrong\u003Eg\u003C\u002Fstrong\u003E, \u003Cstrong\u003Eb\u003C\u002Fstrong\u003E and \u003Cstrong\u003Ey\u003C\u002Fstrong\u003E for \u003Cspan style="color: red;"\u003Ered\u003C\u002Fspan\u003E, \u003Cspan style="color: green;"\u003Egreen\u003C\u002Fspan\u003E, \u003Cspan style="color: blue;"\u003Eblue\u003C\u002Fspan\u003E and \u003Cspan style="color: #c5ad0b;"\u003Eyellow\u003C\u002Fspan\u003E, respectively.\n \u003Cbr\u003E\n Please answer quickly, and as accurately as you can.\n \u003C\u002Fp\u003E\n \u003Cp\u003E\n Press the the space bar on your keyboard to start doing the practice trials.\n \u003C\u002Fp\u003E\n \u003Cp\u003E\n If you want to skip the practice trials and go directly to the task, press the "q" button on your keyboard.\n \u003C\u002Fp\u003E\n\u003C\u002Fmain\u003E\n\n\u003Cfooter class="content-vertical-center content-horizontal-center"\u003E\n \n\u003C\u002Ffooter\u003E\n\n\n', + content: instructionsScreen(instructions), parameters: {}, files: {}, }, @@ -311,8 +316,7 @@ export const stroopExperiment = { 'keypress(Space)': 'continue', }, title: 'Main task', - content: - '\u003Cheader class="content-vertical-center content-horizontal-center"\u003E\n \u003Ch1\u003EReady for the real data collection?\u003C\u002Fh1\u003E\n\u003C\u002Fheader\u003E\n\u003Cmain\u003E\n\n \u003Cp\u003E\n Press the the space bar to start the main task.\n \u003C\u002Fp\u003E\n\n\u003C\u002Fmain\u003E\n\u003Cfooter class="content-vertical-center content-horizontal-center"\u003E\n \n\u003C\u002Ffooter\u003E\n\n\n', + content: transitionScreen(instructions), parameters: {}, files: {}, }, @@ -697,8 +701,7 @@ export const stroopExperiment = { 'keypress(Space)': 'end', }, title: 'Thanks', - content: - '\u003Cheader class="content-vertical-center content-horizontal-center"\u003E\n \n\u003C\u002Fheader\u003E\n\n\u003Cmain\u003E\n \u003Ch1\u003E\n Thank you!\n \u003C\u002Fh1\u003E\n \u003Ch1\u003E\n Press the space bar to finish the task.\n \u003C\u002Fh1\u003E\n\u003C\u002Fmain\u003E\n\n', + content: endScreen(), parameters: {}, files: {}, }, diff --git a/src/renderer/experiments/stroop/params.ts b/src/renderer/experiments/stroop/params.ts index dd310b3e..a05aca8e 100644 --- a/src/renderer/experiments/stroop/params.ts +++ b/src/renderer/experiments/stroop/params.ts @@ -12,9 +12,6 @@ export const params = { nbTrials: 150, iti: 500, sampleType: 'with-replacement', - intro: `In this experiment, your task will be to identify the color of the - word shown on the screen. The word itself is immaterial - you can safely - ignore it.`, showProgressBar: false, stimuli: [ { diff --git a/src/renderer/experiments/stroop/screens.ts b/src/renderer/experiments/stroop/screens.ts new file mode 100644 index 00000000..442b10d8 --- /dev/null +++ b/src/renderer/experiments/stroop/screens.ts @@ -0,0 +1,36 @@ +import { + keycap, + stimulusExamples, + type InstructionsScreenParams, +} from '../shared/participantScreens'; + +/** What participants are told before practice and reminded of before the recorded trials. */ +export const instructions: InstructionsScreenParams = { + title: 'Stroop task', + summary: + 'You will see color words printed in colored ink. Press the key for the ink color, not the word.', + example: stimulusExamples([ + { + stimulus: 'green', + label: 'Red ink', + detail: `The word says “green”. Press ${keycap('r')}`, + }, + { + stimulus: 'yellow', + label: 'Blue ink', + detail: `The word says “yellow”. Press ${keycap('b')}`, + }, + ]), + rules: [ + { + keys: [ + { key: 'r', meaning: 'Red' }, + { key: 'g', meaning: 'Green' }, + { key: 'b', meaning: 'Blue' }, + { key: 'y', meaning: 'Yellow' }, + ], + }, + ], + pacing: 'Answer quickly, and as accurately as you can.', + canSkipPractice: true, +}; From 9bf6843f48909168cb4bb6d5c3b8f81b744d5162 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Fri, 25 Sep 2026 12:43:57 -0400 Subject: [PATCH 2/7] feat(runtime): lab.js screens know whether EEG is recording --- .../CollectComponent/RunComponent.tsx | 1 + src/renderer/components/ExperimentRuntime.tsx | 2 + .../components/LabjsExperimentWindow.tsx | 3 ++ .../__tests__/LabjsExperimentWindow.test.tsx | 38 +++++++++++++++++++ 4 files changed, 44 insertions(+) diff --git a/src/renderer/components/CollectComponent/RunComponent.tsx b/src/renderer/components/CollectComponent/RunComponent.tsx index 981f3dc9..237cfad3 100644 --- a/src/renderer/components/CollectComponent/RunComponent.tsx +++ b/src/renderer/components/CollectComponent/RunComponent.tsx @@ -281,6 +281,7 @@ const Run: React.FC = ({ title={title} experimentObject={experimentObject} params={params} + isEEGEnabled={isEEGEnabled} eventCallback={eventCallback} onFinish={onFinish} onAbort={onAbort} diff --git a/src/renderer/components/ExperimentRuntime.tsx b/src/renderer/components/ExperimentRuntime.tsx index 04999bd6..7603c23d 100644 --- a/src/renderer/components/ExperimentRuntime.tsx +++ b/src/renderer/components/ExperimentRuntime.tsx @@ -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; diff --git a/src/renderer/components/LabjsExperimentWindow.tsx b/src/renderer/components/LabjsExperimentWindow.tsx index eda77763..29bfd899 100644 --- a/src/renderer/components/LabjsExperimentWindow.tsx +++ b/src/renderer/components/LabjsExperimentWindow.tsx @@ -26,6 +26,7 @@ export const LabjsExperimentWindow: React.FC = ({ experimentObject, params, fullScreen = true, + isEEGEnabled, eventCallback, onFinish, onAbort, @@ -44,6 +45,7 @@ export const LabjsExperimentWindow: React.FC = ({ 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( (images, stimulus) => { @@ -135,6 +137,7 @@ export const LabjsExperimentWindow: React.FC = ({ }, [ eventCallback, experimentObject, + isEEGEnabled, onAbort, onFinish, onProgress, diff --git a/src/renderer/components/__tests__/LabjsExperimentWindow.test.tsx b/src/renderer/components/__tests__/LabjsExperimentWindow.test.tsx index 31aed706..2473ad47 100644 --- a/src/renderer/components/__tests__/LabjsExperimentWindow.test.tsx +++ b/src/renderer/components/__tests__/LabjsExperimentWindow.test.tsx @@ -2,6 +2,10 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { LabjsExperimentWindow } from '../LabjsExperimentWindow'; +import { + instructionsScreen, + STILLNESS_LINE, +} from '../../experiments/shared/participantScreens'; // lab.js needs browser APIs jsdom lacks: its canvas module subclasses // DOMMatrixReadOnly at import, every controller opens an AudioContext, and @@ -27,6 +31,20 @@ const study = { ], }; +const instructionsStudy = { + type: 'lab.flow.Sequence', + content: [ + { + type: 'lab.html.Screen', + content: instructionsScreen({ + title: 'Faces and houses', + summary: 'summary', + rules: [{ keys: [{ key: '1', meaning: 'Face' }] }], + }), + }, + ], +}; + describe('LabjsExperimentWindow', () => { it('unmounting mid-study reports the trials so far at once, and never onFinish', async () => { const onFinish = vi.fn(); @@ -49,4 +67,24 @@ describe('LabjsExperimentWindow', () => { expect(onAbort.mock.calls[0][0]).toContain('html.Screen'); expect(onFinish).not.toHaveBeenCalled(); }); + + it.each([ + [true, true], + [false, false], + ])('EEG %s → stillness line shown: %s', async (isEEGEnabled, shown) => { + const { unmount } = render( + + ); + await screen.findByText('Faces and houses', {}, { timeout: 3000 }); + + expect(Boolean(screen.queryByText(STILLNESS_LINE))).toBe(shown); + unmount(); + }); }); From 379c5038f7bc0b4f2b9d4e8a270314eb3a843e0d Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Fri, 25 Sep 2026 12:45:21 -0400 Subject: [PATCH 3/7] feat(custom): participant screens built from the teacher's conditions --- .../ParticipantScreens.stories.tsx | 13 ++-- .../components/ParticipantScreens/fixtures.ts | 70 ++++++++---------- src/renderer/experiments/custom/experiment.ts | 74 ++++++++----------- .../labjs/__tests__/customScreens.test.ts | 55 ++++++++++++++ src/renderer/utils/labjs/customStimuli.ts | 42 +++++++++++ 5 files changed, 167 insertions(+), 87 deletions(-) create mode 100644 src/renderer/utils/labjs/__tests__/customScreens.test.ts diff --git a/src/renderer/components/ParticipantScreens/ParticipantScreens.stories.tsx b/src/renderer/components/ParticipantScreens/ParticipantScreens.stories.tsx index 5603d126..73050439 100644 --- a/src/renderer/components/ParticipantScreens/ParticipantScreens.stories.tsx +++ b/src/renderer/components/ParticipantScreens/ParticipantScreens.stories.tsx @@ -11,11 +11,12 @@ import { instructions as FACES_HOUSES } from '../../experiments/faces_houses/scr 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, + CUSTOM_PARAMS, } from './fixtures'; interface LabjsScreenProps { @@ -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 }, }, }; diff --git a/src/renderer/components/ParticipantScreens/fixtures.ts b/src/renderer/components/ParticipantScreens/fixtures.ts index 15e223af..96b3ae8a 100644 --- a/src/renderer/components/ParticipantScreens/fixtures.ts +++ b/src/renderer/components/ParticipantScreens/fixtures.ts @@ -1,46 +1,40 @@ -import type { InstructionsScreenParams } from '../../experiments/shared/participantScreens'; +import { EVENTS } from '../../constants/constants'; +import type { ExperimentParameters } from '../../constants/interfaces'; -/** - * 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, -}; +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."; diff --git a/src/renderer/experiments/custom/experiment.ts b/src/renderer/experiments/custom/experiment.ts index e32d4513..4c0cb3ec 100644 --- a/src/renderer/experiments/custom/experiment.ts +++ b/src/renderer/experiments/custom/experiment.ts @@ -5,6 +5,12 @@ import { triggerEEGCallback, resetCorrectResponse, } from '../../utils/labjs/functions'; +import { + customInstructionsScreen, + customTransitionScreen, +} from '../../utils/labjs/customStimuli'; +import { endScreen } from '../shared/participantScreens'; +import type { ExperimentParameters } from '../../constants/interfaces'; // The stimulus screen renders whatever the trial carries: an image // (`filepath`), a sound (`audiopath`), or both. Sounds start playing when the @@ -75,6 +81,26 @@ const stimulusScreen = { content: stimulusScreenContent, }; +/** Builds the instruction screen from this workspace's conditions when lab.js prepares it. */ +function prepareInstructions(this: { + parameters: unknown; + options: { content?: string }; +}) { + this.options.content = customInstructionsScreen( + this.parameters as ExperimentParameters + ); +} + +/** Builds the practice → recorded-task screen from this workspace's conditions. */ +function prepareTransition(this: { + parameters: unknown; + options: { content?: string }; +}) { + this.options.content = customTransitionScreen( + this.parameters as ExperimentParameters + ); +} + export const customExperiment = { title: 'root', type: 'lab.flow.Sequence', @@ -100,23 +126,9 @@ export const customExperiment = { 'keypress(Space)': 'continue', 'keypress(q)': 'skipPractice', }, - hooks: {}, + hooks: { 'before:prepare': prepareInstructions }, title: 'Instruction', - content: `
-

Welcome to your experiment

-
- -
- -

- \${this.parameters.intro} -

- -
- -
-

Press the space bar to begin with a few practice trials.

-
`, + content: '', }, { type: 'lab.flow.Loop', @@ -187,21 +199,9 @@ export const customExperiment = { responses: { 'keypress(Space)': 'continue', }, - hooks: {}, + hooks: { 'before:prepare': prepareTransition }, title: 'Main task', - content: `
-

Ready for the real data collection?

-
-
- -

- Press the space bar to start the main task. -

- -
-
- -
`, + content: '', }, { type: 'lab.flow.Loop', @@ -237,19 +237,7 @@ export const customExperiment = { }, hooks: {}, title: 'End', - content: `
- -
- -
-

- Thank you! -

-

- Press the space bar to finish the task. -

-
-`, + content: endScreen(), }, ], }, diff --git a/src/renderer/utils/labjs/__tests__/customScreens.test.ts b/src/renderer/utils/labjs/__tests__/customScreens.test.ts new file mode 100644 index 00000000..4dc106b9 --- /dev/null +++ b/src/renderer/utils/labjs/__tests__/customScreens.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { EVENTS } from '../../../constants/constants'; +import type { ExperimentParameters } from '../../../constants/interfaces'; +import { + customInstructionsScreen, + customResponseRules, + customTransitionScreen, +} from '../customStimuli'; + +const slot = (type: EVENTS, title: string, dir: string, response: string) => ({ + type, + title, + dir, + audioDir: '', + response, +}); + +const params = { + intro: 'Look at ${danger} each animal', + stimulus1: slot(EVENTS.STIMULUS_1, 'Dog', '/pics/dogs', '1'), + stimulus2: slot(EVENTS.STIMULUS_2, 'Condition 2', '/pics/cats', '9'), + stimulus3: slot(EVENTS.STIMULUS_3, 'Bird', '/pics/birds', ''), + stimulus4: slot(EVENTS.STIMULUS_4, '', '', ''), +} as unknown as ExperimentParameters; + +describe('custom participant screens', () => { + it('list every condition with a folder, its key, and no empty slots', () => { + expect(customResponseRules(params)).toEqual([ + { + keys: [ + { key: '1', meaning: 'Dog' }, + { key: '9', meaning: 'cats' }, + { key: undefined, meaning: 'Bird' }, + ], + }, + ]); + }); + + it('show the configured keys before practice and again before the recorded task', () => { + for (const html of [ + customInstructionsScreen(params), + customTransitionScreen(params), + ]) { + expect(html).toContain('1'); + expect(html).toContain('9'); + expect(html).toContain('No key'); + } + }); + + it("keeps the teacher's intro out of the template source", () => { + const html = customInstructionsScreen(params); + expect(html).toContain('${this.parameters.intro}'); + expect(html).not.toContain('${danger}'); + }); +}); diff --git a/src/renderer/utils/labjs/customStimuli.ts b/src/renderer/utils/labjs/customStimuli.ts index a8047d95..4e7d66d3 100644 --- a/src/renderer/utils/labjs/customStimuli.ts +++ b/src/renderer/utils/labjs/customStimuli.ts @@ -4,6 +4,11 @@ import { Stimulus, StimulusCondition, } from '../../constants/interfaces'; +import { + instructionsScreen, + transitionScreen, + type ResponseRule, +} from '../../experiments/shared/participantScreens'; export type ConditionSlotName = | 'stimulus1' @@ -66,6 +71,43 @@ export const DEFAULT_RESPONSE_KEYS: Record<1 | 2 | 3 | 4, readonly string[]> = { const isActiveSlot = (slot: ConditionSlot | undefined) => Boolean(slot && (slot.dir || slot.audioDir)); +/** + * What a participant is told to press: each condition with a stimulus folder, + * named as it is recorded (`conditionTitle`), with its key or none. + */ +export function customResponseRules( + params: ExperimentParameters +): ResponseRule[] { + return [ + { + keys: CONDITION_SLOTS.flatMap(({ name }) => { + const slot = params[name]; + return slot && isActiveSlot(slot) + ? [{ key: slot.response || undefined, meaning: conditionTitle(slot) }] + : []; + }), + }, + ]; +} + +/** + * The custom instruction screen. The teacher's intro stays a lab.js + * placeholder so its text is interpolated once, never parsed as a template. + */ +export function customInstructionsScreen(params: ExperimentParameters): string { + return instructionsScreen({ + title: 'Welcome to your experiment', + summary: '${this.parameters.intro}', + rules: customResponseRules(params), + canSkipPractice: true, + }); +} + +/** The custom practice → recorded-task screen, with the same keys again. */ +export function customTransitionScreen(params: ExperimentParameters): string { + return transitionScreen({ rules: customResponseRules(params) }); +} + /** * Give every active condition a farthest-spaced number key: 1 → "1", 2 → "1/9", * 3 → "1/5/9", 4 → "1/4/6/9". Adding a condition re-spaces the whole set, but From 407b7721af2cd1dea1929f1575d32b88686ffe5b Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Fri, 25 Sep 2026 13:36:51 -0400 Subject: [PATCH 4/7] fix(participant-screens): fit screens in Preview's non-fullscreen mount; docs: WS5b learnings and TODOS --- .llms/learnings.md | 41 +++++++++++++++++++++++++++++++++++++ TODOS.md | 4 +++- src/renderer/app.global.css | 8 ++++++-- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/.llms/learnings.md b/.llms/learnings.md index c2b66b8c..a8e12c7f 100644 --- a/.llms/learnings.md +++ b/.llms/learnings.md @@ -386,3 +386,44 @@ 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. Q-to-skip-practice is +broken in every study that advertises it (see TODOS). + +## 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 `, 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')`). diff --git a/TODOS.md b/TODOS.md index 1cee62f7..53f8f432 100644 --- a/TODOS.md +++ b/TODOS.md @@ -30,9 +30,11 @@ 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).** Faces/Houses, Stroop, Visual Search and Custom say "Press Q to skip practice"; Q ends the instruction screen but practice still runs. Stroop/Search's `skip: "${ state.response === 'skipPractice' }"` is parsed when lab.js prepares the loop, which happens before the instruction screen commits its response (`flipIterable.js` prepares the next stack before `stopOutgoing`), so it is always false; Faces/Houses and Custom have no `skip` at all. Pre-existing; found in the WS5b playtest. ## 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//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. diff --git a/src/renderer/app.global.css b/src/renderer/app.global.css index 3c00e667..dd5829ff 100644 --- a/src/renderer/app.global.css +++ b/src/renderer/app.global.css @@ -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%; From 62e77875d335871ced4b35cb84a10ac5f2323a3a Mon Sep 17 00:00:00 2001 From: jdpigeon <morrisondano@gmail.com> Date: Fri, 25 Sep 2026 14:19:23 -0400 Subject: [PATCH 5/7] fix(labjs): Q on the instruction screen skips practice --- .../__tests__/LabjsExperimentWindow.test.tsx | 60 ++++++++++++++++++- .../__tests__/participantScreens.test.ts | 37 +++++++++--- src/renderer/experiments/custom/experiment.ts | 6 +- .../experiments/faces_houses/experiment.ts | 3 +- src/renderer/experiments/search/experiment.ts | 4 +- src/renderer/experiments/stroop/experiment.ts | 4 +- src/renderer/utils/labjs/functions.ts | 14 +++++ 7 files changed, 114 insertions(+), 14 deletions(-) diff --git a/src/renderer/components/__tests__/LabjsExperimentWindow.test.tsx b/src/renderer/components/__tests__/LabjsExperimentWindow.test.tsx index 2473ad47..566a33dd 100644 --- a/src/renderer/components/__tests__/LabjsExperimentWindow.test.tsx +++ b/src/renderer/components/__tests__/LabjsExperimentWindow.test.tsx @@ -1,11 +1,12 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { LabjsExperimentWindow } from '../LabjsExperimentWindow'; import { instructionsScreen, STILLNESS_LINE, } from '../../experiments/shared/participantScreens'; +import { skipPracticeOnRequest } from '../../utils/labjs/functions'; // lab.js needs browser APIs jsdom lacks: its canvas module subclasses // DOMMatrixReadOnly at import, every controller opens an AudioContext, and @@ -87,4 +88,61 @@ describe('LabjsExperimentWindow', () => { expect(Boolean(screen.queryByText(STILLNESS_LINE))).toBe(shown); unmount(); }); + + it.each([ + ['q', 'skips', null], + [' ', 'runs', 'practice trial'], + ])( + 'pressing %j on the instruction screen %s practice', + async (key, _, practiceText) => { + const skipStudy = { + type: 'lab.flow.Sequence', + content: [ + { + type: 'lab.html.Screen', + content: '<p>instructions</p>', + responses: { + 'keypress(Space)': 'continue', + 'keypress(q)': 'skipPractice', + }, + hooks: { end: skipPracticeOnRequest }, + }, + { + type: 'lab.flow.Loop', + templateParameters: [{ n: 1 }], + template: { + type: 'lab.html.Screen', + content: '<p>practice trial</p>', + timeout: 10, + }, + }, + { type: 'lab.html.Screen', content: '<p>real trials</p>' }, + ], + }; + const seen: string[] = []; + const observer = new MutationObserver(() => + seen.push(document.body.textContent ?? '') + ); + observer.observe(document.body, { childList: true, subtree: true }); + const { unmount } = render( + <LabjsExperimentWindow + title="Study" + experimentObject={skipStudy as never} + params={{} as never} + eventCallback={vi.fn()} + onFinish={vi.fn()} + /> + ); + await screen.findByText('instructions', {}, { timeout: 3000 }); + + fireEvent.keyPress(document, { key: key, charCode: key.charCodeAt(0) }); + await screen.findByText('real trials', {}, { timeout: 3000 }); + + observer.disconnect(); + expect(seen.some((text) => text.includes('practice trial'))).toBe( + practiceText !== null + ); + unmount(); + } + ); }); diff --git a/src/renderer/experiments/__tests__/participantScreens.test.ts b/src/renderer/experiments/__tests__/participantScreens.test.ts index 601f24ea..25e455cc 100644 --- a/src/renderer/experiments/__tests__/participantScreens.test.ts +++ b/src/renderer/experiments/__tests__/participantScreens.test.ts @@ -4,6 +4,10 @@ import { params as facesHousesParams } from '../faces_houses/params'; import { stroopExperiment } from '../stroop/experiment'; import { searchExperimentObject } from '../search/experiment'; import { multitaskingExperimentObject } from '../multitasking/experiment'; +import { customExperiment } from '../custom/experiment'; +import { customInstructionsScreen } from '../../utils/labjs/customStimuli'; +import { skipPracticeOnRequest } from '../../utils/labjs/functions'; +import type { ExperimentParameters } from '../../constants/interfaces'; vi.mock('lab.js', () => ({})); @@ -11,6 +15,7 @@ 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. */ @@ -27,11 +32,11 @@ const acceptedKeys = (node: unknown, out = new Set<string>()): Set<string> => { return out; }; -/** The `content` of the first screen with this title. */ -const screenContent = (node: unknown, title: string): string | undefined => { +/** 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 = screenContent(child, title); + const found = findScreen(child, title); if (found) return found; } } else if (node && typeof node === 'object') { @@ -39,9 +44,9 @@ const screenContent = (node: unknown, title: string): string | undefined => { (node as Node).title === title && typeof (node as Node).content === 'string' ) - return (node as Node).content as string; + return node as Node; for (const child of Object.values(node)) { - const found = screenContent(child, title); + const found = findScreen(child, title); if (found) return found; } } @@ -79,16 +84,34 @@ describe.each([ const accepted = new Set([...acceptedKeys(study), ...dynamicKeys]); it('show exactly the keys the trials accept before practice', () => { - const html = screenContent(study, instructionTitle as string) ?? ''; + const html = String(findScreen(study, instructionTitle)?.content ?? ''); expect(shownKeys(html)).toEqual(accepted); }); it.runIf(transitionTitle)( 'show the same keys again before the recorded task', () => { - const html = screenContent(study, transitionTitle as string) ?? ''; + const html = String( + findScreen(study, transitionTitle as string)?.content ?? '' + ); expect(shownKeys(html)).toEqual(accepted); } ); + + it('skip practice on Q exactly where the screen offers it', () => { + const instruction = findScreen(study, instructionTitle); + expect(instruction?.hooks?.end === skipPracticeOnRequest).toBe( + String(instruction?.content).includes('bw-participant-skip') + ); + }); } ); + +it('Custom skips practice on Q, as its screen offers', () => { + expect(customInstructionsScreen({} as ExperimentParameters)).toContain( + 'bw-participant-skip' + ); + expect(findScreen(customExperiment, 'Instruction')?.hooks?.end).toBe( + skipPracticeOnRequest + ); +}); diff --git a/src/renderer/experiments/custom/experiment.ts b/src/renderer/experiments/custom/experiment.ts index 4c0cb3ec..5b510f00 100644 --- a/src/renderer/experiments/custom/experiment.ts +++ b/src/renderer/experiments/custom/experiment.ts @@ -4,6 +4,7 @@ import { initResponseHandlers, triggerEEGCallback, resetCorrectResponse, + skipPracticeOnRequest, } from '../../utils/labjs/functions'; import { customInstructionsScreen, @@ -126,7 +127,10 @@ export const customExperiment = { 'keypress(Space)': 'continue', 'keypress(q)': 'skipPractice', }, - hooks: { 'before:prepare': prepareInstructions }, + hooks: { + 'before:prepare': prepareInstructions, + end: skipPracticeOnRequest, + }, title: 'Instruction', content: '', }, diff --git a/src/renderer/experiments/faces_houses/experiment.ts b/src/renderer/experiments/faces_houses/experiment.ts index b257ba9d..59de8267 100644 --- a/src/renderer/experiments/faces_houses/experiment.ts +++ b/src/renderer/experiments/faces_houses/experiment.ts @@ -4,6 +4,7 @@ import { initResponseHandlers, triggerEEGCallback, resetCorrectResponse, + skipPracticeOnRequest, } from '../../utils/labjs/functions'; import { endScreen, @@ -38,7 +39,7 @@ export const facesHousesExperiment = { 'keypress(Space)': 'continue', 'keypress(q)': 'skipPractice', }, - hooks: {}, + hooks: { end: skipPracticeOnRequest }, title: 'Instruction', content: instructionsScreen(instructions), }, diff --git a/src/renderer/experiments/search/experiment.ts b/src/renderer/experiments/search/experiment.ts index cf5f2827..785e0426 100644 --- a/src/renderer/experiments/search/experiment.ts +++ b/src/renderer/experiments/search/experiment.ts @@ -7,6 +7,7 @@ import { initGrid, initResponses, } from './utils'; +import { skipPracticeOnRequest } from '../../utils/labjs/functions'; import { endScreen, instructionsScreen, @@ -40,7 +41,7 @@ export const searchExperimentObject = { 'keypress(Space)': 'next', 'keypress(q)': 'skipPractice', }, - hooks: {}, + hooks: { end: skipPracticeOnRequest }, title: 'Instruction', content: instructionsScreen(instructions), }, @@ -58,7 +59,6 @@ export const searchExperimentObject = { }, title: 'Practice task', tardy: true, - skip: "${ state.response === 'skipPractice' }", shuffleGroups: [], template: { type: 'lab.flow.Sequence', diff --git a/src/renderer/experiments/stroop/experiment.ts b/src/renderer/experiments/stroop/experiment.ts index c3423690..2fe681d3 100644 --- a/src/renderer/experiments/stroop/experiment.ts +++ b/src/renderer/experiments/stroop/experiment.ts @@ -2,6 +2,7 @@ import { core } from 'lab.js'; import { initStroopTrial, emitStroopCondition, + skipPracticeOnRequest, } from '../../utils/labjs/functions'; import { endScreen, @@ -29,7 +30,7 @@ export const stroopExperiment = { title: 'Stroop task', content: [ { - hooks: {}, + hooks: { end: skipPracticeOnRequest }, type: 'lab.html.Screen', responses: { 'keypress(Space)': 'continue', @@ -51,7 +52,6 @@ export const stroopExperiment = { hooks: {}, title: 'Practice frame', tardy: true, - skip: "${ state.response === 'skipPractice' }", content: { hooks: {}, type: 'lab.flow.Loop', diff --git a/src/renderer/utils/labjs/functions.ts b/src/renderer/utils/labjs/functions.ts index e10822d0..e3e1a688 100644 --- a/src/renderer/utils/labjs/functions.ts +++ b/src/renderer/utils/labjs/functions.ts @@ -206,6 +206,20 @@ export function resetCorrectResponse(this: lab.core.Component) { this.data.correct_response = false; } +/** + * Instruction-screen `end` hook: a `skipPractice` response (Q) skips the + * practice block that follows this screen. A `skip` template on the block + * cannot do this: lab.js prepares the next component, parsing its options, + * before this screen's response is committed to `state`. + */ +export function skipPracticeOnRequest(this: lab.core.Component) { + if (this.data.response !== 'skipPractice') return; + const siblings: lab.core.Component[] = + (this.parent as lab.flow.Sequence | undefined)?.options.content ?? []; + const practice = siblings[siblings.indexOf(this) + 1]; + if (practice) practice.options.skip = true; +} + // ------------------------------------------------------------- // Stroop From c88e64fb4ebc1843d4d621084bd14f96ffda5e64 Mon Sep 17 00:00:00 2001 From: jdpigeon <morrisondano@gmail.com> Date: Fri, 25 Sep 2026 14:19:23 -0400 Subject: [PATCH 6/7] fix(custom): condition names and keys render literally on participant screens --- .../labjs/__tests__/customScreens.test.ts | 17 +++++++++++++++++ src/renderer/utils/labjs/customStimuli.ts | 19 +++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/renderer/utils/labjs/__tests__/customScreens.test.ts b/src/renderer/utils/labjs/__tests__/customScreens.test.ts index 4dc106b9..78739d71 100644 --- a/src/renderer/utils/labjs/__tests__/customScreens.test.ts +++ b/src/renderer/utils/labjs/__tests__/customScreens.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { template } from 'lodash'; import { EVENTS } from '../../../constants/constants'; import type { ExperimentParameters } from '../../../constants/interfaces'; import { @@ -52,4 +53,20 @@ describe('custom participant screens', () => { expect(html).toContain('${this.parameters.intro}'); expect(html).not.toContain('${danger}'); }); + + it('shows condition titles literally, never as HTML or a template', () => { + const hostile = { + ...params, + stimulus1: slot(EVENTS.STIMULUS_1, '<b>${danger}', '/pics/dogs', '1'), + } as unknown as ExperimentParameters; + const context = { parameters: { intro: '' }, state: {}, files: {} }; + const el = document.createElement('div'); + el.innerHTML = template(customInstructionsScreen(hostile), { + escape: '', + evaluate: '', + }).call(context, context); + + expect(el.textContent).toContain('<b>${danger}'); + expect(el.querySelector('b')).toBeNull(); + }); }); diff --git a/src/renderer/utils/labjs/customStimuli.ts b/src/renderer/utils/labjs/customStimuli.ts index 4e7d66d3..f8c2ba64 100644 --- a/src/renderer/utils/labjs/customStimuli.ts +++ b/src/renderer/utils/labjs/customStimuli.ts @@ -71,9 +71,19 @@ export const DEFAULT_RESPONSE_KEYS: Record<1 | 2 | 3 | 4, readonly string[]> = { const isActiveSlot = (slot: ConditionSlot | undefined) => Boolean(slot && (slot.dir || slot.audioDir)); +/** Teacher-typed text as literal screen text: no HTML, and no `${` for lab.js's template to run. */ +const literalText = (text: string) => + text + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/\$\{/g, '${'); + /** * What a participant is told to press: each condition with a stimulus folder, - * named as it is recorded (`conditionTitle`), with its key or none. + * named as it is recorded (`conditionTitle`), with its key or none. Both are + * escaped: they are baked into the screen's template source. */ export function customResponseRules( params: ExperimentParameters @@ -83,7 +93,12 @@ export function customResponseRules( keys: CONDITION_SLOTS.flatMap(({ name }) => { const slot = params[name]; return slot && isActiveSlot(slot) - ? [{ key: slot.response || undefined, meaning: conditionTitle(slot) }] + ? [ + { + key: slot.response ? literalText(slot.response) : undefined, + meaning: literalText(conditionTitle(slot)), + }, + ] : []; }), }, From 3914e26806c4978a733c6732d0981c9b4825e5d8 Mon Sep 17 00:00:00 2001 From: jdpigeon <morrisondano@gmail.com> Date: Fri, 25 Sep 2026 14:19:23 -0400 Subject: [PATCH 7/7] docs: WS5b fix round 1 TODOS and learnings --- .llms/learnings.md | 8 ++++++-- TODOS.md | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.llms/learnings.md b/.llms/learnings.md index a8e12c7f..05ef0913 100644 --- a/.llms/learnings.md +++ b/.llms/learnings.md @@ -413,8 +413,12 @@ not only `.container.fullscreen`, or the footer spills over Stop preview. `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. Q-to-skip-practice is -broken in every study that advertises it (see TODOS). +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 diff --git a/TODOS.md b/TODOS.md index 53f8f432..cff65cfc 100644 --- a/TODOS.md +++ b/TODOS.md @@ -31,7 +31,9 @@ Deferred and in-flight work. Keep this current — when something ships, delete - ~~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).** Faces/Houses, Stroop, Visual Search and Custom say "Press Q to skip practice"; Q ends the instruction screen but practice still runs. Stroop/Search's `skip: "${ state.response === 'skipPractice' }"` is parsed when lab.js prepares the loop, which happens before the instruction screen commits its response (`flipIterable.js` prepares the next stack before `stopOutgoing`), so it is always false; Faces/Houses and Custom have no `skip` at all. Pre-existing; found in the WS5b playtest. + - ~~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) - [ ] **Participant screens for imported jsPsych/lab.js studies** (deferred 2026-09-25): wrap author timelines with BrainWaves instruction/transition/end screens.