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//` 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.
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/components/Clean/Clean.stories.tsx b/src/renderer/components/Clean/Clean.stories.tsx
new file mode 100644
index 00000000..39f21f15
--- /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..dac050ce
--- /dev/null
+++ b/src/renderer/components/Clean/CleanDatasetSelect.tsx
@@ -0,0 +1,195 @@
+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[];
+ /** 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;
+ /** 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 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.
+ */
+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.find((r) => r.key === selected) ?? null;
+
+ const rail = (
+ <>
+
+