From 6563a7af84aa6dd6884d849a937a700a489856cd Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Fri, 25 Sep 2026 16:24:52 -0400 Subject: [PATCH 1/5] =?UTF-8?q?Design=20pass:=20WS6=20Clean=20=E2=80=94=20?= =?UTF-8?q?dataset=20select,=20review=20pair,=20primer,=20auto-flag=20sugg?= =?UTF-8?q?estions,=20save=20states=20(Storybook=20only)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/uxr/2026-09-25-ws6-design-brief.md | 72 ++++ src/renderer/app.global.css | 8 + .../components/Clean/Clean.stories.tsx | 329 ++++++++++++++++ .../components/Clean/CleanDatasetSelect.tsx | 201 ++++++++++ src/renderer/components/Clean/CleanParts.tsx | 89 +++++ src/renderer/components/Clean/CleanPrimer.tsx | 198 ++++++++++ src/renderer/components/Clean/CleanReview.tsx | 366 ++++++++++++++++++ src/renderer/components/Clean/fixtures.ts | 75 ++++ 8 files changed, 1338 insertions(+) create mode 100644 docs/uxr/2026-09-25-ws6-design-brief.md create mode 100644 src/renderer/components/Clean/Clean.stories.tsx create mode 100644 src/renderer/components/Clean/CleanDatasetSelect.tsx create mode 100644 src/renderer/components/Clean/CleanParts.tsx create mode 100644 src/renderer/components/Clean/CleanPrimer.tsx create mode 100644 src/renderer/components/Clean/CleanReview.tsx create mode 100644 src/renderer/components/Clean/fixtures.ts diff --git a/docs/uxr/2026-09-25-ws6-design-brief.md b/docs/uxr/2026-09-25-ws6-design-brief.md new file mode 100644 index 00000000..be7eb3f9 --- /dev/null +++ b/docs/uxr/2026-09-25-ws6-design-brief.md @@ -0,0 +1,72 @@ +# WS6 design brief — Clean + +**Gate:** plan §10.1 step 2. Storybook stories with fixture data only, no runtime wiring. Engineering integrates after product approves. + +**Sources:** +- `docs/uxr/playtest_naive_1_design_implementation_plan.md` §1.5, §8, §10.2 ("Clean"), §11 WS6. +- `docs/uxr/playtest_naive_1.md` finding 7. +- `docs/uxr/# Playtest Takeaways.md`, the "Cleaning Data" section. +- Design system: `docs/design/DESIGN.md`, `.design-sync/conventions.md`. +- Shape to match, merged: **#277 Analyze**. Use its left controls rail + results layout, its walkthrough step panel and its "no scrolling to reach results" rule, so Clean and Analyze read as one family. + +## The job + +A student has raw EEG recordings and must decide which trials and sensors are too noisy to keep before averaging. The real job (§8.2): +1. click a noisy epoch to exclude or restore it; +2. click a channel when one sensor is consistently bad; +3. treat auto-flags as suggestions to review; +4. watch the Live ERP update as exclusions change; +5. save the cleaned dataset and continue. + +The playtester didn't know what cleaning meant, when it comes before analysis, or what her job was on the screen. #271 added a primer and clearer action names; the layout itself was never redesigned. + +## Current code (read before designing) + +- `src/renderer/components/CleanComponent/index.tsx`: the screen. It has a dataset-select phase and a review phase, the #271 primer ("What does cleaning your data mean?"), and the actions `Start cleaning →`, `← Pick different data`, `Apply exclusions & save`, `Save cleaned dataset & analyze →`, plus confirmations for rejecting all, removing selected, and dropping multiple channels. +- `src/renderer/components/CleanComponent/EpochReviewer.tsx`: the canvas epoch reviewer (columns = epochs, click to exclude; channel labels click to flag). +- `src/renderer/components/CleanComponent/LiveErpPane.tsx`: the live ERP. +- `src/renderer/components/CleanComponent/epochArrays.ts`: decoding and `meanTrace`. Its data is `EpochArraysMeta` (`src/renderer/actions/pyodideActions.ts`) plus a Float32Array [epoch][channel][time]. +- `src/renderer/components/Analyze/fixtures.ts`: `EXAMPLE_EPOCH_ARRAYS` already exists in that shape. Reuse or extend it, and don't make a second synthetic generator. +- Auto-flag suggestions: `SuggestedRejection { index, reason }`, with the threshold `` in CleanComponent. +- Incomplete runs (#275): ended-early recordings are renamed `*.incomplete.csv` and hidden from ordinary discovery. +- **Try to render the real `EpochReviewer` and `LiveErpPane` with fixture props in Storybook.** If they need Redux or the worker, wrap them in a thin fixture adapter inside the stories. Don't edit them. If they can't render at all, draw faithful stand-ins and say so in the PR. + +## Stories required (plan §8, §10.2) + +Pure-props components under `src/renderer/components/Clean/`, with fixtures and stories, rendered inside the real AppShell chrome (`location='clean'`, a workspace, truthful badges and Next). + +| Story | Must show | +|---|---| +| DatasetSelect | Choose a complete raw recording. Ordinary list only; one primary `Start cleaning`. | +| DatasetSelectWithIncomplete | Incomplete recordings hidden by default, with a quiet "N ended-early recordings hidden — show" reveal. Revealed, they're clearly marked incomplete and not selectable as cleaning candidates, with a destructive-styled, confirmed `Delete` (§1.5, §11 WS6). | +| Loading | Loading epochs, explicit. | +| NoEpochs | The recording produced no usable epochs: why, and what to do. | +| Primer (first view of review) | A compact, always-available primer teaching the loop in §8.2, using the §8.1 definition. It collapses once the student interacts. No persisted first-use flag (§13). Consider #277's step-panel idiom with pointers at the reviewer. | +| Review | The Epoch Reviewer and Live ERP visible together as a coordinated pair (§8.3), plus the controls rail (dataset, back to selection, auto-flag threshold, exclusions summary). Rule A: everything on screen at once. | +| ReviewWithSelections | Several epochs excluded and one channel flagged, with the Live ERP visibly changed and the counts in words. | +| AutoFlagSuggestions | Suggestions shown as suggestions (distinct from the student's own exclusions), with review, accept and restore. | +| ConfirmRejectAll / ConfirmDropChannels | The existing confirmations, restyled (§8.3 keeps them). | +| Saving / SaveFailed / Saved | Save in progress, failure with retry, and success pointing to `Analyze` as the next step. | +| BehaviorOnly note | Not a story: behavior-only workspaces have no Clean area (WS1). Confirm this in the PR; don't design one. | + +## Constraints + +- Keep the epoch and channel selection behavior, save semantics and confirmations. Rename actions to describe their effect (§8.3). +- The route back to dataset selection stays (§8.3). +- The stale cleaning sidebar is gone (#271). Don't bring back saline or live-signal lessons (§8.3). +- The original recording is never modified, and the copy says so (§8.1). +- Condition colors come from `conditionPalette`. Signal-quality colors are not used for exclusions. Never color-only. +- One filled-teal primary per surface. Light headings. Student-facing copy. +- Rule A: at 1366×768 and 1280×720, the reviewer, the Live ERP and the controls are all visible with no page scroll. Measure and report. +- Traps in `.llms/learnings.md` (18px root, global `p`/`li`/lab.css). +- New files + stories only. Scoped CSS is fine; never modify shared global CSS classes. No new dependencies. Don't edit CleanComponent/*, epics, the worker or Python. + +## Out of scope + +Wiring, auto-flag algorithm changes, the Epoch reviewer Phase 3 guided mode's full curriculum (TODOS; OQ3 still open), Analyze. + +## Done when + +- Every story renders inside the real shell chrome, is screenshotted at both sizes, and has 0 console errors. +- `npx tsc --noEmit` is clean. +- A PR with the review agenda, open questions, whether the real EpochReviewer/LiveErpPane rendered, and any unmet constraint. diff --git a/src/renderer/app.global.css b/src/renderer/app.global.css index dd5829ff..ad76eb38 100644 --- a/src/renderer/app.global.css +++ b/src/renderer/app.global.css @@ -408,6 +408,14 @@ li { list-style: none; } +/* Clean review pair (components/Clean/CleanReview): EpochReviewer and + LiveErpPane draw at a fixed 640px logical width. `zoom` scales them — + layout included — so both fit beside the 300px rail at 1280 wide without + page scroll. */ +.bw-clean-fit { + zoom: 0.7; +} + a { color: inherit; text-decoration: none; diff --git a/src/renderer/components/Clean/Clean.stories.tsx b/src/renderer/components/Clean/Clean.stories.tsx new file mode 100644 index 00000000..1b5cc989 --- /dev/null +++ b/src/renderer/components/Clean/Clean.stories.tsx @@ -0,0 +1,329 @@ +import React, { useState } from 'react'; +import type { Decorator, Meta, StoryObj } from '@storybook/react-vite'; +import { MemoryRouter } from 'react-router-dom'; +import { fn } from 'storybook/test'; +import AppShell from '../AppShell/AppShell'; +import type { Area } from '../AppShell/types'; +import CleanDatasetSelect from './CleanDatasetSelect'; +import CleanReview, { + CleanConfirm, + CleanSuggestion, + SaveState, +} from './CleanReview'; +import { PrimerStep } from './CleanPrimer'; +import { + EXAMPLE_EPOCH_ARRAYS, + FACES_HOUSES_CODE_TO_LABEL, + RAW_RECORDINGS, + SUGGESTED_REJECTIONS, + WORKSPACE_TITLE, + RawRecording, +} from './fixtures'; + +interface ChromeParameters { + /** Shell badges for the story's data state (`useWorkspaceProgress.summarize`). */ + badges?: Partial>; + nextArea?: Area; +} + +/** + * Storybook deep-merges object parameters, so stories set `badges` whole and + * the fresh-workspace default lives here rather than in `meta.parameters`. + * + * The real chrome around Clean: AppShell at `clean` with the story's workspace + * facts. Clean has no tab bar; the screen fills the rest without page scroll. + */ +const withCleanChrome: Decorator = (Story, { parameters }) => { + const { + badges = { collect: ['4 recordings'] }, + nextArea = 'clean', + } = parameters as ChromeParameters; + return ( + + + + + + ); +}; + +const meta: Meta = { + title: 'Domain/Clean', + parameters: { layout: 'fullscreen' }, + decorators: [withCleanChrome], +}; +export default meta; +type Story = StoryObj; + +/** Dataset selection with local state; the reveal and delete confirm are live. */ +function SelectHarness({ + recordings, + initialShowIncomplete = false, +}: { + recordings: RawRecording[]; + initialShowIncomplete?: boolean; +}) { + const [selected, setSelected] = useState([recordings[0].key]); + const [showIncomplete, setShowIncomplete] = useState(initialShowIncomplete); + const [deleting, setDeleting] = useState(null); + return ( + setDeleting(null)} + onDeleteCancel={() => setDeleting(null)} + onStart={fn()} + /> + ); +} + +interface ReviewHarnessProps { + status?: 'ready' | 'loading' | 'no-epochs'; + rejected?: number[]; + badChannels?: string[]; + suggestions?: CleanSuggestion[]; + saveState?: SaveState; + confirm?: CleanConfirm | null; + primerOpen?: boolean; + primerStep?: PrimerStep; +} + +/** Review with local state: exclusion clicks, suggestions, save and dialogs are live. */ +function ReviewHarness({ + status = 'ready', + rejected = [], + badChannels = [], + suggestions = [], + saveState = 'idle', + confirm = null, + primerOpen = false, + primerStep = 1, +}: ReviewHarnessProps) { + const [rejectedSet, setRejectedSet] = useState( + () => + new Set([ + ...rejected, + // An accepted suggestion is excluded — the sets never disagree. + ...suggestions.filter((s) => s.accepted).map((s) => s.index), + ]) + ); + const [badChannelSet, setBadChannelSet] = useState(() => new Set(badChannels)); + const [suggestionState, setSuggestionState] = useState(suggestions); + const [threshold, setThreshold] = useState(100); + const [save, setSave] = useState(saveState); + const [dialog, setDialog] = useState(confirm); + const [primer, setPrimer] = useState(primerOpen); + const [step, setStep] = useState(primerStep); + return ( + { + setPrimer(false); + setRejectedSet((prev) => { + const next = new Set(prev); + if (next.has(index)) { + next.delete(index); + } else { + next.add(index); + } + return next; + }); + }} + onToggleChannel={(name) => { + setPrimer(false); + const next = new Set(badChannelSet); + const adding = !next.has(name); + if (adding) { + next.add(name); + } else { + next.delete(name); + } + setBadChannelSet(next); + // Dropping more than one of four sensors is informational (existing + // behavior): the flag applies either way, the dialog just warns. + if (adding && next.size > 1) { + setDialog('dropChannels'); + } + }} + autoFlagThreshold={threshold} + onThresholdChange={setThreshold} + suggestions={suggestionState} + onAcceptSuggestion={(index) => { + setPrimer(false); + setRejectedSet((prev) => new Set(prev).add(index)); + setSuggestionState((prev) => + prev.map((s) => (s.index === index ? { ...s, accepted: true } : s)) + ); + }} + onRestoreSuggestion={(index) => { + setPrimer(false); + setRejectedSet((prev) => { + const next = new Set(prev); + next.delete(index); + return next; + }); + setSuggestionState((prev) => + prev.map((s) => (s.index === index ? { ...s, accepted: false } : s)) + ); + }} + onSuggest={() => + setSuggestionState( + SUGGESTED_REJECTIONS.map((s) => ({ ...s, accepted: false })) + ) + } + saveState={save} + onApply={() => { + if (rejectedSet.size >= EXAMPLE_EPOCH_ARRAYS.meta.n_epochs) { + setDialog('rejectAll'); + } else { + setSave('saving'); + } + }} + onSave={() => { + if (rejectedSet.size >= EXAMPLE_EPOCH_ARRAYS.meta.n_epochs) { + setDialog('rejectAll'); + } else if (rejectedSet.size > 0) { + setDialog('removeSelected'); + } else if (badChannelSet.size > 0) { + setDialog('applyChannels'); + } else { + setSave('saving'); + } + }} + onRetrySave={() => setSave('saving')} + onGoToAnalyze={fn()} + onGoToCollect={fn()} + onBackToSelection={fn()} + confirm={dialog} + onConfirmAccept={() => { + setDialog(null); + setSave('saving'); + }} + onConfirmCancel={() => setDialog(null)} + primerOpen={primer} + primerStep={step} + onPrimerOpenChange={setPrimer} + onPrimerStepChange={setStep} + /> + ); +} + +/** C01 — Pick a complete raw recording: ordinary list only, one primary Start cleaning. */ +export const DatasetSelect: Story = { + render: () => ( + !recording.incomplete)} + /> + ), +}; + +/** C02 — Ended-early recordings hidden by default; reveal marks them incomplete and deletes with a confirm. */ +export const DatasetSelectWithIncomplete: Story = { + render: () => , +}; + +/** C03 — Loading epochs, said out loud; the rail stays usable. */ +export const Loading: Story = { + render: () => , +}; + +/** C04 — The recording produced no usable epochs: why, and what to do next. */ +export const NoEpochs: Story = { + render: () => , +}; + +/** C05 — First view of review: the compact primer teaching the cleaning loop, pointing at the reviewer. */ +export const Primer: Story = { + render: () => , +}; + +/** C06 — Review: the Epoch Reviewer and the Live ERP as a coordinated pair, with the controls rail. */ +export const Review: Story = { + render: () => , +}; + +/** C07 — Four trials left out and one sensor flagged: the Live ERP is visibly cleaner, counts in words. */ +export const ReviewWithSelections: Story = { + render: () => ( + ({ + ...s, + accepted: s.index === 3, + }))} + /> + ), +}; + +/** C08 — Auto-flag output as suggestions: distinct from your own exclusions, with accept and restore. */ +export const AutoFlagSuggestions: Story = { + render: () => ( + ({ + ...s, + accepted: s.index === 3, + }))} + /> + ), +}; + +/** C09 — The reject-all confirmation, restyled as a dialog with a destructive confirm. */ +export const ConfirmRejectAll: Story = { + render: () => ( + i + )} + confirm="rejectAll" + /> + ), +}; + +/** C10 — Flagging more than one of four sensors: the drop-channels caution, restyled. */ +export const ConfirmDropChannels: Story = { + render: () => ( + + ), +}; + +/** C11 — Save in progress, said in words; the original recording is unchanged. */ +export const Saving: Story = { + render: () => , +}; + +/** C12 — Save failed: nothing written, with Try again. */ +export const SaveFailed: Story = { + render: () => , +}; + +/** C13 — Saved: success in words, pointing to Analyze as the next step. */ +export const Saved: Story = { + parameters: { + badges: { collect: ['4 recordings'], clean: ['1 cleaned'] }, + nextArea: 'analyze', + }, + render: () => , +}; diff --git a/src/renderer/components/Clean/CleanDatasetSelect.tsx b/src/renderer/components/Clean/CleanDatasetSelect.tsx new file mode 100644 index 00000000..12332aab --- /dev/null +++ b/src/renderer/components/Clean/CleanDatasetSelect.tsx @@ -0,0 +1,201 @@ +import React from 'react'; +import { Button } from '../ui/button'; +import { cn } from '../ui/utils'; +import { RailSection, railLabel } from '../Analyze/AnalyzeParts'; +import { CleanLayout, ConfirmDialog } from './CleanParts'; +import { CLEAN_DEFINITION } from './CleanPrimer'; +import type { RawRecording } from './fixtures'; + +export interface CleanDatasetSelectProps { + recordings: RawRecording[]; + /** Keys of the recordings chosen for cleaning. */ + selected: string[]; + onSelectChange(keys: string[]): void; + /** Ended-early recordings stay hidden until the student asks for them. */ + showIncomplete: boolean; + onShowIncompleteChange(show: boolean): void; + /** Ended-early recording pending a confirmed delete; null closes the dialog. */ + deletingRecording: RawRecording | null; + onDeleteRequest(recording: RawRecording): void; + onDeleteConfirm(): void; + onDeleteCancel(): void; + onStart(): void; +} + +/** The §8.2 loop in one line each, numbers written as text (global `li` reset). */ +const LOOP = [ + 'Leave out noisy trials by clicking them.', + 'Flag a sensor that looks bad the whole way through.', + 'Check the auto-flag suggestions — they are only suggestions.', + 'Watch the Live ERP clean up as you go.', + 'Save the cleaned copy and continue to Analyze.', +]; + +/** + * Clean's first phase: pick a complete raw recording to clean. Ended-early + * recordings are hidden by default and, once revealed, are clearly incomplete + * and deletable but never selectable as cleaning candidates. Pure props. + */ +export default function CleanDatasetSelect({ + recordings, + selected, + onSelectChange, + showIncomplete, + onShowIncompleteChange, + deletingRecording, + onDeleteRequest, + onDeleteConfirm, + onDeleteCancel, + onStart, +}: CleanDatasetSelectProps) { + const complete = recordings.filter((r) => !r.incomplete); + const incomplete = recordings.filter((r) => r.incomplete); + const chosen = recordings.filter((r) => selected.includes(r.key)); + + const rail = ( + <> + +
+ {CLEAN_DEFINITION} +
+
+ +
    + {LOOP.map((line, i) => ( +
  1. + {i + 1}. {line} +
  2. + ))} +
+
+ +
+ {chosen.length === 0 ? ( + 'Nothing chosen yet.' + ) : ( + <> +
+ {chosen.length} recording{chosen.length === 1 ? '' : 's'} ·{' '} + {chosen.map((r) => r.subject).join(', ')} +
+
+ {chosen.map((r) => r.name).join(', ')} +
+ + )} +
+ +
+ + ); + + return ( + +
+

Clean your data

+
+ Choose a complete raw recording. You can pick more than one; cleaning + saves a new copy and never changes the original. +
+ +
Complete recordings
+
    + {complete.map((recording) => { + const checked = selected.includes(recording.key); + return ( +
  • + +
  • + ); + })} +
+ + {incomplete.length > 0 && ( +
+ {incomplete.length} ended-early recording + {incomplete.length === 1 ? '' : 's'}{' '} + {showIncomplete ? 'shown below' : 'hidden'} + +
+ )} + + {showIncomplete && ( +
    + {incomplete.map((recording) => ( +
  • + + Ended early + + + {recording.subject} · {recording.name} + + + Kept as incomplete data — not a cleaning candidate. + + +
  • + ))} +
+ )} +
+ + +
+ ); +} diff --git a/src/renderer/components/Clean/CleanParts.tsx b/src/renderer/components/Clean/CleanParts.tsx new file mode 100644 index 00000000..133a7984 --- /dev/null +++ b/src/renderer/components/Clean/CleanParts.tsx @@ -0,0 +1,89 @@ +import React, { ReactNode } from 'react'; +import { Button } from '../ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '../ui/dialog'; + +/** + * Clean screen body: a fixed-width controls rail on the left and the working + * area on the right, sized so both fit the window without page scroll — the + * Analyze layout, so Clean and Analyze read as one family. + */ +export function CleanLayout({ + title, + rail, + children, +}: { + /** Screen-reader heading for the screen. */ + title: string; + rail: ReactNode; + children: ReactNode; +}) { + return ( +
+

{title}

+ +
+ {children} +
+
+ ); +} + +/** + * One confirmation dialog for the whole screen family. Restyles the native + * `showMessageBox` confirmations of `CleanComponent` as in-app dialogs, so the + * wording and the two-button contract stay the same. + */ +export function ConfirmDialog({ + open, + title, + body, + confirmLabel, + destructive = false, + onConfirm, + onCancel, +}: { + open: boolean; + title: string; + body: string; + /** The effect-bearing action, rightmost. `Cancel` is always the left button. */ + confirmLabel: string; + /** Red for actions that leave nothing behind (rejecting every trial, deleting). */ + destructive?: boolean; + onConfirm(): void; + onCancel(): void; +}) { + return ( + !next && onCancel()}> + + + {title} + + {body} + + +
+ + +
+
+
+ ); +} diff --git a/src/renderer/components/Clean/CleanPrimer.tsx b/src/renderer/components/Clean/CleanPrimer.tsx new file mode 100644 index 00000000..518651a9 --- /dev/null +++ b/src/renderer/components/Clean/CleanPrimer.tsx @@ -0,0 +1,198 @@ +import React, { useEffect, useRef } from 'react'; +import { Button } from '../ui/button'; +import { cn } from '../ui/utils'; +import { railLabel } from '../Analyze/AnalyzeParts'; + +/** The cleaning loop of plan §8.2, one step at a time. */ +export type PrimerStep = 1 | 2 | 3 | 4 | 5; + +/** The §8.1 definition, shown with every step. */ +export const CLEAN_DEFINITION = + 'Cleaning means finding trials or sensors with movement or poor signal and excluding them before the responses are averaged. Your original recording stays unchanged.'; + +const STEPS: PrimerStep[] = [1, 2, 3, 4, 5]; + +const PRIMER_COPY: Record< + PrimerStep, + { title: string; body: string; pointer: string } +> = { + 1: { + title: 'Leave out noisy trials', + body: 'Every column in the Epochs panel is one trial. Click a noisy one to leave it out — click it again to bring it back.', + pointer: 'Click a noisy trial column to leave it out', + }, + 2: { + title: 'Flag a bad sensor', + body: 'One row is one sensor. If one sensor looks bad the whole way through, click its name to leave it out too.', + pointer: 'Click a sensor name to flag it', + }, + 3: { + title: 'Check the suggestions', + body: 'Auto-flag can point out trials that look noisy. They are only suggestions — you decide what to leave out.', + pointer: 'Suggestions wait for your review', + }, + 4: { + title: 'Watch the Live ERP', + body: 'The average on the right updates as you leave trials out. As the noisy trials go, the waves get cleaner.', + pointer: 'This average updates as you clean', + }, + 5: { + title: 'Save and continue', + body: 'Save the cleaned dataset when you are happy. Analyze uses the cleaned copy to make your results; your original recording stays unchanged.', + pointer: 'Save is in the left panel', + }, +}; + +/** Where each step points over the review pair (`left`/`top` in % of the pair). */ +const POINTER_PLACEMENT: Record< + PrimerStep, + { left: string; top: string; arrow: string } +> = { + 1: { left: '14%', top: '30%', arrow: '↓' }, + 2: { left: '1%', top: '58%', arrow: '→' }, + 3: { left: '0%', top: '4%', arrow: '←' }, + 4: { left: '58%', top: '42%', arrow: '↓' }, + 5: { left: '0%', top: '88%', arrow: '←' }, +}; + +/** + * A callout chip over the review pair showing which part of the screen the + * current primer step is about. Decorative — the step copy carries the words. + */ +export function PrimerPointer({ step }: { step: PrimerStep }) { + const { left, top, arrow } = POINTER_PLACEMENT[step]; + return ( +
+ {arrow === '←' && {arrow}} + {PRIMER_COPY[step].pointer} + {arrow !== '←' && {arrow}} +
+ ); +} + +/** + * The always-available cleaning primer: a compact step panel under the review + * pair (the walkthrough idiom from Analyze), collapsed to a one-line bar once + * the student starts working. No first-use flag — it can always be reopened. + */ +export function CleanPrimer({ + open, + step, + onStepChange, + onOpenChange, +}: { + open: boolean; + step: PrimerStep; + onStepChange(step: PrimerStep): void; + onOpenChange(open: boolean): void; +}) { + const heading = useRef(null); + const mounted = useRef(false); + useEffect(() => { + if (mounted.current) heading.current?.focus(); + mounted.current = true; + }, [step]); + + if (!open) { + return ( +
+ How cleaning works +
+ Click a noisy trial to leave it out — click again to bring it back. +
+ +
+ ); + } + + const copy = PRIMER_COPY[step]; + return ( +
+
+
+ + How cleaning works · Step {step} of 5 + + + {STEPS.map((s) => ( + + ))} + +
+

+ {copy.title} +

+
+ {copy.body} +
+
+ {CLEAN_DEFINITION} +
+
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/renderer/components/Clean/CleanReview.tsx b/src/renderer/components/Clean/CleanReview.tsx new file mode 100644 index 00000000..b8318c35 --- /dev/null +++ b/src/renderer/components/Clean/CleanReview.tsx @@ -0,0 +1,366 @@ +import React from 'react'; +import type { SuggestedRejection } from '../../actions'; +import { PTP_THRESHOLD } from '../../constants/constants'; +import EpochReviewer from '../CleanComponent/EpochReviewer'; +import LiveErpPane from '../CleanComponent/LiveErpPane'; +import { Button } from '../ui/button'; +import { Spinner } from '../ui/spinner'; +import { RailSection, ResultStatus, railLabel } from '../Analyze/AnalyzeParts'; +import { CleanLayout, ConfirmDialog } from './CleanParts'; +import { CleanPrimer, PrimerPointer, PrimerStep } from './CleanPrimer'; +import type { EpochArrays } from './fixtures'; + +/** Which `CleanComponent` confirmation is open, restyled as an in-app dialog. */ +export type CleanConfirm = + | 'rejectAll' + | 'removeSelected' + | 'applyChannels' + | 'dropChannels'; + +/** One auto-flag suggestion with its review state. */ +export interface CleanSuggestion extends SuggestedRejection { + /** Accepted suggestions are excluded like the student's own clicks. */ + accepted: boolean; +} + +/** Where the save stands. `idle` shows the two save actions. */ +export type SaveState = 'idle' | 'saving' | 'failed' | 'saved'; + +export interface CleanReviewProps { + /** Where the epochs came from, for the rail and the status copy. */ + dataset: { subject: string; recording: string }; + /** Epochs as `pyodide.epochArrays` holds them; null while loading. */ + epochArrays: EpochArrays | null; + /** `loading` and `no-epochs` replace the pair; the rail stays usable. */ + status: 'ready' | 'loading' | 'no-epochs'; + codeToLabel: Record; + /** ABSOLUTE epoch indices left out, including accepted suggestions. */ + rejected: Set; + badChannels: Set; + onToggleEpoch(index: number): void; + onToggleChannel(name: string): void; + autoFlagThreshold: number; + onThresholdChange(value: number): void; + suggestions: CleanSuggestion[]; + onAcceptSuggestion(index: number): void; + onRestoreSuggestion(index: number): void; + onSuggest(): void; + saveState: SaveState; + onApply(): void; + onSave(): void; + onRetrySave(): void; + onGoToAnalyze(): void; + onGoToCollect(): void; + onBackToSelection(): void; + confirm: CleanConfirm | null; + onConfirmAccept(): void; + onConfirmCancel(): void; + primerOpen: boolean; + primerStep: PrimerStep; + onPrimerOpenChange(open: boolean): void; + onPrimerStepChange(step: PrimerStep): void; +} + +/** + * Clean's review phase: the Epoch Reviewer and the Live ERP as a coordinated + * pair beside the controls rail, with the always-available primer, auto-flag + * suggestions to review, and the save states. Everything fits the window with + * no page scroll. Pure props; the real reviewer and ERP panes are rendered + * unmodified. + */ +export default function CleanReview(props: CleanReviewProps) { + const meta = props.epochArrays?.meta ?? null; + const total = meta?.n_epochs ?? 0; + const acceptedCount = props.suggestions.filter((s) => s.accepted).length; + const kept = total - props.rejected.size; + const { dataset } = props; + + const rail = ( + <> + +
+
{dataset.subject}
+
{dataset.recording}
+
+ +
+ +
+ More flags + props.onThresholdChange(Number(e.target.value))} + className="flex-1 accent-brand" + /> + Fewer flags +
+
+ Suggests trials whose peak-to-peak amplitude goes over{' '} + {props.autoFlagThreshold} µV. +
+ +
+ + {total === 0 ? ( +
+ Counts show up once the trials are loaded. +
+ ) : ( +
+
+ {props.rejected.size} of {total} trials left out + {props.rejected.size > 0 && + (acceptedCount > 0 && props.rejected.size > acceptedCount + ? ` — ${props.rejected.size - acceptedCount} clicked by you, ${acceptedCount} accepted from suggestions` + : acceptedCount > 0 + ? ' — all accepted from suggestions' + : ' — all clicked by you')} +
+
+ {props.badChannels.size === 0 + ? 'No sensors flagged' + : `Sensor${props.badChannels.size === 1 ? '' : 's'} flagged: ${[ + ...props.badChannels, + ].join(', ')}`} +
+
{kept} trials will be averaged
+
+ )} +
+ + {props.saveState === 'saving' && ( +
+ +
+
+ Saving your cleaned dataset… +
+
+ Writing a cleaned copy. Your original recording is unchanged. +
+
+
+ )} + {props.saveState === 'failed' && ( +
+
+ Couldn't save the cleaned dataset +
+
+ Nothing was written — your original recording is unchanged. +
+
+ + +
+
+ )} + {props.saveState === 'saved' && ( +
+
+ ✓ Cleaned dataset saved +
+
+ Your original recording is unchanged. The cleaned copy is ready to + use in Analyze. +
+ +
+ )} + {props.saveState === 'idle' && ( +
+ + +
+ )} +
+ + ); + + let body: React.ReactNode; + if (props.status === 'loading') { + body = ( + + ); + } else if (props.status === 'no-epochs') { + body = ( +
+

+ No trials to clean in this recording +

+
+ {dataset.recording} has no usable trials. That usually means the + experiment ended before any stimulus appeared, or the trial markers + were missing. Your original recording is unchanged. +
+
+ + +
+
+ ); + } else { + body = ( + <> +
+
+ +
+
+ +
+ {props.primerOpen && } +
+ + {props.suggestions.length > 0 && ( +
+
+ Suggested by auto-flag +
+ Suggestions, not decisions — check each one before it goes into + your cleaned data. +
+
+ {props.suggestions.length - acceptedCount} to review ·{' '} + {acceptedCount} accepted +
+
+
    + {props.suggestions.map((suggestion) => ( +
  • + + Trial {suggestion.index} + + + {suggestion.reason} + + {suggestion.accepted ? ( + <> + + ✓ Left out (from a suggestion) + + + + ) : ( + + )} +
  • + ))} +
+
+ )} + + + + ); + } + + const confirmCopy: Record< + CleanConfirm, + { title: string; body: string; confirmLabel: string; destructive: boolean } + > = { + rejectAll: { + title: 'Leave out every trial?', + body: `This will reject all ${total} epochs, leaving nothing to analyze. Are you sure?`, + confirmLabel: 'Reject all anyway', + destructive: true, + }, + removeSelected: { + title: 'Remove the selected trials?', + body: `This will remove ${props.rejected.size} selected epoch${props.rejected.size === 1 ? '' : 's'} before analysis. Continue?`, + confirmLabel: 'Remove selected and analyze', + destructive: false, + }, + applyChannels: { + title: 'Apply the flagged sensors?', + body: 'This will apply flagged bad channels before analysis. Continue?', + confirmLabel: 'Apply and analyze', + destructive: false, + }, + dropChannels: { + title: 'More than one bad sensor flagged', + body: "You've marked more than one bad channel on a 4-channel recording. That removes a big chunk of your data — if the signal is really this noisy, consider collecting another dataset.", + confirmLabel: 'Got it', + destructive: false, + }, + }; + const dialog = props.confirm ? confirmCopy[props.confirm] : null; + + return ( + + {body} + + + ); +} diff --git a/src/renderer/components/Clean/fixtures.ts b/src/renderer/components/Clean/fixtures.ts new file mode 100644 index 00000000..d0fe75e0 --- /dev/null +++ b/src/renderer/components/Clean/fixtures.ts @@ -0,0 +1,75 @@ +import type { SuggestedRejection } from '../../actions'; + +export { + EXAMPLE_EPOCH_ARRAYS, + FACES_HOUSES_CODE_TO_LABEL, + MUSE_CHANNEL_INFO, + WORKSPACE_TITLE, +} from '../Analyze/fixtures'; +export type { EpochArrays } from '../Analyze/fixtures'; + +/** A raw EEG recording as `readWorkspaceRawEEGData` lists it (`---raw.csv`). */ +export interface RawRecording { + key: string; + subject: string; + /** File name shown in the list. */ + name: string; + /** How long the run lasted. */ + duration: string; + /** Ended-early runs are renamed `*.incomplete.csv` and hidden from ordinary selection. */ + incomplete: boolean; +} + +/** + * Example workspace recordings: three complete runs Clean may offer, and two + * ended-early runs (#275) kept as incomplete data but never cleaning + * candidates. + */ +export const RAW_RECORDINGS: RawRecording[] = [ + { + key: 'P01-A-1', + subject: 'P01', + name: 'P01-A-1-raw.csv', + duration: '4 min · 84 trials', + incomplete: false, + }, + { + key: 'P01-A-2', + subject: 'P01', + name: 'P01-A-2-raw.csv', + duration: '4 min · 84 trials', + incomplete: false, + }, + { + key: 'P02-A-1', + subject: 'P02', + name: 'P02-A-1-raw.csv', + duration: '3 min · 62 trials', + incomplete: false, + }, + { + key: 'P02-A-2', + subject: 'P02', + name: 'P02-A-2-raw.incomplete.csv', + duration: '2 min · experiment ended early', + incomplete: true, + }, + { + key: 'P03-A-1', + subject: 'P03', + name: 'P03-A-1-raw.incomplete.csv', + duration: '30 s · experiment ended early', + incomplete: true, + }, +]; + +/** + * Auto-flag output for the example epochs, in the shape Python's + * `suggest_rejections` returns: one artifact suggestion per noisy trial. + * Suggestions are never applied on their own — the student accepts them. + */ +export const SUGGESTED_REJECTIONS: SuggestedRejection[] = [ + { index: 3, reason: 'Peak-to-peak 212 µV at AF7 — over the 100 µV threshold' }, + { index: 12, reason: 'Peak-to-peak 189 µV at TP9 — over the 100 µV threshold' }, + { index: 27, reason: 'Peak-to-peak 176 µV at AF7 — over the 100 µV threshold' }, +]; From 626030d0698d8acc6fd5fc4158db4dadf1cc2873 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Fri, 25 Sep 2026 17:14:32 -0400 Subject: [PATCH 2/5] Round 2: single-recording selection; full-area Epochs panel, Live ERP + suggestions in the rail, primer as rail bar + overlay step card --- src/renderer/app.global.css | 8 - .../components/Clean/Clean.stories.tsx | 2 +- .../components/Clean/CleanDatasetSelect.tsx | 50 ++-- src/renderer/components/Clean/CleanParts.tsx | 48 +++- src/renderer/components/Clean/CleanPrimer.tsx | 211 ++++++++------- src/renderer/components/Clean/CleanReview.tsx | 254 +++++++++--------- src/renderer/components/Clean/fixtures.ts | 7 +- 7 files changed, 303 insertions(+), 277 deletions(-) diff --git a/src/renderer/app.global.css b/src/renderer/app.global.css index ad76eb38..dd5829ff 100644 --- a/src/renderer/app.global.css +++ b/src/renderer/app.global.css @@ -408,14 +408,6 @@ li { list-style: none; } -/* Clean review pair (components/Clean/CleanReview): EpochReviewer and - LiveErpPane draw at a fixed 640px logical width. `zoom` scales them — - layout included — so both fit beside the 300px rail at 1280 wide without - page scroll. */ -.bw-clean-fit { - zoom: 0.7; -} - a { color: inherit; text-decoration: none; diff --git a/src/renderer/components/Clean/Clean.stories.tsx b/src/renderer/components/Clean/Clean.stories.tsx index 1b5cc989..39f21f15 100644 --- a/src/renderer/components/Clean/Clean.stories.tsx +++ b/src/renderer/components/Clean/Clean.stories.tsx @@ -74,7 +74,7 @@ function SelectHarness({ recordings: RawRecording[]; initialShowIncomplete?: boolean; }) { - const [selected, setSelected] = useState([recordings[0].key]); + const [selected, setSelected] = useState(recordings[0].key); const [showIncomplete, setShowIncomplete] = useState(initialShowIncomplete); const [deleting, setDeleting] = useState(null); return ( diff --git a/src/renderer/components/Clean/CleanDatasetSelect.tsx b/src/renderer/components/Clean/CleanDatasetSelect.tsx index 12332aab..dac050ce 100644 --- a/src/renderer/components/Clean/CleanDatasetSelect.tsx +++ b/src/renderer/components/Clean/CleanDatasetSelect.tsx @@ -8,9 +8,9 @@ import type { RawRecording } from './fixtures'; export interface CleanDatasetSelectProps { recordings: RawRecording[]; - /** Keys of the recordings chosen for cleaning. */ - selected: string[]; - onSelectChange(keys: string[]): void; + /** The one recording chosen for cleaning — Clean loads a single recording. */ + selected: string | null; + onSelectChange(key: string): void; /** Ended-early recordings stay hidden until the student asks for them. */ showIncomplete: boolean; onShowIncompleteChange(show: boolean): void; @@ -32,7 +32,7 @@ const LOOP = [ ]; /** - * Clean's first phase: pick a complete raw recording to clean. Ended-early + * Clean's first phase: pick one complete raw recording to clean. Ended-early * recordings are hidden by default and, once revealed, are clearly incomplete * and deletable but never selectable as cleaning candidates. Pure props. */ @@ -50,7 +50,7 @@ export default function CleanDatasetSelect({ }: CleanDatasetSelectProps) { const complete = recordings.filter((r) => !r.incomplete); const incomplete = recordings.filter((r) => r.incomplete); - const chosen = recordings.filter((r) => selected.includes(r.key)); + const chosen = recordings.find((r) => r.key === selected) ?? null; const rail = ( <> @@ -70,24 +70,19 @@ export default function CleanDatasetSelect({
- {chosen.length === 0 ? ( - 'Nothing chosen yet.' - ) : ( + {chosen ? ( <> -
- {chosen.length} recording{chosen.length === 1 ? '' : 's'} ·{' '} - {chosen.map((r) => r.subject).join(', ')} -
-
- {chosen.map((r) => r.name).join(', ')} -
+
{chosen.subject}
+
{chosen.name}
+ ) : ( + 'Nothing chosen yet.' )}
+ + ); +} + /** - * The always-available cleaning primer: a compact step panel under the review - * pair (the walkthrough idiom from Analyze), collapsed to a one-line bar once - * the student starts working. No first-use flag — it can always be reopened. + * The open primer: one step at a time in the walkthrough idiom from Analyze, + * docked as a card near its pointer target over the review area (it covers no + * control and no target). No first-use flag — it can always be reopened from + * the rail bar. */ -export function CleanPrimer({ - open, +export function CleanPrimerCard({ step, onStepChange, - onOpenChange, + onClose, + className, }: { - open: boolean; step: PrimerStep; onStepChange(step: PrimerStep): void; - onOpenChange(open: boolean): void; + onClose(): void; + className?: string; }) { const heading = useRef(null); const mounted = useRef(false); @@ -97,101 +132,73 @@ export function CleanPrimer({ mounted.current = true; }, [step]); - if (!open) { - return ( -
- How cleaning works -
- Click a noisy trial to leave it out — click again to bring it back. -
- -
- ); - } - const copy = PRIMER_COPY[step]; return (
-
-
- - How cleaning works · Step {step} of 5 - - - {STEPS.map((s) => ( - - ))} - -
-

+ + How cleaning works · Step {step} of 5 + + + {STEPS.map((s) => ( + + ))} + +

-
- {copy.body} -
-
- {CLEAN_DEFINITION} -
+ Hide ✕ + +
+

+ {copy.title} +

+
{copy.body}
+
+ {CLEAN_DEFINITION}
-
+
+ -
- - -
); diff --git a/src/renderer/components/Clean/CleanReview.tsx b/src/renderer/components/Clean/CleanReview.tsx index b8318c35..5cdb4550 100644 --- a/src/renderer/components/Clean/CleanReview.tsx +++ b/src/renderer/components/Clean/CleanReview.tsx @@ -6,8 +6,13 @@ import LiveErpPane from '../CleanComponent/LiveErpPane'; import { Button } from '../ui/button'; import { Spinner } from '../ui/spinner'; import { RailSection, ResultStatus, railLabel } from '../Analyze/AnalyzeParts'; -import { CleanLayout, ConfirmDialog } from './CleanParts'; -import { CleanPrimer, PrimerPointer, PrimerStep } from './CleanPrimer'; +import { CleanLayout, ConfirmDialog, FitPane } from './CleanParts'; +import { + CleanPrimerBar, + CleanPrimerCard, + PrimerPointer, + PrimerStep, +} from './CleanPrimer'; import type { EpochArrays } from './fixtures'; /** Which `CleanComponent` confirmation is open, restyled as an in-app dialog. */ @@ -62,11 +67,13 @@ export interface CleanReviewProps { } /** - * Clean's review phase: the Epoch Reviewer and the Live ERP as a coordinated - * pair beside the controls rail, with the always-available primer, auto-flag - * suggestions to review, and the save states. Everything fits the window with - * no page scroll. Pure props; the real reviewer and ERP panes are rendered - * unmodified. + * Clean's review phase. The Epoch Reviewer fills the working area — it is the + * thing students click, and its fixed 640×426 drawing box has almost exactly + * the aspect of the area, so scaled up it uses all of it. The Live ERP, the + * auto-flag suggestions and the save controls live in the rail; the primer is + * a rail bar whose steps open as a card near their pointer target. Everything + * fits the window with no page scroll. Pure props; the real reviewer and ERP + * panes are rendered unmodified inside `FitPane`. */ export default function CleanReview(props: CleanReviewProps) { const meta = props.epochArrays?.meta ?? null; @@ -78,17 +85,37 @@ export default function CleanReview(props: CleanReviewProps) { const rail = ( <> -
-
{dataset.subject}
-
{dataset.recording}
+
+
+ {dataset.subject} + · {dataset.recording} +
+
- - +
+ + + +
+
- More flags + More flags props.onThresholdChange(Number(e.target.value))} className="flex-1 accent-brand" /> - Fewer flags -
-
- Suggests trials whose peak-to-peak amplitude goes over{' '} - {props.autoFlagThreshold} µV. + Fewer
+ {props.suggestions.length > 0 && ( +
    + {props.suggestions.map((suggestion) => ( +
  • + +
  • + ))} +
+ )}
- + {total === 0 ? ( -
+
Counts show up once the trials are loaded.
) : ( -
-
- {props.rejected.size} of {total} trials left out - {props.rejected.size > 0 && - (acceptedCount > 0 && props.rejected.size > acceptedCount - ? ` — ${props.rejected.size - acceptedCount} clicked by you, ${acceptedCount} accepted from suggestions` - : acceptedCount > 0 - ? ' — all accepted from suggestions' - : ' — all clicked by you')} -
-
- {props.badChannels.size === 0 - ? 'No sensors flagged' - : `Sensor${props.badChannels.size === 1 ? '' : 's'} flagged: ${[ - ...props.badChannels, - ].join(', ')}`} -
-
{kept} trials will be averaged
+
+
+ {props.rejected.size} of {total} trials left out + {props.rejected.size > 0 && + (acceptedCount > 0 && props.rejected.size > acceptedCount + ? ` (${props.rejected.size - acceptedCount} by you, ${acceptedCount} suggested)` + : acceptedCount > 0 + ? ' (all suggested)' + : ' (all by you)')} +
+
+ {props.badChannels.size === 0 + ? 'No sensors flagged' + : `Sensor${props.badChannels.size === 1 ? '' : 's'} flagged: ${[ + ...props.badChannels, + ].join(', ')}`} +
+
{kept} trials will be averaged
)} - + {props.saveState === 'saving' && (
-
+
Saving your cleaned dataset…
-
+
Writing a cleaned copy. Your original recording is unchanged.
@@ -153,10 +205,10 @@ export default function CleanReview(props: CleanReviewProps) { )} {props.saveState === 'failed' && (
-
+
Couldn't save the cleaned dataset
-
+
Nothing was written — your original recording is unchanged.
@@ -171,10 +223,10 @@ export default function CleanReview(props: CleanReviewProps) { )} {props.saveState === 'saved' && (
-
+
✓ Cleaned dataset saved
-
+
Your original recording is unchanged. The cleaned copy is ready to use in Analyze.
@@ -184,7 +236,7 @@ export default function CleanReview(props: CleanReviewProps) {
)} {props.saveState === 'idle' && ( -
+
@@ -194,6 +246,7 @@ export default function CleanReview(props: CleanReviewProps) {
)} + props.onPrimerOpenChange(true)} /> ); @@ -229,92 +282,27 @@ export default function CleanReview(props: CleanReviewProps) { ); } else { body = ( - <> -
-
- -
-
- -
- {props.primerOpen && } -
- - {props.suggestions.length > 0 && ( -
-
- Suggested by auto-flag -
- Suggestions, not decisions — check each one before it goes into - your cleaned data. -
-
- {props.suggestions.length - acceptedCount} to review ·{' '} - {acceptedCount} accepted -
-
-
    - {props.suggestions.map((suggestion) => ( -
  • - - Trial {suggestion.index} - - - {suggestion.reason} - - {suggestion.accepted ? ( - <> - - ✓ Left out (from a suggestion) - - - - ) : ( - - )} -
  • - ))} -
-
+
+ + + + {props.primerOpen && } + {props.primerOpen && ( + props.onPrimerOpenChange(false)} + className="absolute right-[16px] top-[16px]" + /> )} - - - +
); } diff --git a/src/renderer/components/Clean/fixtures.ts b/src/renderer/components/Clean/fixtures.ts index d0fe75e0..01f67ed8 100644 --- a/src/renderer/components/Clean/fixtures.ts +++ b/src/renderer/components/Clean/fixtures.ts @@ -67,9 +67,10 @@ export const RAW_RECORDINGS: RawRecording[] = [ * Auto-flag output for the example epochs, in the shape Python's * `suggest_rejections` returns: one artifact suggestion per noisy trial. * Suggestions are never applied on their own — the student accepts them. + * Reasons are short enough for the rail's one-line rows. */ export const SUGGESTED_REJECTIONS: SuggestedRejection[] = [ - { index: 3, reason: 'Peak-to-peak 212 µV at AF7 — over the 100 µV threshold' }, - { index: 12, reason: 'Peak-to-peak 189 µV at TP9 — over the 100 µV threshold' }, - { index: 27, reason: 'Peak-to-peak 176 µV at AF7 — over the 100 µV threshold' }, + { index: 3, reason: '212 µV peak-to-peak at AF7' }, + { index: 12, reason: '189 µV peak-to-peak at TP9' }, + { index: 27, reason: '176 µV peak-to-peak at AF7' }, ]; From b7caa60639d32cfa6d13b11c316fa736667a8508 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Fri, 25 Sep 2026 17:30:48 -0400 Subject: [PATCH 3/5] Round 3: legible Live ERP (440x224) beside the suggestions; primer docked next to the canvas; rail untruncated with stacked save actions --- src/renderer/components/Clean/CleanPrimer.tsx | 233 +++++++++--------- src/renderer/components/Clean/CleanReview.tsx | 221 ++++++++++------- 2 files changed, 248 insertions(+), 206 deletions(-) diff --git a/src/renderer/components/Clean/CleanPrimer.tsx b/src/renderer/components/Clean/CleanPrimer.tsx index bee699a6..aa771006 100644 --- a/src/renderer/components/Clean/CleanPrimer.tsx +++ b/src/renderer/components/Clean/CleanPrimer.tsx @@ -10,6 +10,15 @@ export type PrimerStep = 1 | 2 | 3 | 4 | 5; export const CLEAN_DEFINITION = 'Cleaning means finding trials or sensors with movement or poor signal and excluding them before the responses are averaged. Your original recording stays unchanged.'; +/** The §8.2 loop in one line each, numbers written as text (global `li` reset). */ +export const CLEAN_LOOP = [ + 'Leave out noisy trials by clicking them.', + 'Flag a sensor that looks bad the whole way through.', + 'Check the auto-flag suggestions — they are only suggestions.', + 'Watch the Live ERP clean up as you go.', + 'Save the cleaned copy and continue to Analyze.', +]; + const STEPS: PrimerStep[] = [1, 2, 3, 4, 5]; const PRIMER_COPY: Record< @@ -28,12 +37,12 @@ const PRIMER_COPY: Record< }, 3: { title: 'Check the suggestions', - body: 'Auto-flag can point out trials that look noisy. They are only suggestions — you decide what to leave out.', - pointer: 'Suggested trials collect in the left panel', + body: 'Auto-flag can point out trials that look noisy. They collect under the Live ERP — they are only suggestions, so you decide.', + pointer: 'Suggestions collect under the Live ERP', }, 4: { title: 'Watch the Live ERP', - body: 'The Live ERP in the left panel updates as you leave trials out. As the noisy trials go, the waves get cleaner.', + body: 'The Live ERP under these trials updates as you leave trials out. As the noisy trials go, the waves get cleaner.', pointer: 'This average updates as you clean', }, 5: { @@ -43,21 +52,23 @@ const PRIMER_COPY: Record< }, }; -/** Where each step points over the review area (`left`/`top` in % of it). */ +/** Where each step points over the Epochs panel (`left`/`top` in % of it). */ const POINTER_PLACEMENT: Record< PrimerStep, { left: string; top: string; arrow: string } > = { - 1: { left: '26%', top: '38%', arrow: '↓' }, - 2: { left: '1%', top: '52%', arrow: '→' }, - 3: { left: '0%', top: '64%', arrow: '←' }, - 4: { left: '0%', top: '30%', arrow: '←' }, - 5: { left: '0%', top: '86%', arrow: '←' }, + 1: { left: '30%', top: '34%', arrow: '↓' }, + 2: { left: '0%', top: '56%', arrow: '→' }, + 3: { left: '58%', top: '90%', arrow: '↓' }, + 4: { left: '18%', top: '90%', arrow: '↓' }, + 5: { left: '0%', top: '30%', arrow: '←' }, }; /** - * A callout chip over the review area showing which part of the screen the - * current primer step is about. Decorative — the step card carries the words. + * A callout chip over the Epochs panel showing which part of the screen the + * current primer step is about; it may overlap the canvas edge but never + * covers a pointer target or the Prev/Next controls. Decorative — the step + * panel carries the words. */ export function PrimerPointer({ step }: { step: PrimerStep }) { const { left, top, arrow } = POINTER_PLACEMENT[step]; @@ -74,55 +85,23 @@ export function PrimerPointer({ step }: { step: PrimerStep }) { ); } -/** The always-available collapsed primer: one line and a way back into the steps. */ -export function CleanPrimerBar({ - onOpen, - className, -}: { - onOpen(): void; - className?: string; -}) { - return ( -
-
-
How cleaning works
-
- Click a noisy trial to leave it out — click again to bring it back. -
-
- -
- ); -} - /** - * The open primer: one step at a time in the walkthrough idiom from Analyze, - * docked as a card near its pointer target over the review area (it covers no - * control and no target). No first-use flag — it can always be reopened from - * the rail bar. + * The always-available cleaning primer, docked beside the Epochs panel so it + * never covers the reviewer. Collapsed it teaches the whole loop (§8.1 + * definition plus the §8.2 steps); expanded it walks through the steps one at + * a time in the walkthrough idiom from Analyze. No first-use flag. */ -export function CleanPrimerCard({ +export function CleanPrimerPanel({ + open, step, onStepChange, - onClose, + onOpenChange, className, }: { + open: boolean; step: PrimerStep; onStepChange(step: PrimerStep): void; - onClose(): void; + onOpenChange(open: boolean): void; className?: string; }) { const heading = useRef(null); @@ -132,74 +111,102 @@ export function CleanPrimerCard({ mounted.current = true; }, [step]); - const copy = PRIMER_COPY[step]; return (
-
- - How cleaning works · Step {step} of 5 - - - {STEPS.map((s) => ( - - ))} - - -
-

- {copy.title} -

-
{copy.body}
-
- {CLEAN_DEFINITION} -
-
- - -
+ {!open ? ( + <> +
How cleaning works
+
+ {CLEAN_DEFINITION} +
+
    + {CLEAN_LOOP.map((line, i) => ( +
  1. + {i + 1}.{' '} + {line} +
  2. + ))} +
+ + + ) : ( + <> +
+ + How cleaning works · Step {step} of 5 + + + {STEPS.map((s) => ( + + ))} + + +
+

+ {PRIMER_COPY[step].title} +

+
+ {PRIMER_COPY[step].body} +
+
+ {CLEAN_DEFINITION} +
+
+ + +
+ + )}
); } diff --git a/src/renderer/components/Clean/CleanReview.tsx b/src/renderer/components/Clean/CleanReview.tsx index 5cdb4550..d19faad8 100644 --- a/src/renderer/components/Clean/CleanReview.tsx +++ b/src/renderer/components/Clean/CleanReview.tsx @@ -8,8 +8,7 @@ import { Spinner } from '../ui/spinner'; import { RailSection, ResultStatus, railLabel } from '../Analyze/AnalyzeParts'; import { CleanLayout, ConfirmDialog, FitPane } from './CleanParts'; import { - CleanPrimerBar, - CleanPrimerCard, + CleanPrimerPanel, PrimerPointer, PrimerStep, } from './CleanPrimer'; @@ -36,7 +35,7 @@ export interface CleanReviewProps { dataset: { subject: string; recording: string }; /** Epochs as `pyodide.epochArrays` holds them; null while loading. */ epochArrays: EpochArrays | null; - /** `loading` and `no-epochs` replace the pair; the rail stays usable. */ + /** `loading` and `no-epochs` replace the review area; the rail stays usable. */ status: 'ready' | 'loading' | 'no-epochs'; codeToLabel: Record; /** ABSOLUTE epoch indices left out, including accepted suggestions. */ @@ -67,13 +66,12 @@ export interface CleanReviewProps { } /** - * Clean's review phase. The Epoch Reviewer fills the working area — it is the - * thing students click, and its fixed 640×426 drawing box has almost exactly - * the aspect of the area, so scaled up it uses all of it. The Live ERP, the - * auto-flag suggestions and the save controls live in the rail; the primer is - * a rail bar whose steps open as a card near their pointer target. Everything - * fits the window with no page scroll. Pure props; the real reviewer and ERP - * panes are rendered unmodified inside `FitPane`. + * Clean's review phase. The Epoch Reviewer fills the top of the working area + * — it is the thing students click — with the primer docked beside it (never + * over it) and a bottom row holding a legible Live ERP next to the auto-flag + * suggestions it feeds. Everything fits the window with no page scroll. Pure + * props; the real reviewer and ERP panes are rendered unmodified inside + * `FitPane`. */ export default function CleanReview(props: CleanReviewProps) { const meta = props.epochArrays?.meta ?? null; @@ -85,35 +83,15 @@ export default function CleanReview(props: CleanReviewProps) { const rail = ( <> -
-
- {dataset.subject} - · {dataset.recording} -
- +
+
{dataset.subject}
+
{dataset.recording}
+ -
- - - -
- +
More flags Fewer
+
+ Suggests trials whose peak-to-peak amplitude goes over{' '} + {props.autoFlagThreshold} µV. +
- {props.suggestions.length > 0 && ( -
    - {props.suggestions.map((suggestion) => ( -
  • - -
  • - ))} -
- )}
- + {total === 0 ? (
Counts show up once the trials are loaded.
) : ( -
+
{props.rejected.size} of {total} trials left out {props.rejected.size > 0 && @@ -189,7 +142,7 @@ export default function CleanReview(props: CleanReviewProps) {
)} - + {props.saveState === 'saving' && (
@@ -211,7 +164,7 @@ export default function CleanReview(props: CleanReviewProps) {
Nothing was written — your original recording is unchanged.
-
+
@@ -236,17 +189,16 @@ export default function CleanReview(props: CleanReviewProps) {
)} {props.saveState === 'idle' && ( -
- +
+
)} - props.onPrimerOpenChange(true)} /> ); @@ -282,27 +234,110 @@ export default function CleanReview(props: CleanReviewProps) { ); } else { body = ( -
- - - - {props.primerOpen && } - {props.primerOpen && ( - +
+
+ + + + {props.primerOpen && } +
+ props.onPrimerOpenChange(false)} - className="absolute right-[16px] top-[16px]" + onOpenChange={props.onPrimerOpenChange} + className="w-[300px] flex-none" /> - )} -
+
+
+
+ + + +
+
+
+ Suggested by auto-flag +
+ Suggestions, not decisions — you decide. +
+
+ {props.suggestions.length === 0 ? ( +
+ No suggestions yet. Suggest noisy trials in the left panel and + they will collect here for you to review — accepting one leaves + that trial out, Restore puts it back. +
+ ) : ( +
    + {props.suggestions.map((suggestion) => ( +
  • + + Trial {suggestion.index} + + + {suggestion.reason} + + {suggestion.accepted ? ( + <> + + ✓ Left out (from a suggestion) + + + + ) : ( + + )} +
  • + ))} +
+ )} +
+
+ ); } From 2f22df267a001d2043b99554b7af1f7085bbd28b Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Fri, 25 Sep 2026 18:38:35 -0400 Subject: [PATCH 4/5] Round 4: suggestions card renders only when relevant; Live ERP takes the full bottom row otherwise --- src/renderer/components/Clean/CleanReview.tsx | 38 +++++++++---------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/src/renderer/components/Clean/CleanReview.tsx b/src/renderer/components/Clean/CleanReview.tsx index d19faad8..bf0492ec 100644 --- a/src/renderer/components/Clean/CleanReview.tsx +++ b/src/renderer/components/Clean/CleanReview.tsx @@ -260,12 +260,16 @@ export default function CleanReview(props: CleanReviewProps) {
0 + ? 'flex w-[440px] flex-none flex-col rounded-lg border border-gray-200 bg-white px-[12px] pb-[8px] pt-[6px]' + : 'flex flex-1 flex-col rounded-lg border border-gray-200 bg-white px-[12px] pb-[8px] pt-[6px]' + } >
-
-
- Suggested by auto-flag -
- Suggestions, not decisions — you decide. -
-
- {props.suggestions.length === 0 ? ( -
- No suggestions yet. Suggest noisy trials in the left panel and - they will collect here for you to review — accepting one leaves - that trial out, Restore puts it back. + {props.suggestions.length > 0 && ( +
+
+ Suggested by auto-flag +
+ Suggestions, not decisions — you decide. +
- ) : (
    {props.suggestions.map((suggestion) => (
  • ))}
- )} -
+
+ )}
); From 802cae98e13c64c55f44a870de8a31b442926747 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 27 Sep 2026 15:23:11 -0400 Subject: [PATCH 5/5] docs(todos): cleaning UXR playtest after WS6 integration --- TODOS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/TODOS.md b/TODOS.md index cff65cfc..d91576f6 100644 --- a/TODOS.md +++ b/TODOS.md @@ -36,6 +36,7 @@ Deferred and in-flight work. Keep this current — when something ships, delete - 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) +- [ ] **Cleaning UXR playtest** (added 2026-09-25, after the WS6 Clean design #280) — a naive user, with no facilitator help, cleans one real recording in the redesigned Clean screen. Can they find the job (leave out noisy trials, flag a bad sensor, review auto-flag suggestions, watch the Live ERP change, save), and do they understand why cleaning comes before Analyze? Run after WS6 integration, before WS8's full second playtest. - [ ] **Participant screens for imported jsPsych/lab.js studies** (deferred 2026-09-25): wrap author timelines with BrainWaves instruction/transition/end screens. - [ ] **Import stimuli into the workspace?** — today custom experiments load images/sounds straight from wherever the student keeps them (Documents/Downloads) via the `bwfile://` allowlist; moving/renaming that folder silently breaks the study, and a workspace can't be zipped up and shared as a self-contained bundle. Alternative: copy stimuli into `BrainWaves_Workspaces//stimuli/<condition>/` at selection time (single pre-authorized root, portable study bundles; costs disk duplication + stale copies if the source folder is edited later). **Contingent on user testing** — students may actually prefer managing their own folders in Documents/Downloads, since workspace folders are semi-private territory full of mysterious things like `appState.json`. Decide after watching a class use the current flow. - [ ] **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.