Skip to content

Explore: integrate the approved WS3 design into the running app - #286

Merged
jdpigeon merged 10 commits into
mainfrom
feat/ws3-explore-integration
Sep 28, 2026
Merged

jdpigeon merged 10 commits into
mainfrom
feat/ws3-explore-integration

Conversation

@jdpigeon

@jdpigeon jdpigeon commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Replaces the running /explore screen with the approved Storybook design (#282). The landing, the connected surface (quality summary, head diagram and sensor card with shared hover, live plot legend deviceInfo.name · samplingRate Hz, three lesson cards), the cleaner-signal tips, the blink lesson steps 0–4, the separate eyes-closed activity, and the stream-stopped and unsupported-channel banners now all render the design's views with live data. Disconnect moves to the shell chip's setup dialog.

Decisions followed

  1. Live plots use the real viewer, frozen plots use the SVG. FixturePlot is renamed to SnapshotPlot. ExploreSurface, BlinkLessonView and EyesClosedView (and the new CleanSignalView) take a livePlot slot. The runtime passes ViewerComponent; stories pass SnapshotPlot. The blink strips, the review segments and the example still render SnapshotPlot. Waiting is quality === 'waiting'. The runner's local FrozenStrip is deleted.
  2. Trace colors. Lessons use channelColor(name, deviceInfo.channels) (channelColors helper in traceColors.ts). The surface and the tips lesson keep the quality colors. ViewerComponent.channelColors is forwarded through initGraph.channelColours and updateChannels (whose payload is now {channels, channelColours?}). When it is set, EEGViewer.updateData does not recolor.
  3. EEGViewer. The end pill sits at height - LABEL_HEIGHT - 4. Fixed colors are honored. A live amplitudeScale rescales in place: updateSnapshot(null, scale) on a live viewer redraws without clearing the data, and ViewerComponent resends it when the scale changes. There is one new test for each of the three.
  4. Three cards. EXPLORE_LESSONS gains "Eyes-closed activity · about 1 minute". LessonPicker.onStart(id). The picker uses a 3-column row. CLEAN_SIGNAL_LESSON is untouched.
  5. Tips. CleanSignalView uses LessonStepPanel with a new unit prop, so the label reads "How do I get a cleaner signal? · Tip n of 3", with pips, Back/Next/Exit and "Finish lesson". It sits beside the live plot (quality colors) and the head diagram. It has a CleanSignalTips story at 1366×768 and a CleanSignalTips720 story.
  6. Blink steps.
    • Bands come from status().blinkEvents, labelled "blink · from your eyes, not your brain".
    • Steps 1–4 are down-selected to AF7/AF8 when supported.
    • notDetected shows after 8 s on steps 1–3 with no blink since the step began.
    • The quiz stays local. The arrow-key, Escape and viewer:navigate handling is unchanged.
  7. Eyes-closed.
    • Begin: applies the freshness guard (>1500 ms), then calls LessonAudio.preview() inside the click.
    • Countdown: one component timer for 3→2→1. It is cancelled on Exit/unmount.
    • Interval and end: start(onStart, onEnd) sets cue through sampleTimeAt. The end phase shows "Open your eyes".
    • Review: freezes once latestTime ≥ cue.endTime. Eyes-open is [start−3000, start] and eyes-closed is the middle 3 s of the interval. It uses the posterior channels present, with rhythmRatio = alphaRatio(start, end).
    • Example and Back: showExample is on for intro and review. Back re-runs from intro.
  8. Quality. Explore/quality.ts holds the copy, QUALITY_STATE_TONE, qualityCopy (adjust names the BAD sensors via Intl.ListFormat) and summarizeQuality. The fixtures keep only the per-state sensors (QUALITY_SENSORS). There is a precedence test.
  9. Banners. "Stream stopped" shows on the stream's error or complete, and its action runs DisconnectFromDevice then openHeadsetSetup(). "Unsupported channels" names the missing frontal channels, and its action is "Switch headset" → openHeadsetSetup().
  10. Disconnect. The Connected screen of HeadsetSetup gets an outline "Disconnect" beside "Check my signal". It dispatches DisconnectFromDevice and closes the dialog. The Connected story gets onDisconnect. Explore's own button is gone.
  11. Left as-is: LiveSignalPrep, ExploreSession (only the FRONTAL/POSTERIOR constants are now exported), LessonAudio, the shareReplay bufferSize, SignalQualityIndicatorComponent and ExploreSensorCard. ExploreLessonFlow.tsx was rewritten in place, and NOISE_LESSON is deleted.

Later user decision: captures are labelled as paused.

  • The blink strips read "your recent signal, paused · …".
  • The review card caption is "your eyes-closed interval, paused · same scale", and the card has no live dot (PlotCard paused).
  • Step 4's body text is now "Five paused seconds…".
  • No UI copy says "frozen". The Example card keeps its label.
  • The stories use the same components, so they match.

Deviations (and why)

  • Step 4 range control. The design only shows the range control once a comparison exists, which conflicts with "±150/±50 drives the live amplitudeScale". Step 4 now shows the control over the live frontal plot while the student does the step's own action, "sit still for 5 seconds". After 5 s of data on the step, session.comparison() produces the strips at the chosen range. If the comparison is null, the step stays live and "Finish lesson" still works; that is the "skip" (confirmed with Main).
  • Head diagram input. ExploreSurface and CleanSignalView take head (the live stream) and a stable channels list, instead of the fixture-derived of(qualitySample(sensors)). That derivation fed the sensor card fake metrics, and with a per-render channels array it reset every electrode to grey on each epoch (caught in the playtest; fixed in e798e23).
  • Other props added to the design views: legend, deviceChannels, segments, error, blinkCount and onRangeChange. These replace the fixture constants, which were PLOT_LEGEND, EXPLORE_CHANNELS colors, the fixture review segments and the annotation count. markerStyle moved to the stories' SnapshotPlot, since the runtime only uses bands.
  • Tips intro step dropped. Collect's CLEAN_SIGNAL_LESSON[0] intro ("Improve the signal quality") is not shown; Explore shows the three tips only (decision 5 says Tip n of 3).
  • Unsupported-channels banner body. It reads "…the blink steps will show your signal without blink marks…" instead of "watch AF7 only", because the runtime keeps every channel when the frontal pair is incomplete. The banner is gated on !supported && missing.length, so a slow AF7/AF8 stream never gets an empty title.
  • Noise lesson meta. It stays "4 steps · about 2 minutes", since the design is Step n of 4.

Verification

  • npm run typecheck → > tsc --noEmit with no errors.
  • npx vitest run src/renderer/components src/renderer/utils/eeg → Test Files 36 passed (36) / Tests 168 passed (168). Earlier runs while Electron and Storybook were loading the machine hit 5 s timeouts in unrelated Design/Clean/HeadsetSetup tests; they pass on an idle run.
  • npx eslint <changed files> → 0 errors, 11 warnings (pre-existing warning classes).
  • New tests:
    • EEGViewer.test.ts: end pill inside the plot box; fixed colors survive updateData; a live amplitudeScale changes the y span 4× without clearing. They fail on the old viewer.
    • Explore/quality.test.ts: precedence and adjust copy.
  • Storybook (own instance, :6117, now stopped): all 27 Domain/Explore stories plus HeadsetSetup/Connected render, with document overflow 0 at 1366×768 and 1280×720. The picker, the tips (TIP 1 OF 3), the adjust copy ("Adjust AF7 and TP10"), step 4 and the review were checked in the DOM.
  • Electron playtest. Dev app, fixture headset, throwaway profile /tmp/ws3-integration/profile, DevTools closed, window sized with setContentSize, viewport overlay off. Results at both sizes:
    • Disconnected → Connect → Waiting (seen live, ~2–3 s) → surface ("Sensors are still settling"), legend "Fixture (Synthetic EEG) · 256 Hz".
    • Hovering AF7 (real mouse hover) sets aria-pressed, and the sensor card help opens ("AF7 · LEFT FOREHEAD …").
    • Three lesson cards. Tips 1–3 labelled TIP n OF 3.
    • Noise steps 0–4: step 0 shows all four channels, steps 1–4 show AF7,AF8. Step 4 range ±150 → ±50 changes the live trace's y span from 32→97 px (1366) and 28→83 px (1280).
    • Eyes-closed at 1280: Begin → countdown (3-2-1 seen) → "Close your eyes" → "Open your eyes" → review. The instrumented OscillatorNode.start recorded 660 (preview), 660 (start), 880, 880 Hz.
    • Chip → Disconnect → landing ("No headset").
    • Console errors / exceptions: 0 / 0 at both sizes. document.scrollingElement overflow: 0 on every state at both sizes. The shell's inner scroller showed 26 px only while the AF7 help popover was open at 1280×720.
    • Screenshots: /tmp/ws3-integration/*-1366.png and *-1280.png (01-disconnected, 02-waiting, 03-surface, 04-hover-af7, 05-tip1..3, 06-noise-step0..4, 06-noise-step4-live, 07-eyes-intro/countdown/interval/end/review, 08-chip-connected-dialog, 09-after-disconnect).

Quality states: Waiting and Settling were seen live. Ready, Adjust and No signal are covered only by the summarizeQuality test and the stories; the fixture replay never produced them.

Known ceilings

  • The playtest window was throttled, and the flags could not stop it. It was launched with --disable-backgrounding-occluded-windows --disable-renderer-backgrounding --disable-background-timer-throttling (confirmed in ps), plus setBackgroundThrottling(false), prevent-app-suspension and a temporary NSAppSleepDisabled (removed afterwards). Once the window lost the front, macOS still slowed it to ~20 timer ticks/s and ~10 fps rAF, so the fixture (one setInterval tick per sample) streamed at roughly 1/12 of real time. These timings could not be trusted:

    • the 1366 eyes-closed run tripped the freshness guard ("Wait for a live signal…");
    • the 1280 review came out "not enough data" / no segments;
    • step 4 never froze a comparison.

    Review segments with a ratio, and the step-4 strips, were not observed live; they are covered by the stories.

  • The fixture replay has no detected blinks: 0 bands on steps 1–4 at both sizes, so blink bands were not observed live. The band code path is EEGViewer annotations, which are already tested.

  • Other limits:

    • There is no stall watchdog (decision 9). The stream-stopped banner only has a natural trigger on error or complete, and the fixture never produces either.
    • Step 1's caption says "watching the frontal sensors (AF7, AF8)" even when the headset lacks them. The unsupported banner covers that case.
    • The review can briefly show "Not enough continuous data" if "See your result" is pressed before the stream passes the cue's end.

Review round (fix commit)

  • The stream-stopped banner now renders above both the surface and a running lesson. EEGExplorationComponent.test.tsx checks that a stream completing mid-lesson shows the alert with "Reconnect headset"; the test fails without the fix.
  • Step 4 shows only its prompt ("Now sit still, eyes open, for 5 seconds.") while the plot is live. The "Five paused seconds…" copy appears once the strips are shown.
  • Removed:
    • the ExploreStatus.baselineStable and FrozenComparison.sharedScale public fields (the internal state and validation stay);
    • the CleanSignalViewProps export;
    • stale comments;
    • the obsolete plotHeight + 6 learning.

…side plot

ViewerComponent gains channelColors, forwarded via initGraph/updateChannels;
EEGViewer keeps fixed colors instead of recoloring by quality, rescales a
live view without clearing it, and draws the end pill inside the plot box.
…copy

FixturePlot becomes SnapshotPlot (it now draws real frozen snapshots too).
ExploreSurface, BlinkLessonView and EyesClosedView take a livePlot slot, a
legend and the device channel list; review segments and step 4's range are
props. Quality copy and summarizeQuality move to Explore/quality.ts, adjust
names the BAD sensors. LessonPicker starts a lesson by id. Adds
CleanSignalView and its story at 1366x768 and 1280x720.
EXPLORE_LESSONS gains 'Eyes-closed activity · about 1 minute'; the noise
lesson is the four blink steps. The picker lays the three cards out in one row.
ExploreLessonFlow now runs the cleaner-signal tips (tip n of 3), the blink
steps 0-4 and the separate eyes-closed activity in CleanSignalView,
BlinkLessonView and EyesClosedView, with one live ViewerComponent per lesson
in stable channel colors (quality colors for the tips). Step 4's range drives
the live scale until five still seconds freeze the comparison. Eyes-closed:
Begin unlocks audio and checks stream freshness, a 3-2-1 component timer,
the chime-bounded interval, then 3 s eyes-open/eyes-closed review segments
and the posterior rhythm ratio. NOISE_LESSON and the local FrozenStrip go.
Disconnected → ExploreDisconnected; connected → ExploreSurface with the live
ViewerComponent (quality colors), the legend from deviceInfo, the quality
summary from summarizeQuality, and ErrorBanners for a stopped stream
(error or complete → Reconnect headset) and missing frontal channels
(Switch headset). Explore's own Disconnect button goes; the shell chip's
setup dialog owns it next. LiveSignalPrep is unchanged.
The shell device chip opens HeadsetSetupDialog; its Connected screen now has
a secondary Disconnect beside Check my signal, which dispatches
DisconnectFromDevice and closes the dialog. The Connected story shows it.
The blink comparison strips and the eyes-closed review caption now read
"your recent signal, paused" / "your eyes-closed interval, paused" and the
review card drops the live dot (PlotCard paused). No UI copy says frozen.
Found in the Electron playtest: feeding the head diagram of(sample) with a
per-render channels array reset every electrode to grey on each epoch.
ExploreSurface and CleanSignalView now take the shared stream (head) and a
stable channels list, as the old screen wired them.
… cuts

The stream-stopped banner now sits above both the surface and a running
lesson (one wrapper), with a test that a stream ending mid-lesson shows it.
Step 4 shows only its prompt until the paused strips appear. Drops the unused
ExploreStatus.baselineStable and FrozenComparison.sharedScale fields,
un-exports CleanSignalViewProps, and removes stale comments and the obsolete
end-pill learning.
@jdpigeon
jdpigeon merged commit a1b7153 into main Sep 28, 2026
15 checks passed
@jdpigeon
jdpigeon deleted the feat/ws3-explore-integration branch September 28, 2026 14:13
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