Skip to content

design(WS3): Explore EEG Storybook pass - #282

Merged
jdpigeon merged 7 commits into
mainfrom
design/ws3-explore
Sep 28, 2026
Merged

jdpigeon merged 7 commits into
mainfrom
design/ws3-explore

Conversation

@jdpigeon

@jdpigeon jdpigeon commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Design pass for Workstream 3 (Explore EEG), following docs/uxr/2026-09-25-ws3-design-brief.md (copied into this branch) and plan §5, §10.2 and §11 WS3. This is Storybook only; runtime behavior does not change. EEGExplorationComponent.tsx, ExploreLessonFlow.tsx, ViewerComponent, EEGViewer.js, the explore constants and exploreSignal.ts are untouched. Engineering integrates after product approves.

Round 2 applies the review decisions: a lighter Ready state, the real head diagram in place of the text sensor list, device legend, quiz-style prediction options with immediate feedback, the detection-time tick fallback, a segment-based review plot, the rainbow trace colors, and copy fixes throughout.

Layout decision

  • Connected surface (ExploreSurface): the overall status sits above the plot. The left column (320px) renders the existing signal-quality design unmodified: SignalQualityIndicatorComponent at its real height 250 with hover/click, plus ExploreSensorCard, sharing hoveredChannel / onHoveredChannelChange exactly as EEGExplorationComponent.tsx:106-121 wires them (hover state is story-local useState). Both are fed by the fixture observable/sample. The right column holds the live plot and the two lesson cards. Nothing scrolls.
  • Lessons (BlinkLessonView, EyesClosedView): instruction and expected action sit in a 340px step panel beside the plot (plan §5.2), with progress pips and Exit/Back/Next local to the lesson — Analyze's walkthrough idiom turned vertical. The copy region is overflow-y-auto only as a safety net; it fits at both sizes without scrolling (numbers below).
  • One filled-teal action per surface. Lessons: Next/Finish filled, Back is outline-brand. Landing: Connect a headset. Lesson picker: two equal outlined Start buttons (reviewed decision).
  • Colors: teal = action, gold = location/progress. Signal colors appear only in signal status. During the noise demonstration traces use stable colors (§5.2).

The live plot is a webview — fixture stand-in and how it maps

ViewerComponent/EEGViewer render inside an Electron <webview> and cannot run in Storybook, so FixturePlot (React SVG) stands in. It consumes exactly the real viewer's data contract — EEGSnapshot and PlotAnnotation[] from shared/eegVizTypes, the same props ViewerComponent takes (snapshot, annotations, amplitudeScale) — so integration swaps one component for the other:

  • Same geometry: margins 20/10/30/44, channels stacked in equal bands, each trace mean-centered with a symmetric µV half-range, 1.75px strokes with the viewer's 2× downsampling.
  • Same axes: channel labels on the left (axisLeft style), time running left→right with the offset axis and whole-second ticks (-5s … 0s), exactly buildTimeAxis.
  • Same annotations: TONE_STYLES copied verbatim for the blink bands (gold, dashed edges, start pill ending at the band's left edge, pills skipped under 40px width). Two deliberate deltas: end pills are drawn just inside the plot's bottom edge instead of at plotHeight + 6 (where the real viewer collides with the tick labels), and markerStyle="tick" adds the fallback detection-time bar described below. Integration should adopt both.
  • Differences: pill widths are estimated from glyph count (the real viewer measures getBBox), and the stand-in scales uniformly into its card (viewBox) instead of re-laying-out to the exact box.

The traces are a synthetic fixture (seeded PRNG in fixtures.ts): 10 Hz / 5 Hz background rhythms, sensor noise, blink humps weighted to AF7/AF8, and a steady back-of-head rhythm weighted to TP9/TP10 while the eyes are closed. Not recorded data; story descriptions say "example", UI copy reads "your signal", same convention as #277.

Blink marker viability — what the detector actually outputs

ExploreSession.detect() (utils/eeg/exploreSignal.ts) emits BlinkEvent with both onset and offset: startTime is the threshold crossing on the bilateral low-passed channel pair, endTime is the return-to-baseline crossing (capped at 400 ms), and accepted events are gated to 100–400 ms duration with a 250 ms refractory. So the detector supplies a true interval, not just an instant.

  • Integration should use the band (markerStyle="band", the default): the interval maps directly onto the annotation band the real viewer already draws.
  • The tick marker is the fallback (markerStyle="tick", story BlinkTickMarker): a 3px gold bar at the detection instant, for sources that can only report one. Both styles are fixture-side; the real viewer keeps drawing bands.

Review segments — back-of-envelope math

The review plot shows an eyes-open segment above an eyes-closed segment of equal length (3 s), both on one ±50 µV scale. Typical Muse amplitudes: eyes open posterior ≈ 5–15 µV peak-to-peak; eyes closed the posterior rhythm runs ≈ 15–45 µV ptp (2–3× eyes open). Rendered geometry measured in Chromium:

quantity 1366×768 1280×720
rendered segment strip 920×252 px 834×229 px
per channel row (2 rows/strip) 99.5 px 90.2 px
µV per px (±50 µV range) 1.01 1.11
px per second (3 s segment) 287 261
px per 10 Hz cycle (3 s) 28.7 26.1
px per 10 Hz cycle (10 s segment) 8.6 7.8

At ~1 µV/px a 20 µV rhythm is ~20 px tall (visible even at 10 µV ≈ 9 px), and with 3 s segments a 10 Hz cycle spans ~26–29 px — clearly an oscillation at the viewer's 1.75 px stroke, with real EEG, not just the fixture. A 10 s segment compresses the cycle to ~8 px (mush at this stroke); 2 s would give ~39–43 px. Chose 3 s: enough cycles to read as a rhythm, wide enough to feel like a moment of the interval. The fixture segments carry the same amplitudes (eyes open ~17 µV ptp, eyes closed ~35–45 µV ptp).

Trace colors: d3.interpolateRainbow, keyed by channel

utils/eeg/traceColors.ts exports channelColor(name, allChannels): the rainbow sampled at allChannels.indexOf(name) / allChannels.length. The scale is built from the device's full channel list, so a channel's color never changes when a view down-selects — AF7/AF8 in the two-channel blink views keep the exact colors they have in the four-channel plot, and the review strips and the Example keep TP9/TP10's. Every plot passes the full device list.

Legibility on white was checked (contrast: purple 7.05:1, red 2.99:1, teal 2.10:1, yellow-green 1.36:1); the user signed off on the contrast, so the sampling stays exactly index / n.

Stories

Domain/Explore, all in the real AppShell with no workspace (Explore is workspace-free):

  • Entry: Disconnected (what Explore is and one Connect a headset; the once-connected list is removed) · Waiting (connected, no data: explicit waiting state, lessons disabled until signal arrives)
  • QualitySummary: Ready (a light status row — the card earns its weight in yellow/red) · Settling · AdjustSensors · NoSignal (headset off/disconnected + the fix). Head diagram at left in all of them.
  • Lesson choice & definition: LessonPicker · NoiseDefinition (plan §5.1 wording before the student judges anything; "better contact means less static"; no fixed warm-up promise)
  • Blink (§5.3): BlinkStep1 (blink once, find the marked response) · BlinkStep2 (predict) · BlinkStep2PredictionAnswered (either option reveals the expected answer "a big, slow hump"; nothing is recorded) · BlinkStep3 ("Now blink several times in a row!") · BlinkStep4 (blinking vs quiet interval, same scale, with a Plot range control to play with) · BlinkTickMarker (the detection-time fallback marker) · BlinkNotDetected (lesson continues gracefully)
  • Stable colors (§5.2): NoiseLessonStableColors — all four sensors during the demonstration in stable rainbow colors
  • Eyes-closed (§5.4): EyesClosedIntro (headline "Let's look at how your brain signal changes when you close your eyes"; the chimes explanation; one-line Begin; Example mini-plot) · EyesClosedCountdown (visible 3–2–1, reduced-motion safe) · EyesClosedInterval · EyesClosedEndCue ("Open your eyes. Let's look at your brainwaves.") · EyesClosedReview (equal segments on one scale + the measured comparison)
  • Results: AlphaResult / AlphaNoEffect — plain-language framing ("the seeing part of your brain… gets louder"), the measured comparison phrased without technical terms, the Example reference as a small line plot in a dashed card, TP9/TP10 named as Muse's look at the back of the head. NoEffect is a valid, encouraging outcome.
  • Errors: StreamError (the stream stopped; exactly one problem and one action) · UnsupportedChannels (this headset reports no AF8 — the fixture agrees — and the message says what to do)

Measurements

All 25 stories × 2 sizes verified after the round-4 fixes (50 PNGs in /tmp/ws3-design/{1366x768,1280x720}/, raw numbers in /tmp/ws3-design/measurements.json):

  • Scroll, measured directly (document.scrollingElement.scrollHeight vs clientHeight): 768/768 at 1366×768 and 720/720 at 1280×720 on every story. The app's content container (flex-1 overflow-y-auto): 704/704 and 656/656 on every story — zero overflow, zero tolerance (an earlier check with a +2px tolerance missed a real 2px overflow on StreamError; that tolerance is gone).
  • Document scroll (x/y): 0 everywhere. Inner scrollers: none. Horizontal overflow: none. Console/page errors: 0. One AppShell <header>, no bare <main>.
  • Panel clipping check (last text bottom / controls top, copy overflow): clean on every lesson story at both sizes. Worst cases at 1280×720: AlphaResult and AlphaNoEffect 609/644 (35px clearance), BlinkNotDetected 571/644, EyesClosedIntro 583/644. At 1366×768 the tightest is 609/692 (83px).
  • Marker/axis clearance: blink-band pills sit 360–397 px clear of the tick labels; the review plot has no pills at all any more (segments), and the lifted end-pill placement stays in FixturePlot for any future end labels.
  • Rule A (plot + current instruction + lesson controls visible, no page scroll): lesson plot/panel bottoms 752 (1366×768) and 704 (1280×720), lowest control 737 / 689 — inside both viewports. Surface plot bottoms 641 / 593.
  • Interaction check (one per size): hovering the AF7 electrode shows the sensor card's location help — tooltip first line AF7 · Left forehead, present at both 1366×768 and 1280×720.

Review agenda

  • Entry: Disconnected · Waiting
  • Signal quality: QualitySummaryReady · Settling · AdjustSensors · NoSignal
  • Lessons: NoiseDefinition · LessonPicker · BlinkStep1 · BlinkStep2 · BlinkStep2PredictionAnswered · BlinkStep3 · BlinkStep4 · BlinkTickMarker · BlinkNotDetected · NoiseLessonStableColors
  • Eyes-closed: EyesClosedIntro · EyesClosedCountdown · EyesClosedInterval · EyesClosedEndCue · EyesClosedReview · AlphaResult · AlphaNoEffect
  • Errors: StreamError · UnsupportedChannels

Open questions

  • Rainbow legibility (numbers above): keep i / n as ordered, or adopt (i + 0.5) / n / darken the yellow-green samples?
  • Blink step 4 range control (±150 µV / ±50 µV) implements the "playing with the plot range" idea. Keep both ranges, or add zoom in/out like the real viewer?
  • Prediction options record nothing (per decision). If a later round wants "your guess vs the answer", the quiz already has the state seam.
  • The landing's third bullet still says "your alpha rhythm" (not on the round-2 change list). Align it with the plain-language result copy in a later pass?

Constraints not met / notes for integration

  • The webview itself is out of scope: FixturePlot is a stand-in (mapping above). Integration swaps it for ViewerComponent and keeps the surrounding cards.
  • The prediction quiz and countdown are visual states only; no detection math, audio or state wiring is included (per the no-runtime-wiring gate).
  • traceColors.ts imports interpolateRainbow from d3 (installed); d3 ships no types and @types/d3 is not installed — the import is untyped under noImplicitAny: false. Adding @types/d3 later would type it (needs a package.json change, out of scope here).
  • Annotation pill widths are glyph estimates, not measured. The fixture's blink label reads "blink · from your eyes, not your brain" and blink step 1's copy says "from your eyes moving, not your brain thinking"; integration should adopt the same wording — ExploreLessonFlow's live annotation still says "eye muscle".
  • app.global.css gained only new scoped classes in round 1 (.explore-countdown-*, reduced-motion guarded). Round 2 touched no CSS. No existing shared class changed. No new dependencies; package.json/lockfile untouched.
  • SignalPrep's checklist is not duplicated (brief constraint): the lessons follow it.
  • Trace palette integration note: the palette is set from the actual channels in the data (deviceInfo.channels), and each channel always keeps its color.

Verification

@jdpigeon
jdpigeon merged commit 138391b into main Sep 28, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant