From 7a0848ebbaab4f7f50821f6445f3b9be5bcdd7fa Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Fri, 25 Sep 2026 17:49:42 -0400 Subject: [PATCH 1/6] design(WS3): Explore EEG Storybook pass --- docs/uxr/2026-09-25-ws3-design-brief.md | 70 ++ src/renderer/app.global.css | 22 + .../components/Explore/Explore.stories.tsx | 351 +++++++++ .../components/Explore/ExploreParts.tsx | 732 ++++++++++++++++++ .../components/Explore/ExploreScreens.tsx | 510 ++++++++++++ src/renderer/components/Explore/fixtures.ts | 312 ++++++++ 6 files changed, 1997 insertions(+) create mode 100644 docs/uxr/2026-09-25-ws3-design-brief.md create mode 100644 src/renderer/components/Explore/Explore.stories.tsx create mode 100644 src/renderer/components/Explore/ExploreParts.tsx create mode 100644 src/renderer/components/Explore/ExploreScreens.tsx create mode 100644 src/renderer/components/Explore/fixtures.ts diff --git a/docs/uxr/2026-09-25-ws3-design-brief.md b/docs/uxr/2026-09-25-ws3-design-brief.md new file mode 100644 index 00000000..0c57583b --- /dev/null +++ b/docs/uxr/2026-09-25-ws3-design-brief.md @@ -0,0 +1,70 @@ +# WS3 design brief — Explore EEG + +**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` §5, §10.2 ("Explore EEG"), §11 WS3. +- `docs/uxr/playtest_naive_1.md` findings 3 and 4. +- `docs/uxr/# Playtest Takeaways.md`, the "Live EEG Viewing" section. +- Design system: `docs/design/DESIGN.md`, `.design-sync/conventions.md`. +- Shape to match, all merged: #272 HeadsetSetup/SignalPrep, #273 ParticipantScreens, #277 Analyze (left controls rail + results, and the ERP walkthrough's Back/Next step panel). Build on #277's rail and walkthrough patterns, so the app has one lesson idiom. + +## The job + +A student puts on a headset, sees their own brain signal live, and learns three things without a facilitator: +- whether the signal is usable; +- what "noise" means (not sound); +- that they can cause visible changes: a blink artifact, and alpha with eyes closed. + +In the playtest this was the most engaging moment: the blink made the plot jump. It was also the most facilitator-dependent. Instructions sat too low, "noise" was misread, the eyes-closed activity had no clear start, countdown or chime meaning, and the alpha effect was subtle and unexplained. + +## Current code (read before designing) + +- `src/renderer/components/EEGExplorationComponent.tsx`: + - The disconnected landing: image, "Explore Raw EEG", "Connect a headset". + - `ConnectedExplore`: plot, sensor card and lesson picker. + - The disconnected landing is the "blank screen" product flagged; redesign it. +- `src/renderer/components/ExploreLessonFlow.tsx`: the lesson runner (steps, Back/Next/Exit, blink detector, eyes-closed interval, alpha comparison). +- `src/renderer/constants/exploreLessons.ts`: the two lessons and their step copy: `clean-signal` "How do I get a cleaner signal?" and `noise-sources` "Where is this noise coming from?". +- `src/renderer/components/ExploreSensorCard.tsx`, `SignalQualityIndicatorComponent.tsx`, `constants/electrodes.ts` (`QUALITY_LABELS`, `ELECTRODES`). +- `src/renderer/utils/eeg/exploreSignal.ts` (blink detection, alpha ratio) and `lessonAudio.ts` (chimes). +- `src/renderer/components/HeadsetSetup/SignalPrep.tsx` (#274): signal prep after pairing. It now shows on Explore before the connected view. Don't duplicate its checklist; the lessons follow it. +- **The live plot is a `` (`ViewerComponent`, d3 `EEGViewer`) and cannot run in Storybook.** Draw a fixture stand-in with React SVG: a few seconds of multi-channel trace from a synthetic series, with blink spikes and an eyes-closed alpha burst where a step needs them. Keep its geometry close to the real viewer's (channels stacked, time running left to right, labels on the left) so integration swaps in the webview. + +## Stories required (plan §5, §10.2) + +Pure-props components under `src/renderer/components/Explore/`, with a fixtures file and stories. Render inside the real AppShell chrome with no workspace (Explore is workspace-free), as #273/#277's decorators do. + +| Story | Must show | +|---|---| +| Disconnected | Redesigned landing. What Explore is, one primary `Connect a headset`, what you'll do once connected. Pairs visually with the Home Explore card. No "Live view only" chip (product removed it). | +| Waiting | Connected, no data yet: an explicit waiting state. | +| QualitySummary: Ready / Settling / AdjustSensors / NoSignal | Overall status above the plot (§5.1): `Ready to explore`, `Sensors are still settling`, `Adjust AF7 and TP10`, `No signal detected`. It names an action where one exists. Color supports; words explain. Per-sensor detail stays available without reading color. | +| NoiseDefinition | The plain-language definition of noise (§5.1 wording), shown before the student judges anything. Contact can improve over several minutes; never promise a fixed warm-up. | +| LessonPicker | The two lessons as clear, local choices, with no competing global nav. | +| Blink steps 1–4 | The sequence in §5.3: (1) blink once and find the marked response; (2) predict what another blink will do; (3) blink several times so it's unmistakable; (4) compare a blinking interval with a quiet one. The instruction sits above or beside the plot (§5.2), with Back/Next/Exit local to the lesson. Also a **BlinkNotDetected** state: the lesson continues gracefully if detection fails or the frontal sensors haven't settled. | +| NoiseLessonStableColors | During the noise-source demonstration, traces use stable colors (not signal-quality colors) so they don't compete (§5.2). | +| EyesClosed steps | The single guided sequence in §5.4: explain the start and end sounds → `Begin eyes-closed activity` → visible 3–2–1 countdown → `Close your eyes` → the interval (works without watching the screen) → unmistakable `Open your eyes` → review the marked interval plus the measured 8–12 Hz comparison. | +| AlphaResult / AlphaNoEffect | The student's real alpha comparison as the result. An optional ideal reference, labelled `Example`, visually separated, never implying everyone shows it. For Muse, describe TP9/TP10 as the available posterior-side proxy. NoEffect is a valid, encouraging outcome, not a failure. | +| StreamError | Error and unsupported-channel states that say what to do. | + +## Constraints + +- Explore never creates a workspace or records anything (§2.2, §13). +- No fake participant data presented as real (§13). Stand-in plots are fixtures; any reference curve is labelled `Example` in the UI. +- One filled-teal primary action per surface. Teal = action, gold = location. Signal colors only for signal status. Never color-only. +- Student-facing, friendly, direct copy. Light headings. +- No animation beyond what a step needs, and none during stimulus-like moments. The countdown may animate and must respect `prefers-reduced-motion`. +- Rule A (from #277): at 1366×768 and 1280×720, the plot, the current instruction and its lesson controls are all visible with no page scroll. Measure and report it. +- Traps in `.llms/learnings.md`: 18px root font-size; global `p { 18px !important }`; `li { list-style: none }`; lab.css `main/header`. +- New files + stories only. Scoped CSS classes are fine, but never modify existing shared global CSS classes. No new dependencies (package.json untouched). Don't edit EEGExplorationComponent, ExploreLessonFlow, ViewerComponent, EEGViewer.js, the constants or utils; read them for real copy, thresholds and step structure. + +## Out of scope + +Wiring, the webview itself, changes to detection math, the Collect-side signal checks. + +## Done when + +- Every story renders in Storybook 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 copy questions, how the fixture plot maps to the real viewer, and any unmet constraint. diff --git a/src/renderer/app.global.css b/src/renderer/app.global.css index dd5829ff..5d888198 100644 --- a/src/renderer/app.global.css +++ b/src/renderer/app.global.css @@ -505,3 +505,25 @@ button:active { fill: var(--color-brand); cursor: pointer; } + +/* Explore (WS3) design pass: eyes-closed countdown pulse. Scoped to the + countdown digit; the only animation this surface needs. */ +.explore-countdown-active { + animation: explore-countdown-pulse 1s ease-in-out infinite; +} + +@keyframes explore-countdown-pulse { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(1.14); + } +} + +@media (prefers-reduced-motion: reduce) { + .explore-countdown-active { + animation: none; + } +} diff --git a/src/renderer/components/Explore/Explore.stories.tsx b/src/renderer/components/Explore/Explore.stories.tsx new file mode 100644 index 00000000..4a1fcf79 --- /dev/null +++ b/src/renderer/components/Explore/Explore.stories.tsx @@ -0,0 +1,351 @@ +import React 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 { DeviceState } from '../AppShell/types'; +import { + ALPHA_INCREASE_RATIO, + ALPHA_NO_EFFECT_RATIO, + BLINK_MANY, + BLINK_MANY_ALL, + BLINK_MANY_ANNOTATIONS, + BLINK_ONE, + BLINK_ONE_ANNOTATIONS, + BLINK_PREDICT, + BLINK_PREDICT_ANNOTATIONS, + BLINKING_SNAPSHOT, + CALM_SNAPSHOT, + COMPARISON_RATIO, + COMPARISON_SHARED_SCALE, + EYES_CLOSED_ANNOTATIONS, + EYES_CLOSED_LIVE, + EYES_CLOSED_SNAPSHOT, + LIVE_SNAPSHOT, + NO_SIGNAL_SNAPSHOT, + QUALITY_SCENARIOS, + QualityState, + SensorStatus, +} from './fixtures'; +import { + BlinkLessonView, + EyesClosedView, + ExploreDisconnected, + ExploreSurface, +} from './ExploreScreens'; +import { ErrorBanner } from './ExploreParts'; + +/** SIGNAL_QUALITY values are the trace colors, one per sensor. */ +const qualityColors = (sensors: SensorStatus[]) => + sensors.map((sensor) => sensor.quality); + +/** + * Explore in the real AppShell with no workspace (Explore is workspace-free; + * the shell bar is the only chrome). All signal traces are synthetic fixture + * series — labelled "example" in these descriptions only, exactly as the app + * copy will read once the real webview is wired in. + */ +const withExploreChrome: Decorator = (Story, { parameters }) => ( + + + + + +); + +const meta: Meta = { + title: 'Domain/Explore', + parameters: { layout: 'fullscreen' }, + decorators: [withExploreChrome], +}; +export default meta; +type Story = StoryObj; + +/** The connected surface in one of the four quality states. */ +function Surface({ + state, + ...props +}: { + state: QualityState | 'waiting'; +} & Partial>) { + const sensors = + QUALITY_SCENARIOS[state === 'waiting' ? 'ready' : state].sensors; + return ( + + ); +} + +/** X01 — Redesigned landing: what Explore is, one primary action, what waits once connected. Nothing is recorded. */ +export const Disconnected: Story = { + parameters: { device: 'none' }, + render: () => , +}; + +/** X02 — Connected, no data yet: an explicit waiting state; lessons stay disabled until signal arrives. */ +export const Waiting: Story = { + render: () => , +}; + +/** Q01 — Overall status above the plot: `Ready to explore`, per-sensor words at left. */ +export const QualitySummaryReady: Story = { + render: () => , +}; + +/** Q02 — `Sensors are still settling`: contact can improve over several minutes, never a fixed warm-up. */ +export const QualitySummarySettling: Story = { + render: () => , +}; + +/** Q03 — `Adjust AF7 and TP10`: the status names the action and the sensors. */ +export const QualitySummaryAdjustSensors: Story = { + render: () => , +}; + +/** Q04 — `No signal detected`: flat traces, and what to check. */ +export const QualitySummaryNoSignal: Story = { + render: () => , +}; + +/** N01 — The plain-language definition of noise (plan §5.1) before the student judges anything. */ +export const NoiseDefinition: Story = { + render: () => ( + + ), +}; + +/** L01 — The two lessons as clear, local choices; no competing global nav. */ +export const LessonPicker: Story = { + render: () => , +}; + +/** B01 — Blink step 1/4: blink once and find the marked response on the plot. */ +export const BlinkStep1: Story = { + render: () => ( + + ), +}; + +/** B02 — Blink step 2/4: predict what another blink will do, then check. */ +export const BlinkStep2: Story = { + render: () => ( + + ), +}; + +/** B03 — Blink step 3/4: blink several times so the difference is unmistakable. */ +export const BlinkStep3: Story = { + render: () => ( + + ), +}; + +/** B04 — Blink step 4/4: a blinking interval beside a quiet one, same sensors, same scale. */ +export const BlinkStep4: Story = { + render: () => ( + + ), +}; + +/** B05 — BlinkNotDetected: detection misses or the frontal sensors have not settled; the lesson continues gracefully. */ +export const BlinkNotDetected: Story = { + render: () => ( + + ), +}; + +/** S01 — Noise demonstration on all four sensors: stable trace colors (§5.2), never the quality colors. */ +export const NoiseLessonStableColors: Story = { + render: () => ( + + ), +}; + +/** E01 — Eyes-closed 1/5: the start and end sounds explained, `Begin eyes-closed activity`, Example reference. */ +export const EyesClosedIntro: Story = { + render: () => ( + + ), +}; + +/** E02 — Eyes-closed 2/5: visible 3–2–1 countdown (mid-count), pulse respects reduced motion. */ +export const EyesClosedCountdown: Story = { + render: () => ( + + ), +}; + +/** E03 — Eyes-closed 3/5: `Close your eyes` and the interval — nothing on screen needs watching. */ +export const EyesClosedInterval: Story = { + render: () => ( + + ), +}; + +/** E04 — Eyes-closed 4/5: the unmistakable `Open your eyes` end cue. */ +export const EyesClosedEndCue: Story = { + render: () => ( + + ), +}; + +/** E05 — Eyes-closed 5/5: the marked interval reviewed with the measured 8–12 Hz comparison. */ +export const EyesClosedReview: Story = { + render: () => ( + + ), +}; + +/** A01 — AlphaResult: the student's real comparison as the result, with the ideal reference labelled `Example`. */ +export const AlphaResult: Story = { + render: () => ( + + ), +}; + +/** A02 — AlphaNoEffect: a valid, encouraging outcome — alpha is subtle for many people. */ +export const AlphaNoEffect: Story = { + render: () => ( + + ), +}; + +/** X03 — Stream error and unsupported channels: both say exactly what to do. */ +export const StreamError: Story = { + render: () => ( + + } + /> + ), +}; \ No newline at end of file diff --git a/src/renderer/components/Explore/ExploreParts.tsx b/src/renderer/components/Explore/ExploreParts.tsx new file mode 100644 index 00000000..4bab8eb0 --- /dev/null +++ b/src/renderer/components/Explore/ExploreParts.tsx @@ -0,0 +1,732 @@ +import React, { ReactNode, useEffect, useId, useRef } from 'react'; +import type { EEGSnapshot, PlotAnnotation } from '../../../shared/eegVizTypes'; +import { SIGNAL_QUALITY } from '../../constants/constants'; +import { + ELECTRODES, + QUALITY_LABELS, + UNKNOWN_ELECTRODE, +} from '../../constants/electrodes'; +import { Button } from '../ui/button'; +import { cn } from '../ui/utils'; +import { + ALPHA_EXAMPLE_CAPTION, + NOISE_DEFINITION, + NOISE_SETTLING_NOTE, + QUALITY_SCENARIOS, + QUALITY_STATE_TONE, + QualityState, + SensorStatus, +} from './fixtures'; + +/** Small uppercase label for lesson step counters and section titles. */ +export const stepLabel = + 'text-[12px] font-bold uppercase tracking-[0.5px] text-ink-muted'; + +const PLOT_MARGIN = { top: 20, right: 10, bottom: 30, left: 44 }; +const LABEL_HEIGHT = 22; +const LABEL_RADIUS = LABEL_HEIGHT / 2; +const LABEL_GUTTER = LABEL_HEIGHT + 8; + +/** Matches `EEGViewer`'s annotation tones so integration swaps in cleanly. */ +const TONE_STYLES = { + blink: { + fill: 'rgba(255, 193, 7, 0.18)', + stroke: '#ffc107', + text: '#1a1a1a', + }, + 'eyes-closed': { + fill: 'rgba(0, 124, 112, 0.10)', + stroke: '#007c70', + text: '#ffffff', + }, +} as const; + +/** Glyph-width estimate; the real viewer measures with `getBbox`. */ +const PILL_FONT = 12; + +const AXIS_FONT = '11px Lato, "Helvetica Neue", sans-serif'; +const PILL_FONT_STACK = `${PILL_FONT}px Lato, "Helvetica Neue", sans-serif`; + +interface FixturePlotProps { + /** Synthetic stand-in data; the real viewer renders this from the webview. */ + snapshot: EEGSnapshot; + annotations?: PlotAnnotation[]; + /** Per-channel stroke colors, index-aligned with `snapshot.channels`. */ + colors: string[]; + /** Symmetric µV half-range, like `ViewerComponent.amplitudeScale`. */ + amplitudeScale?: number; + width?: number; + height?: number; +} + +/** + * Storybook stand-in for the live `` plot (`ViewerComponent` / + * `EEGViewer`, which cannot run outside Electron). Same geometry as the real + * viewer: 20/10/30/44 margins, channels stacked in equal bands, channel labels + * on the left, time running left→right with a whole-second offset axis, and + * annotation bands with pill labels. Drawn from a synthetic fixture series. + */ +export function FixturePlot({ + snapshot, + annotations = [], + colors, + amplitudeScale = 200, + width = 900, + height = 420, +}: FixturePlotProps) { + const clipId = useId(); + const plotW = width - PLOT_MARGIN.left - PLOT_MARGIN.right; + const plotH = height - PLOT_MARGIN.top - PLOT_MARGIN.bottom; + const count = snapshot.channels.length; + const bandH = plotH / count; + const span = snapshot.endTime - snapshot.startTime; + const xAt = (t: number) => ((t - snapshot.startTime) / span) * plotW; + + const paths = snapshot.data.map((samples, i) => { + const center = samples.reduce((a, b) => a + b, 0) / samples.length; + const top = i * bandH; + const yAt = (v: number) => + top + ((center + amplitudeScale - v) / (2 * amplitudeScale)) * bandH; + // Same 2× downsampling as the real viewer's line paths. + let d = ''; + for (let n = 0; n < samples.length; n += 2) { + const x = xAt(snapshot.startTime + (n / samples.length) * span); + d += `${d ? 'L' : 'M'}${x.toFixed(1)},${yAt(samples[n]).toFixed(1)}`; + } + return d; + }); + + // Whole-second offset ticks only, mirroring `EEGViewer.buildTimeAxis`. + const maxTicks = Math.max(2, Math.floor(plotW / 80)); + const tickStep = Math.max(1, Math.ceil(span / 1000 / maxTicks)) * 1000; + const ticks: number[] = []; + for (let offset = 0; offset >= -span; offset -= tickStep) ticks.push(offset); + + const bands = annotations + .filter( + (annotation) => + (annotation.endTime == null || annotation.endTime >= snapshot.startTime) && + annotation.startTime <= snapshot.endTime + ) + .map((annotation) => { + const startX = xAt(annotation.startTime); + const endX = + annotation.endTime != null ? xAt(annotation.endTime) : plotW; + return { + annotation, + x: Math.max(0, Math.min(plotW, startX)), + width: Math.max(0, Math.min(plotW, endX) - Math.max(0, startX)), + ended: annotation.endTime != null, + }; + }); + + return ( + + + + {paths.map((d, i) => ( + + ))} + + {bands.map(({ annotation, x, width: bandWidth, ended }) => { + const style = TONE_STYLES[annotation.tone]; + const solid = annotation.tone === 'eyes-closed'; + const pillW = (text: string) => + text.length * (PILL_FONT * 0.55) + 18; + const startW = pillW(annotation.label); + const endW = annotation.endLabel ? pillW(annotation.endLabel) : 0; + return ( + + + + {ended && ( + + )} + {bandWidth > 40 && ( + <> + + + + {annotation.label} + + + {ended && annotation.endLabel && ( + + + + {annotation.endLabel} + + + )} + + )} + + ); + })} + + {ticks.map((offset) => ( + + + + {Math.round(offset / 1000)}s + + + ))} + {snapshot.channels.map((channel, i) => ( + + + + {channel} + + + ))} + + + + + + ); +} + +/** A plot card: one caption line above a plot that fills the given space. */ +export function PlotCard({ + caption, + aside, + children, + className, +}: { + caption: string; + aside?: ReactNode; + children: ReactNode; + className?: string; +}) { + return ( +
+
+ + + {caption} + + {aside} +
+
{children}
+
+ ); +} + +/** + * Overall signal status above the plot (plan §5.1). Words explain, color only + * supports; the sensor strip repeats every state in text. + */ +export function QualitySummary({ + state, + sensors, + className, +}: { + state: QualityState; + sensors: SensorStatus[]; + className?: string; +}) { + const scenario = QUALITY_SCENARIOS[state]; + return ( +
+
+ + +

+ {scenario.heading} +

+
+ + {scenario.action} + +
+
+ {sensors.map(({ channel, quality }) => ( + + {channel}{' '} + {QUALITY_LABELS[quality]} + + ))} +
+
+ ); +} + +/** + * Per-sensor detail as color plus a word plus a fix, so nothing needs color + * interpretation (same idiom as `SignalPrep`). Variability is not impedance. + */ +export function SensorList({ sensors }: { sensors: SensorStatus[] }) { + return ( +
+

Each sensor

+
    + {sensors.map(({ channel, quality }) => { + const meta = ELECTRODES[channel] ?? UNKNOWN_ELECTRODE; + return ( +
  • + + + + {channel} · {meta.location} —{' '} + {QUALITY_LABELS[quality]} + + + {meta.fixes[quality]} + + +
  • + ); + })} +
+
+ ); +} + +/** The plain-language definition of noise (plan §5.1), before any judging. */ +export function NoiseDefinitionCard() { + return ( +
+

What “noise” means here

+

+ {NOISE_DEFINITION} +

+

+ {NOISE_SETTLING_NOTE} +

+
+ ); +} + +/** + * The lesson step panel in the Analyze walkthrough's idiom: step counter with + * gold progress pips, local Exit, fading step copy, Back / Next at the bottom. + */ +export function LessonStepPanel({ + label, + step, + steps, + title, + action, + body, + children, + onBack, + onNext, + onExit, + backLabel = 'Back', + nextLabel = 'Next', + backDisabled, + nextDisabled, +}: { + /** Uppercase step counter prefix, e.g. `Where is this noise coming from?`. */ + label: string; + /** Current step; 0 hides the counter and pips (pre-step screens). */ + step: number; + steps: number; + title: string; + /** The expected action at a glance (plan §5.2). */ + action?: string; + body: ReactNode; + children?: ReactNode; + onBack(): void; + onNext(): void; + onExit(): void; + backLabel?: string; + nextLabel?: string; + backDisabled?: boolean; + nextDisabled?: boolean; +}) { + const heading = useRef(null); + useEffect(() => { + heading.current?.focus({ preventScroll: true }); + }, [step]); + return ( + + ); +} + +/** Color legend for the noise demonstration's stable trace colors (§5.2). */ +export function TraceLegend({ + channels, + colors, +}: { + channels: string[]; + colors: string[]; +}) { + return ( +
+ {channels.map((channel, i) => ( + + + {channel} + + ))} + — colors stay the same through this lesson +
+ ); +} + +/** + * Frozen five-second comparison window with its peak-to-peak readout, like the + * lesson flow's frozen strips. + */ +export function FrozenStrip({ + label, + sublabel, + snapshot, + colors, + scale, + blinking, + ratio, +}: { + label: string; + sublabel: string; + snapshot: EEGSnapshot; + colors: string[]; + scale: number; + blinking?: boolean; + ratio?: number; +}) { + return ( +
+
+ {label} + {sublabel} +
+
+
+ +
+
+
+
+
+ {snapshot.peakToPeak.toFixed(0)} µV +
+
+ {blinking && ratio !== undefined + ? `${Math.round(ratio)}× the still signal` + : 'peak to peak'} +
+
+
+
+
+ ); +} + +/** Visible 3–2–1 countdown; pulses only when motion is allowed. */ +export function Countdown({ value }: { value: 3 | 2 | 1 }) { + return ( +
+ {([3, 2, 1] as const).map((digit) => { + const state = + digit > value ? 'done' : digit === value ? 'active' : 'ahead'; + return ( + + {digit} + + ); + })} +
+ ); +} + +/** + * Optional ideal alpha reference. Labelled `Example`, visually separated by a + * dashed border, and never presented as the expected outcome. + */ +export function AlphaExampleCard() { + return ( +
+ Example + + + + + before + + + eyes closed + + +

+ What a clear alpha increase can look like. {ALPHA_EXAMPLE_CAPTION} +

+
+ ); +} + +/** An error state that says what to do, with the action beside it. */ +export function ErrorBanner({ + title, + body, + actionLabel, + onAction, + unsupported, +}: { + title: string; + body: string; + actionLabel: string; + onAction(): void; + /** Unsupported-channel note, when the headset reports unusable channels. */ + unsupported?: string; +}) { + return ( +
+
+
+ + ! + +
+
{title}
+
{body}
+
+
+ +
+ {unsupported && ( +
+ {unsupported} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/renderer/components/Explore/ExploreScreens.tsx b/src/renderer/components/Explore/ExploreScreens.tsx new file mode 100644 index 00000000..64fe90c2 --- /dev/null +++ b/src/renderer/components/Explore/ExploreScreens.tsx @@ -0,0 +1,510 @@ +import React, { ReactNode } from 'react'; +import type { EEGSnapshot, PlotAnnotation } from '../../../shared/eegVizTypes'; +import { EXPLORE_LESSONS } from '../../constants/exploreLessons'; +import eegArt from '../../assets/common/EEG.png'; +import { Button } from '../ui/button'; +import { cn } from '../ui/utils'; +import { + ALPHA_NO_EFFECT_BODY, + BLINK_NOT_DETECTED, + BLINK_STEPS, + EYES_END_BODY, + EYES_INTRO_BODY, + EYES_INTRO_BODY_2, + EYES_INTERVAL_BODY, + EYES_PROXY_NOTE, + QualityState, + STABLE_COLOR_BY_CHANNEL, + SensorStatus, + alphaResultBody, +} from './fixtures'; +import { + AlphaExampleCard, + Countdown, + FixturePlot, + FrozenStrip, + LessonStepPanel, + NoiseDefinitionCard, + PlotCard, + QualitySummary, + SensorList, + TraceLegend, + stepLabel, +} from './ExploreParts'; + +const SECTION_LABEL = + 'm-0 text-[14px] font-bold uppercase tracking-[0.5px] text-ink-muted'; +const BODY_TEXT = + 'm-0 !text-[16px] leading-normal !tracking-normal [text-wrap:pretty]'; + +/** Four or more call sites across the lesson screens keep these lockstep. */ +const stableColors = (channels: string[]) => + channels.map((channel) => STABLE_COLOR_BY_CHANNEL[channel]); + +/** + * Redesigned disconnected landing: what Explore is, one primary action, and + * what waits on the other side. Pairs with Home's Explore card. Explore never + * records or creates a workspace, and the landing says so. + */ +export function ExploreDisconnected({ onConnect }: { onConnect(): void }) { + return ( +
+
+ +
+

+ Explore EEG +

+

+ Put on a headset and watch the EEG (electroencephalogram) signal in + real time — your own brain's electricity, arriving live. No + experiment to set up, nothing recorded, no workspace created. +

+
+ + + Takes about 30 seconds + +
+
+

Once you're connected

+
+ 1. See whether your signal is usable. + 2. Learn what “noise” means here — it is not a sound. + + 3. Make your own blink, then your alpha rhythm, show up on the + plot. + +
+
+
+
+
+ ); +} + +/** The two lessons as equal, local choices; both actions stay outlined. */ +export function LessonPicker({ + disabled, + onStart, +}: { + disabled?: boolean; + onStart(): void; +}) { + return ( +
+

Learn with this signal

+
+ {EXPLORE_LESSONS.map((lesson) => ( +
+
+
+ {lesson.title} +
+
{lesson.detail}
+
+ +
+ ))} +
+
+ ); +} + +export interface ExploreSurfaceProps { + /** `waiting` = connected but no data yet; the four quality states after. */ + quality: QualityState | 'waiting'; + sensors: SensorStatus[]; + /** Null shows the explicit waiting state in the plot area. */ + snapshot: EEGSnapshot | null; + /** Quality colors per channel: the main surface teaches signal quality. */ + colors: string[]; + onStartLesson(): void; + /** Above the status summary, e.g. the stream-error banner. */ + banner?: ReactNode; +} + +/** + * The connected Explore surface: overall status above the plot, per-sensor + * detail on the left, live plot and lesson choices on the right. Fills the + * window without page scroll at 1366×768 and 1280×720. + */ +export function ExploreSurface({ + quality, + sensors, + snapshot, + colors, + onStartLesson, + banner, +}: ExploreSurfaceProps) { + const waiting = quality === 'waiting'; + return ( +
+ {banner} + {waiting ? ( +
+ +

+ Waiting for the headset signal… +

+ + Connected — the first seconds of data are on their way. + +
+ ) : ( + + )} +
+
+ +
+
+ + {snapshot ? ( + + ) : ( +
+ Waiting for the headset signal… +
+ )} +
+ +
+
+
+ ); +} + +export interface BlinkLessonViewProps { + /** 0 is the noise-definition intro; 1–4 are the blink steps (plan §5.3). */ + step: 0 | 1 | 2 | 3 | 4; + /** Adds the stable-color legend (the four-sensor demonstration, §5.2). */ + showLegend?: boolean; + /** The lesson continues gracefully when detection misses (plan §5.3). */ + notDetected?: boolean; + snapshot: EEGSnapshot; + annotations?: PlotAnnotation[]; + comparison?: { + calm: EEGSnapshot; + blinking: EEGSnapshot; + sharedScale: number; + ratio: number; + }; + onBack(): void; + onNext(): void; + onExit(): void; +} + +/** + * The noise-source lesson: the noise definition first, then the four blink + * steps, instruction beside the plot with the lesson's own Back/Next/Exit. + * Traces use stable colors (§5.2) so changing quality colors do not compete. + */ +export function BlinkLessonView({ + step, + showLegend, + notDetected, + snapshot, + annotations, + comparison, + onBack, + onNext, + onExit, +}: BlinkLessonViewProps) { + const channels = snapshot.channels; + const caught = (annotations ?? []).length; + return ( +
+ 0 ? BLINK_STEPS[step - 1].action : undefined} + body={ + step === 0 ? ( +
+ + {EYES_INTRO_BODY_2} +
+ ) : ( + BLINK_STEPS[step - 1].body + ) + } + backLabel={step === 0 ? 'Exit' : 'Back'} + nextLabel={ + step === 0 + ? 'Start the steps' + : step === 4 + ? 'Finish lesson' + : 'Next' + } + onBack={step === 0 ? onExit : onBack} + onNext={onNext} + onExit={onExit} + > + {step === 2 && ( +
+ Your prediction +
+ + +
+
+ )} + {notDetected && ( +
+ {BLINK_NOT_DETECTED} +
+ )} +
+
+ {comparison ? ( + <> + + + + ) : ( + 0 && step < 4 ? ( + + {caught} {caught === 1 ? 'blink' : 'blinks'} marked + + ) : undefined + } + > + + + )} + {showLegend && ( + + )} +
+
+ ); +} + +export type EyesClosedPhase = + | 'intro' + | 'countdown' + | 'interval' + | 'end' + | 'review'; + +export interface EyesClosedViewProps { + phase: EyesClosedPhase; + /** 3–2–1 position during the countdown. */ + countdown?: 3 | 2 | 1; + /** Measured 8–12 Hz ratio (eyes-closed ÷ before); null = not enough data. */ + alphaRatio: number | null; + showExample?: boolean; + snapshot: EEGSnapshot; + annotations?: PlotAnnotation[]; + onBack(): void; + onNext(): void; + onExit(): void; +} + +/** + * The single guided eyes-closed sequence (plan §5.4): explain the sounds, + * Begin, visible countdown, `Close your eyes`, the interval, an unmistakable + * `Open your eyes`, then the marked interval and the measured comparison. + */ +export function EyesClosedView({ + phase, + countdown = 3, + alphaRatio, + showExample, + snapshot, + annotations, + onBack, + onNext, + onExit, +}: EyesClosedViewProps) { + const running = phase === 'countdown' || phase === 'interval'; + const resultBody = + alphaRatio == null + ? 'There is not enough continuous posterior-channel data to compare alpha power. The marked interval is still saved below.' + : alphaRatio > 1 + ? alphaResultBody(alphaRatio) + : ALPHA_NO_EFFECT_BODY; + return ( +
+ + {EYES_INTRO_BODY} + {EYES_INTRO_BODY_2} +
+ ) : phase === 'countdown' ? ( + 'Close your eyes when you hear the single chime.' + ) : phase === 'interval' ? ( + EYES_INTERVAL_BODY + ) : phase === 'end' ? ( + EYES_END_BODY + ) : ( + resultBody + ) + } + nextLabel={ + { + intro: 'Begin eyes-closed activity', + countdown: 'Starting…', + interval: 'Recording…', + end: 'See your result', + review: 'Finish lesson', + }[phase] + } + backDisabled={running} + nextDisabled={running} + onBack={onBack} + onNext={onNext} + onExit={onExit} + > + {phase === 'intro' && showExample && } + {phase === 'review' && ( + <> +
+ {alphaRatio == null + ? 'Measured 8–12 Hz power: not enough data this time.' + : alphaRatio > 1 + ? `Measured 8–12 Hz power: ${alphaRatio.toFixed(1)}× the five seconds before you closed your eyes.` + : 'Measured 8–12 Hz power: about the same as the five seconds before.'} +
+ {showExample && } +
+ {EYES_PROXY_NOTE} +
+ + )} + +
+ + + + {(running || phase === 'end') && ( +
+ {phase === 'countdown' ? ( + + ) : ( +
+ {phase === 'interval' ? 'Close your eyes' : 'Open your eyes'} +
+ )} +
+ {phase === 'countdown' + ? 'Close your eyes when the chime sounds.' + : phase === 'interval' + ? 'About ten seconds. Two chimes will end it.' + : 'The activity just ended.'} +
+
+ )} +
+ + ); +} \ No newline at end of file diff --git a/src/renderer/components/Explore/fixtures.ts b/src/renderer/components/Explore/fixtures.ts new file mode 100644 index 00000000..7b713568 --- /dev/null +++ b/src/renderer/components/Explore/fixtures.ts @@ -0,0 +1,312 @@ +import type { EEGSnapshot, PlotAnnotation } from '../../../shared/eegVizTypes'; +import { SIGNAL_QUALITY } from '../../constants/constants'; + +/** Muse montage order, matching the live viewer's channel order. */ +export const EXPLORE_CHANNELS = ['TP9', 'AF7', 'AF8', 'TP10']; +export const FRONTAL_CHANNELS = ['AF7', 'AF8']; +export const POSTERIOR_CHANNELS = ['TP9', 'TP10']; +export const SAMPLING_RATE = 256; + +/** + * Stable per-channel trace colors for the noise demonstration. Signal-quality + * colors would repaint the lines while the student is watching them move, so + * the demo uses these fixed hues instead (plan §5.2). + */ +export const STABLE_TRACE_COLORS = ['#4263eb', '#9c36b5', '#f08c00', '#1098ad']; +export const STABLE_COLOR_BY_CHANNEL: Record = { + TP9: STABLE_TRACE_COLORS[0], + AF7: STABLE_TRACE_COLORS[1], + AF8: STABLE_TRACE_COLORS[2], + TP10: STABLE_TRACE_COLORS[3], +}; + +/** Deterministic PRNG so every screenshot of the synthetic signal is identical. */ +function mulberry32(seed: number) { + let a = seed; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const bump = (t: number, center: number, width: number) => + Math.exp(-((t - center) ** 2) / (2 * width ** 2)); + +export interface TraceSpec { + seed: number; + /** Blink centers on the snapshot's own clock (0 = snapshot start), in ms. */ + blinksMs?: number[]; + /** Eyes-closed window on the snapshot's own clock, in ms. */ + alphaMs?: [number, number]; + /** Scales the whole signal; near zero for the no-signal state. */ + gain?: number; +} + +/** + * Synthetic multi-channel EEG for the fixture plot: background 10 Hz / 5 Hz + * rhythms with random phase, sensor noise, blink humps weighted to the frontal + * sensors, and an alpha burst weighted to TP9/TP10. Not recorded data. + */ +export function makeSnapshot( + channels: string[], + durationMs: number, + spec: TraceSpec +): EEGSnapshot { + const random = mulberry32(spec.seed); + const step = 1000 / SAMPLING_RATE; + const count = Math.round(durationMs / step) + 1; + const phases = channels.map(() => random() * Math.PI * 2); + const data = channels.map((channel, i) => { + const frontal = FRONTAL_CHANNELS.includes(channel) ? 1 : 0.3; + const posterior = POSTERIOR_CHANNELS.includes(channel) ? 1 : 0.35; + const samples = new Array(count); + for (let n = 0; n < count; n += 1) { + const t = n * step; + let value = + 6 * Math.sin((2 * Math.PI * 10 * t) / 1000 + phases[i]) + + 5 * Math.sin((2 * Math.PI * 5.5 * t) / 1000 + phases[i] * 1.7) + + (random() - 0.5) * 8; + for (const center of spec.blinksMs ?? []) { + value += frontal * 130 * bump(t, center, 110); + } + if (spec.alphaMs && t >= spec.alphaMs[0] && t <= spec.alphaMs[1]) { + const edge = Math.min( + 1, + (t - spec.alphaMs[0]) / 400, + (spec.alphaMs[1] - t) / 400 + ); + value += + posterior * 22 * edge * Math.sin((2 * Math.PI * 10.5 * t) / 1000); + } + samples[n] = value * (spec.gain ?? 1); + } + return samples; + }); + return { + startTime: 0, + endTime: durationMs, + data, + channels, + samplingRate: SAMPLING_RATE, + peakToPeak: Math.max( + ...data.map((s) => Math.max(...s) - Math.min(...s)) + ), + }; +} + +const blinkBand = (id: string, startTime: number, endTime: number): PlotAnnotation => ({ + id: `blink-${id}`, + startTime, + endTime, + label: 'blink · eye muscle, not brain', + tone: 'blink', +}); + +/** Live four-sensor window for the connected surface. */ +export const LIVE_SNAPSHOT = makeSnapshot(EXPLORE_CHANNELS, 5000, { + seed: 11, +}); + +/** Flat traces for the `No signal detected` state: contact, not data. */ +export const NO_SIGNAL_SNAPSHOT = makeSnapshot(EXPLORE_CHANNELS, 5000, { + seed: 12, + gain: 0.04, +}); + +/** Blink step 1: one blink, one marked response. */ +export const BLINK_ONE = makeSnapshot(FRONTAL_CHANNELS, 5000, { + seed: 21, + blinksMs: [2100], +}); +export const BLINK_ONE_ANNOTATIONS = [blinkBand('one', 1650, 2650)]; + +/** Blink step 2: the first marked blink, second not yet taken. */ +export const BLINK_PREDICT = makeSnapshot(FRONTAL_CHANNELS, 5000, { + seed: 22, + blinksMs: [1050], +}); +export const BLINK_PREDICT_ANNOTATIONS = [blinkBand('predict', 650, 1550)]; + +/** Blink step 3: several blinks so the effect is unmistakable. */ +export const BLINK_MANY = makeSnapshot(FRONTAL_CHANNELS, 5000, { + seed: 23, + blinksMs: [900, 2050, 3250, 4350], +}); +export const BLINK_MANY_ANNOTATIONS = [ + blinkBand('m1', 500, 1350), + blinkBand('m2', 1650, 2500), + blinkBand('m3', 2850, 3700), + blinkBand('m4', 3950, 4750), +]; + +/** Stable-color demo: all four sensors while blinking, for the §5.2 story. */ +export const BLINK_MANY_ALL = makeSnapshot(EXPLORE_CHANNELS, 5000, { + seed: 24, + blinksMs: [900, 2050, 3250, 4350], +}); + +/** Blink step 4: two frozen five-second windows at one shared scale. */ +export const CALM_SNAPSHOT = makeSnapshot(FRONTAL_CHANNELS, 5000, { + seed: 31, +}); +export const BLINKING_SNAPSHOT = makeSnapshot(FRONTAL_CHANNELS, 5000, { + seed: 32, + blinksMs: [1150, 2350, 3650], +}); +export const COMPARISON_SHARED_SCALE = 150; +export const COMPARISON_RATIO = + BLINKING_SNAPSHOT.peakToPeak / CALM_SNAPSHOT.peakToPeak; + +/** Eyes-closed review: the marked interval with an alpha burst on TP9/TP10. */ +export const EYES_CLOSED_SNAPSHOT = makeSnapshot(EXPLORE_CHANNELS, 15500, { + seed: 41, + alphaMs: [3000, 13000], +}); +export const EYES_CLOSED_ANNOTATIONS: PlotAnnotation[] = [ + { + id: 'eyes-closed', + startTime: 3000, + endTime: 13000, + label: 'eyes closed', + endLabel: 'eyes open', + tone: 'eyes-closed', + }, +]; + +/** Live window shown during the countdown and interval. */ +export const EYES_CLOSED_LIVE = makeSnapshot(EXPLORE_CHANNELS, 5000, { + seed: 42, +}); + +/** Measured 8–12 Hz comparison for the result stories (eyes-closed ÷ before). */ +export const ALPHA_INCREASE_RATIO = 2.4; +export const ALPHA_NO_EFFECT_RATIO = 0.9; + +export interface SensorStatus { + channel: string; + quality: SIGNAL_QUALITY; +} + +export type QualityState = 'ready' | 'settling' | 'adjust' | 'no-signal'; + +/** + * Overall status above the plot (plan §5.1). The four headings are the + * product's wording; `action` names what to do where an action exists. + */ +export const QUALITY_SCENARIOS: Record< + QualityState, + { heading: string; action: string; sensors: SensorStatus[] } +> = { + ready: { + heading: 'Ready to explore', + action: 'Sit still and watch your signal, then start a lesson below.', + sensors: EXPLORE_CHANNELS.map((channel) => ({ + channel, + quality: SIGNAL_QUALITY.GREAT, + })), + }, + settling: { + heading: 'Sensors are still settling', + action: + 'Contact can improve over several minutes. Sit still and let the measurements calm down.', + sensors: EXPLORE_CHANNELS.map((channel) => ({ + channel, + quality: + channel === 'TP10' ? SIGNAL_QUALITY.GREAT : SIGNAL_QUALITY.OK, + })), + }, + adjust: { + heading: 'Adjust AF7 and TP10', + action: + 'Press AF7 and TP10 gently against your skin — or move hair aside — and hold for 10 seconds.', + sensors: EXPLORE_CHANNELS.map((channel) => ({ + channel, + quality: + channel === 'AF7' || channel === 'TP10' + ? SIGNAL_QUALITY.BAD + : channel === 'AF8' + ? SIGNAL_QUALITY.OK + : SIGNAL_QUALITY.GREAT, + })), + }, + 'no-signal': { + heading: 'No signal detected', + action: + 'Check that the headset is on your head and every sensor is touching your skin.', + sensors: EXPLORE_CHANNELS.map((channel) => ({ + channel, + quality: SIGNAL_QUALITY.DISCONNECTED, + })), + }, +}; + +/** Overall dot color; supporting signal only — the words carry the meaning. */ +export const QUALITY_STATE_TONE: Record = { + ready: SIGNAL_QUALITY.GREAT, + settling: SIGNAL_QUALITY.OK, + adjust: SIGNAL_QUALITY.BAD, + 'no-signal': SIGNAL_QUALITY.DISCONNECTED, +}; + +/** Plan §5.1 wording, shown before the student judges anything. */ +export const NOISE_DEFINITION = + 'Noise is electrical activity the headset records that did not come from the brain signal we are trying to measure. Blinks, jaw tension, movement, and poor sensor contact can all create noise.'; + +export const NOISE_SETTLING_NOTE = + 'Not a sound — think of it as static in the recording. Sensor contact often improves over several minutes while the sensors sit on your skin; there is no fixed warm-up time. The live measurements tell you when you are ready.'; + +/** Blink lesson steps (plan §5.3). `action` is the expected step at a glance. */ +export const BLINK_STEPS: { + title: string; + action: string; + body: string; +}[] = [ + { + title: 'Blink once and find the marked response', + action: 'Blink once, then keep still and watch AF7 and AF8.', + body: 'A blink drops one big slow hump onto the two front sensors — your eyelid muscle moving, not your brain thinking. When we spot one, the plot marks it with a gold band.', + }, + { + title: 'Predict what another blink will do', + action: 'Guess first, then blink once and check.', + body: 'The lines are flat again. Before you blink, decide what the next blink will look like — then blink once and see whether you were right.', + }, + { + title: 'Blink several times so it is unmistakable', + action: 'Blink hard, three or four times in a row.', + body: 'Each blink slams another hump into the front sensors. This is the loudest thing in most student recordings — and seeing it land every time is how you know your headset is really hearing you.', + }, + { + title: 'Compare the blinking interval with a quiet interval', + action: 'Look at both windows, same sensors and same scale.', + body: 'One frozen five seconds while you were blinking, one while you sat still. Your brain signal is in both — the blinks just tower over it. This is why researchers ask you to hold still.', + }, +]; + +export const BLINK_NOT_DETECTED = + 'We cannot see your blinks yet. Check that AF7 and AF8 sit flat against your forehead, then try again. Detection is not required — Next stays open whenever you are ready to move on.'; + +export const EYES_INTRO_BODY = + 'Two sounds guide this activity. One chime means close your eyes now. Two chimes, about ten seconds later, mean open them again. Nothing on screen needs watching in between.'; +export const EYES_INTRO_BODY_2 = + 'When you press Begin, a visible 3–2–1 countdown comes first, so the start never surprises you.'; + +export const EYES_PROXY_NOTE = + 'Muse has no sensors over the visual cortex, so we watch TP9 and TP10 behind your ears — the closest available posterior-side look at alpha.'; + +export const EYES_INTERVAL_BODY = + 'Keep them closed until you hear two chimes. Sitting still is fine — the screen can wait.'; + +export const EYES_END_BODY = + 'Two chimes just ended the activity. Your marked interval is saved on the plot below — take a look.'; + +export const alphaResultBody = (ratio: number) => + `The teal band is the ten seconds your eyes were closed. Measured 8–12 Hz (alpha) power was ${ratio.toFixed(1)}× the five seconds before it. The back of your head starts humming a steady rhythm when it has nothing to look at: the seeing part of your brain gets louder once you stop giving it anything to see.`; + +export const ALPHA_NO_EFFECT_BODY = + 'Alpha did not increase in this interval. That is a real result, not a failed lesson — alpha is clearest in some people and nearly invisible in others. Blinking still worked, and that one really was your eyelid.'; + +export const ALPHA_EXAMPLE_CAPTION = + 'Some recordings look like this. Yours will be yours.'; \ No newline at end of file From 321016fb23a194e8cfdb2e6ec08fd98e183fef6f Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Fri, 25 Sep 2026 18:57:48 -0400 Subject: [PATCH 2/6] design(WS3): fit lesson panel copy above controls, clear end pill from the axis --- src/renderer/components/Explore/ExploreParts.tsx | 16 ++++++++-------- .../components/Explore/ExploreScreens.tsx | 4 ++-- src/renderer/components/Explore/fixtures.ts | 10 +++++----- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/renderer/components/Explore/ExploreParts.tsx b/src/renderer/components/Explore/ExploreParts.tsx index 4bab8eb0..c106546f 100644 --- a/src/renderer/components/Explore/ExploreParts.tsx +++ b/src/renderer/components/Explore/ExploreParts.tsx @@ -206,7 +206,7 @@ export function FixturePlot({ transform={`translate(${Math.max( 0, Math.min(plotW - endW, x + bandWidth + 6) - )},${plotH + 6})`} + )},${plotH - LABEL_HEIGHT - 4})`} >

Example - - - - + + + + before - + eyes closed

- What a clear alpha increase can look like. {ALPHA_EXAMPLE_CAPTION} + A clear alpha increase. {ALPHA_EXAMPLE_CAPTION}

); diff --git a/src/renderer/components/Explore/ExploreScreens.tsx b/src/renderer/components/Explore/ExploreScreens.tsx index 64fe90c2..7469d6d1 100644 --- a/src/renderer/components/Explore/ExploreScreens.tsx +++ b/src/renderer/components/Explore/ExploreScreens.tsx @@ -6,6 +6,7 @@ import { Button } from '../ui/button'; import { cn } from '../ui/utils'; import { ALPHA_NO_EFFECT_BODY, + ALPHA_RESULT_BODY, BLINK_NOT_DETECTED, BLINK_STEPS, EYES_END_BODY, @@ -16,7 +17,6 @@ import { QualityState, STABLE_COLOR_BY_CHANNEL, SensorStatus, - alphaResultBody, } from './fixtures'; import { AlphaExampleCard, @@ -397,7 +397,7 @@ export function EyesClosedView({ alphaRatio == null ? 'There is not enough continuous posterior-channel data to compare alpha power. The marked interval is still saved below.' : alphaRatio > 1 - ? alphaResultBody(alphaRatio) + ? ALPHA_RESULT_BODY : ALPHA_NO_EFFECT_BODY; return (
diff --git a/src/renderer/components/Explore/fixtures.ts b/src/renderer/components/Explore/fixtures.ts index 7b713568..5b0bc9ee 100644 --- a/src/renderer/components/Explore/fixtures.ts +++ b/src/renderer/components/Explore/fixtures.ts @@ -293,20 +293,20 @@ export const EYES_INTRO_BODY = export const EYES_INTRO_BODY_2 = 'When you press Begin, a visible 3–2–1 countdown comes first, so the start never surprises you.'; -export const EYES_PROXY_NOTE = - 'Muse has no sensors over the visual cortex, so we watch TP9 and TP10 behind your ears — the closest available posterior-side look at alpha.'; - export const EYES_INTERVAL_BODY = 'Keep them closed until you hear two chimes. Sitting still is fine — the screen can wait.'; export const EYES_END_BODY = 'Two chimes just ended the activity. Your marked interval is saved on the plot below — take a look.'; -export const alphaResultBody = (ratio: number) => - `The teal band is the ten seconds your eyes were closed. Measured 8–12 Hz (alpha) power was ${ratio.toFixed(1)}× the five seconds before it. The back of your head starts humming a steady rhythm when it has nothing to look at: the seeing part of your brain gets louder once you stop giving it anything to see.`; +export const ALPHA_RESULT_BODY = + 'The teal band marks the ten seconds your eyes were closed. The back of your head hums a steady rhythm when it has nothing to look at — that hum is alpha, and it is the seeing part of your brain getting louder.'; export const ALPHA_NO_EFFECT_BODY = 'Alpha did not increase in this interval. That is a real result, not a failed lesson — alpha is clearest in some people and nearly invisible in others. Blinking still worked, and that one really was your eyelid.'; +export const EYES_PROXY_NOTE = + 'Muse has no sensors over the visual cortex — TP9 and TP10 behind the ears are the closest available posterior-side look at alpha.'; + export const ALPHA_EXAMPLE_CAPTION = 'Some recordings look like this. Yours will be yours.'; \ No newline at end of file From e409ed99d9aaaf8972e1e9d27ef4eb2658a19585 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 27 Sep 2026 16:21:27 -0400 Subject: [PATCH 3/6] =?UTF-8?q?design(WS3):=20round-2=20review=20pass=20?= =?UTF-8?q?=E2=80=94=20light=20Ready,=20head=20diagram,=20quiz=20options,?= =?UTF-8?q?=20tick=20fallback,=20segment=20review,=20rainbow=20traces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/Explore/Explore.stories.tsx | 81 +++-- .../components/Explore/ExploreParts.tsx | 313 +++++++++++------- .../components/Explore/ExploreScreens.tsx | 210 +++++++----- src/renderer/components/Explore/fixtures.ts | 142 ++++---- src/renderer/utils/eeg/traceColors.ts | 11 + 5 files changed, 449 insertions(+), 308 deletions(-) create mode 100644 src/renderer/utils/eeg/traceColors.ts diff --git a/src/renderer/components/Explore/Explore.stories.tsx b/src/renderer/components/Explore/Explore.stories.tsx index 4a1fcf79..cf378baf 100644 --- a/src/renderer/components/Explore/Explore.stories.tsx +++ b/src/renderer/components/Explore/Explore.stories.tsx @@ -5,8 +5,6 @@ import { fn } from 'storybook/test'; import AppShell from '../AppShell/AppShell'; import type { DeviceState } from '../AppShell/types'; import { - ALPHA_INCREASE_RATIO, - ALPHA_NO_EFFECT_RATIO, BLINK_MANY, BLINK_MANY_ALL, BLINK_MANY_ANNOTATIONS, @@ -17,14 +15,13 @@ import { BLINKING_SNAPSHOT, CALM_SNAPSHOT, COMPARISON_RATIO, - COMPARISON_SHARED_SCALE, - EYES_CLOSED_ANNOTATIONS, EYES_CLOSED_LIVE, - EYES_CLOSED_SNAPSHOT, LIVE_SNAPSHOT, NO_SIGNAL_SNAPSHOT, QUALITY_SCENARIOS, QualityState, + RHYTHM_INCREASE_RATIO, + RHYTHM_NO_EFFECT_RATIO, SensorStatus, } from './fixtures'; import { @@ -86,7 +83,7 @@ function Surface({ ); } -/** X01 — Redesigned landing: what Explore is, one primary action, what waits once connected. Nothing is recorded. */ +/** X01 — Redesigned landing: what Explore is, one primary action, what waits once connected. */ export const Disconnected: Story = { parameters: { device: 'none' }, render: () => , @@ -97,12 +94,12 @@ export const Waiting: Story = { render: () => , }; -/** Q01 — Overall status above the plot: `Ready to explore`, per-sensor words at left. */ +/** Q01 — Ready: a light status row; the card only earns its weight in the yellow/red states. */ export const QualitySummaryReady: Story = { render: () => , }; -/** Q02 — `Sensors are still settling`: contact can improve over several minutes, never a fixed warm-up. */ +/** Q02 — `Sensors are still settling`: better contact means less static; no fixed warm-up promise. */ export const QualitySummarySettling: Story = { render: () => , }; @@ -112,7 +109,7 @@ export const QualitySummaryAdjustSensors: Story = { render: () => , }; -/** Q04 — `No signal detected`: flat traces, and what to check. */ +/** Q04 — `No signal detected`: headset off or disconnected, and the fix. */ export const QualitySummaryNoSignal: Story = { render: () => , }; @@ -122,7 +119,6 @@ export const NoiseDefinition: Story = { render: () => ( ( + + ), +}; + /** B03 — Blink step 3/4: blink several times so the difference is unmistakable. */ export const BlinkStep3: Story = { render: () => ( @@ -178,7 +189,7 @@ export const BlinkStep3: Story = { ), }; -/** B04 — Blink step 4/4: a blinking interval beside a quiet one, same sensors, same scale. */ +/** B04 — Blink step 4/4: a blinking interval beside a quiet one, same sensors, same scale; the range is adjustable. */ export const BlinkStep4: Story = { render: () => ( ( + + ), +}; + /** B05 — BlinkNotDetected: detection misses or the frontal sensors have not settled; the lesson continues gracefully. */ export const BlinkNotDetected: Story = { render: () => ( @@ -217,7 +242,6 @@ export const NoiseLessonStableColors: Story = { render: () => ( ( ( ( ( ( ( - + {paths.map((d, i) => ( ))} - {bands.map(({ annotation, x, width: bandWidth, ended }) => { + {markerStyle === 'tick' && + annotations + .filter( + (a) => + a.startTime >= snapshot.startTime && + a.startTime <= snapshot.endTime + ) + .map((a) => ( + + ))} + {markerStyle !== 'tick' && + bands.map(({ annotation, x, width: bandWidth, ended }) => { const style = TONE_STYLES[annotation.tone]; const solid = annotation.tone === 'eyes-closed'; const pillW = (text: string) => @@ -231,6 +260,8 @@ export function FixturePlot({ ); })} + {showAxis && ( + <> ))} + + )} @@ -312,92 +345,39 @@ export function PlotCard({ */ export function QualitySummary({ state, - sensors, className, }: { state: QualityState; - sensors: SensorStatus[]; className?: string; }) { const scenario = QUALITY_SCENARIOS[state]; + // The card earns its place in the yellow/red states; ready stays a light row. + const light = scenario.action === ''; return (
-
- - -

- {scenario.heading} -

-
+ + +

+ {scenario.heading} +

+
+ {scenario.action && ( {scenario.action} -
-
- {sensors.map(({ channel, quality }) => ( - - {channel}{' '} - {QUALITY_LABELS[quality]} - - ))} -
-
- ); -} - -/** - * Per-sensor detail as color plus a word plus a fix, so nothing needs color - * interpretation (same idiom as `SignalPrep`). Variability is not impedance. - */ -export function SensorList({ sensors }: { sensors: SensorStatus[] }) { - return ( -
-

Each sensor

-
    - {sensors.map(({ channel, quality }) => { - const meta = ELECTRODES[channel] ?? UNKNOWN_ELECTRODE; - return ( -
  • - - - - {channel} · {meta.location} —{' '} - {QUALITY_LABELS[quality]} - - - {meta.fixes[quality]} - - -
  • - ); - })} -
+ )}
); } @@ -534,31 +514,6 @@ export function LessonStepPanel({ ); } -/** Color legend for the noise demonstration's stable trace colors (§5.2). */ -export function TraceLegend({ - channels, - colors, -}: { - channels: string[]; - colors: string[]; -}) { - return ( -
- {channels.map((channel, i) => ( - - - {channel} - - ))} - — colors stay the same through this lesson -
- ); -} - /** * Frozen five-second comparison window with its peak-to-peak readout, like the * lesson flow's frozen strips. @@ -658,28 +613,144 @@ export function Countdown({ value }: { value: 3 | 2 | 1 }) { } /** - * Optional ideal alpha reference. Labelled `Example`, visually separated by a - * dashed border, and never presented as the expected outcome. + * The review view: an eyes-open segment above an eyes-closed segment of equal + * length, both on one µV scale so the change is directly comparable. `compact` + * renders the same picture small for the `Example` card. + */ +export function SegmentComparison({ + open, + closed, + colors, + scale, + compact, +}: { + open: EEGSnapshot; + closed: EEGSnapshot; + colors: string[]; + scale: number; + compact?: boolean; +}) { + const duration = `${Math.round((open.endTime - open.startTime) / 1000)} SECONDS`; + return ( +
+ {( + [ + { snapshot: open, label: 'EYES OPEN' }, + { snapshot: closed, label: 'EYES CLOSED' }, + ] as const + ).map(({ snapshot, label }) => ( +
+
+ {label} · {duration} +
+
+ +
+
+ ))} +
+ ); +} + +const QUIZ_OPTIONS = [ + { value: 'hump', text: 'A big, slow hump' }, + { value: 'flat', text: 'Not much change' }, +] as const; + +/** + * Prediction options styled as radios (not actions); choosing one immediately + * reveals the expected answer. Nothing is recorded. + */ +export function PredictionQuiz({ + defaultAnswer, +}: { + /** Pre-answered state, for the answered-state story. */ + defaultAnswer?: (typeof QUIZ_OPTIONS)[number]['value']; +}) { + const [answer, setAnswer] = useState< + (typeof QUIZ_OPTIONS)[number]['value'] | null + >(defaultAnswer ?? null); + return ( +
+ Your prediction +
+ {QUIZ_OPTIONS.map((option) => { + const active = answer === option.value; + return ( + + ); + })} +
+ {answer && ( +
+ The expected answer is “a big, slow hump” — every + blink drops one onto the two front sensors. Check the plot to see + yours. +
+ )} +
+ ); +} + +/** + * Optional ideal reference. Labelled `Example`, visually separated by a dashed + * border, and never presented as the expected outcome. Same picture as the + * review view, just small. */ export function AlphaExampleCard() { return (
Example - - - - - before - - - eyes closed - - +

- A clear alpha increase. {ALPHA_EXAMPLE_CAPTION} + {ALPHA_EXAMPLE_CAPTION}

); diff --git a/src/renderer/components/Explore/ExploreScreens.tsx b/src/renderer/components/Explore/ExploreScreens.tsx index 7469d6d1..6a6462a3 100644 --- a/src/renderer/components/Explore/ExploreScreens.tsx +++ b/src/renderer/components/Explore/ExploreScreens.tsx @@ -1,7 +1,11 @@ -import React, { ReactNode } from 'react'; +import React, { ReactNode, useMemo, useState } from 'react'; +import { of } from 'rxjs'; import type { EEGSnapshot, PlotAnnotation } from '../../../shared/eegVizTypes'; +import { PLOTTING_INTERVAL } from '../../constants/constants'; import { EXPLORE_LESSONS } from '../../constants/exploreLessons'; import eegArt from '../../assets/common/EEG.png'; +import { traceColors } from '../../utils/eeg/traceColors'; +import SignalQualityIndicatorComponent from '../SignalQualityIndicatorComponent'; import { Button } from '../ui/button'; import { cn } from '../ui/utils'; import { @@ -9,14 +13,18 @@ import { ALPHA_RESULT_BODY, BLINK_NOT_DETECTED, BLINK_STEPS, + CLOSED_SEGMENT, EYES_END_BODY, EYES_INTRO_BODY, - EYES_INTRO_BODY_2, + EYES_INTRO_OPENER, EYES_INTERVAL_BODY, EYES_PROXY_NOTE, + OPEN_SEGMENT, + PLOT_LEGEND, QualityState, - STABLE_COLOR_BY_CHANNEL, + REVIEW_SEGMENT_SCALE, SensorStatus, + qualitySample, } from './fixtures'; import { AlphaExampleCard, @@ -26,9 +34,9 @@ import { LessonStepPanel, NoiseDefinitionCard, PlotCard, + PredictionQuiz, QualitySummary, - SensorList, - TraceLegend, + SegmentComparison, stepLabel, } from './ExploreParts'; @@ -37,14 +45,9 @@ const SECTION_LABEL = const BODY_TEXT = 'm-0 !text-[16px] leading-normal !tracking-normal [text-wrap:pretty]'; -/** Four or more call sites across the lesson screens keep these lockstep. */ -const stableColors = (channels: string[]) => - channels.map((channel) => STABLE_COLOR_BY_CHANNEL[channel]); - /** * Redesigned disconnected landing: what Explore is, one primary action, and - * what waits on the other side. Pairs with Home's Explore card. Explore never - * records or creates a workspace, and the landing says so. + * what waits on the other side. Pairs with Home's Explore card. */ export function ExploreDisconnected({ onConnect }: { onConnect(): void }) { return ( @@ -62,7 +65,7 @@ export function ExploreDisconnected({ onConnect }: { onConnect(): void }) {

Put on a headset and watch the EEG (electroencephalogram) signal in real time — your own brain's electricity, arriving live. No - experiment to set up, nothing recorded, no workspace created. + experiment to set up.

- -
-
- )} + {step === 2 && } {notDetected && (
{comparison ? ( <> + {step === 4 && ( +
+ Plot range + {( + [ + { value: 'wide', text: '±150 µV' }, + { value: 'narrow', text: '±50 µV' }, + ] as const + ).map((option) => { + const active = range === option.value; + return ( + + ); + })} +
+ )} @@ -323,12 +353,10 @@ export function BlinkLessonView({ ) : ( 0 && step < 4 ? ( + step > 0 && step < 3 ? ( {caught} {caught === 1 ? 'blink' : 'blinks'} marked @@ -338,18 +366,13 @@ export function BlinkLessonView({ )} - {showLegend && ( - - )}
); @@ -366,11 +389,10 @@ export interface EyesClosedViewProps { phase: EyesClosedPhase; /** 3–2–1 position during the countdown. */ countdown?: 3 | 2 | 1; - /** Measured 8–12 Hz ratio (eyes-closed ÷ before); null = not enough data. */ - alphaRatio: number | null; + /** Measured comparison (eyes-closed ÷ before); null = not enough data. */ + rhythmRatio: number | null; showExample?: boolean; snapshot: EEGSnapshot; - annotations?: PlotAnnotation[]; onBack(): void; onNext(): void; onExit(): void; @@ -384,19 +406,19 @@ export interface EyesClosedViewProps { export function EyesClosedView({ phase, countdown = 3, - alphaRatio, + rhythmRatio, showExample, snapshot, - annotations, onBack, onNext, onExit, }: EyesClosedViewProps) { const running = phase === 'countdown' || phase === 'interval'; + const increase = rhythmRatio != null && rhythmRatio > 1; const resultBody = - alphaRatio == null - ? 'There is not enough continuous posterior-channel data to compare alpha power. The marked interval is still saved below.' - : alphaRatio > 1 + rhythmRatio == null + ? 'There is not enough continuous data from the back of your head to compare. The marked interval is still saved below.' + : increase ? ALPHA_RESULT_BODY : ALPHA_NO_EFFECT_BODY; return ( @@ -407,7 +429,7 @@ export function EyesClosedView({ steps={0} title={ { - intro: 'Close your eyes until the two chimes', + intro: 'Keep your eyes closed until you hear two chimes', countdown: 'Starting…', interval: 'Close your eyes', end: 'Open your eyes', @@ -424,8 +446,8 @@ export function EyesClosedView({ body={ phase === 'intro' ? (
+ {EYES_INTRO_OPENER} {EYES_INTRO_BODY} - {EYES_INTRO_BODY_2}
) : phase === 'countdown' ? ( 'Close your eyes when you hear the single chime.' @@ -456,11 +478,11 @@ export function EyesClosedView({ {phase === 'review' && ( <>
- {alphaRatio == null - ? 'Measured 8–12 Hz power: not enough data this time.' - : alphaRatio > 1 - ? `Measured 8–12 Hz power: ${alphaRatio.toFixed(1)}× the five seconds before you closed your eyes.` - : 'Measured 8–12 Hz power: about the same as the five seconds before.'} + {rhythmRatio == null + ? 'Comparison: not enough data this time.' + : increase + ? `The steady rhythm from the back of your head was ${rhythmRatio.toFixed(1)}× as strong with your eyes closed.` + : 'The steady rhythm from the back of your head was about as strong as before.'}
{showExample && }
@@ -470,22 +492,28 @@ export function EyesClosedView({ )}
- - - + {phase === 'review' ? ( + + + + ) : ( + + + + )} {(running || phase === 'end') && (
{phase === 'countdown' ? ( diff --git a/src/renderer/components/Explore/fixtures.ts b/src/renderer/components/Explore/fixtures.ts index 5b0bc9ee..211de93a 100644 --- a/src/renderer/components/Explore/fixtures.ts +++ b/src/renderer/components/Explore/fixtures.ts @@ -1,24 +1,19 @@ import type { EEGSnapshot, PlotAnnotation } from '../../../shared/eegVizTypes'; -import { SIGNAL_QUALITY } from '../../constants/constants'; +import { + MUSE_SAMPLING_RATE, + SIGNAL_QUALITY, +} from '../../constants/constants'; +import type { SignalQualityData } from '../../constants/interfaces'; /** Muse montage order, matching the live viewer's channel order. */ export const EXPLORE_CHANNELS = ['TP9', 'AF7', 'AF8', 'TP10']; export const FRONTAL_CHANNELS = ['AF7', 'AF8']; export const POSTERIOR_CHANNELS = ['TP9', 'TP10']; -export const SAMPLING_RATE = 256; +export const SAMPLING_RATE = MUSE_SAMPLING_RATE; -/** - * Stable per-channel trace colors for the noise demonstration. Signal-quality - * colors would repaint the lines while the student is watching them move, so - * the demo uses these fixed hues instead (plan §5.2). - */ -export const STABLE_TRACE_COLORS = ['#4263eb', '#9c36b5', '#f08c00', '#1098ad']; -export const STABLE_COLOR_BY_CHANNEL: Record = { - TP9: STABLE_TRACE_COLORS[0], - AF7: STABLE_TRACE_COLORS[1], - AF8: STABLE_TRACE_COLORS[2], - TP10: STABLE_TRACE_COLORS[3], -}; +/** Device identity for the plot legend; integration reads both from deviceInfo. */ +export const DEVICE_NAME = 'Muse-1A2B'; +export const PLOT_LEGEND = `${DEVICE_NAME} · ${SAMPLING_RATE} Hz`; /** Deterministic PRNG so every screenshot of the synthetic signal is identical. */ function mulberry32(seed: number) { @@ -47,7 +42,8 @@ export interface TraceSpec { /** * Synthetic multi-channel EEG for the fixture plot: background 10 Hz / 5 Hz * rhythms with random phase, sensor noise, blink humps weighted to the frontal - * sensors, and an alpha burst weighted to TP9/TP10. Not recorded data. + * sensors, and a steady back-of-head rhythm weighted to TP9/TP10 while the + * eyes are closed. Not recorded data. */ export function makeSnapshot( channels: string[], @@ -78,7 +74,7 @@ export function makeSnapshot( (spec.alphaMs[1] - t) / 400 ); value += - posterior * 22 * edge * Math.sin((2 * Math.PI * 10.5 * t) / 1000); + posterior * 15 * edge * Math.sin((2 * Math.PI * 10.5 * t) / 1000); } samples[n] = value * (spec.gain ?? 1); } @@ -90,13 +86,15 @@ export function makeSnapshot( data, channels, samplingRate: SAMPLING_RATE, - peakToPeak: Math.max( - ...data.map((s) => Math.max(...s) - Math.min(...s)) - ), + peakToPeak: Math.max(...data.map((s) => Math.max(...s) - Math.min(...s))), }; } -const blinkBand = (id: string, startTime: number, endTime: number): PlotAnnotation => ({ +const blinkBand = ( + id: string, + startTime: number, + endTime: number +): PlotAnnotation => ({ id: `blink-${id}`, startTime, endTime, @@ -109,7 +107,7 @@ export const LIVE_SNAPSHOT = makeSnapshot(EXPLORE_CHANNELS, 5000, { seed: 11, }); -/** Flat traces for the `No signal detected` state: contact, not data. */ +/** Flat traces for the `No signal detected` state: no contact, not data. */ export const NO_SIGNAL_SNAPSHOT = makeSnapshot(EXPLORE_CHANNELS, 5000, { seed: 12, gain: 0.04, @@ -141,7 +139,7 @@ export const BLINK_MANY_ANNOTATIONS = [ blinkBand('m4', 3950, 4750), ]; -/** Stable-color demo: all four sensors while blinking, for the §5.2 story. */ +/** Stable-color demo: all four sensors while blinking (plan §5.2). */ export const BLINK_MANY_ALL = makeSnapshot(EXPLORE_CHANNELS, 5000, { seed: 24, blinksMs: [900, 2050, 3250, 4350], @@ -155,45 +153,62 @@ export const BLINKING_SNAPSHOT = makeSnapshot(FRONTAL_CHANNELS, 5000, { seed: 32, blinksMs: [1150, 2350, 3650], }); -export const COMPARISON_SHARED_SCALE = 150; export const COMPARISON_RATIO = BLINKING_SNAPSHOT.peakToPeak / CALM_SNAPSHOT.peakToPeak; -/** Eyes-closed review: the marked interval with an alpha burst on TP9/TP10. */ -export const EYES_CLOSED_SNAPSHOT = makeSnapshot(EXPLORE_CHANNELS, 15500, { - seed: 41, - alphaMs: [3000, 13000], -}); -export const EYES_CLOSED_ANNOTATIONS: PlotAnnotation[] = [ - { - id: 'eyes-closed', - startTime: 3000, - endTime: 13000, - label: 'eyes closed', - endLabel: 'eyes open', - tone: 'eyes-closed', - }, -]; - /** Live window shown during the countdown and interval. */ export const EYES_CLOSED_LIVE = makeSnapshot(EXPLORE_CHANNELS, 5000, { seed: 42, }); -/** Measured 8–12 Hz comparison for the result stories (eyes-closed ÷ before). */ -export const ALPHA_INCREASE_RATIO = 2.4; -export const ALPHA_NO_EFFECT_RATIO = 0.9; +/** + * The review view: equal 3-second segments from the eyes-open and eyes-closed + * parts of the marked interval, on one shared µV scale. At this length a + * ~10 Hz rhythm is ~29 px per cycle at 1366×768 — visible with real Muse + * amplitudes; 10-second segments would compress it to ~9 px (mush). + */ +export const REVIEW_SEGMENT_MS = 3000; +export const REVIEW_SEGMENT_SCALE = 50; +export const OPEN_SEGMENT = makeSnapshot(POSTERIOR_CHANNELS, REVIEW_SEGMENT_MS, { + seed: 51, + gain: 0.6, +}); +export const CLOSED_SEGMENT = makeSnapshot(POSTERIOR_CHANNELS, REVIEW_SEGMENT_MS, { + seed: 52, + alphaMs: [0, REVIEW_SEGMENT_MS], +}); + +/** Measured comparison (eyes-closed ÷ before) for the result stories. */ +export const RHYTHM_INCREASE_RATIO = 2.4; +export const RHYTHM_NO_EFFECT_RATIO = 0.9; export interface SensorStatus { channel: string; quality: SIGNAL_QUALITY; } +/** A one-emission fixture stream for `SignalQualityIndicatorComponent`. */ +export function qualitySample(sensors: SensorStatus[]): SignalQualityData { + return { + data: sensors.map(() => []), + info: { + samplingRate: SAMPLING_RATE, + startTime: 0, + signalQuality: Object.fromEntries(sensors.map((s) => [s.channel, 2])), + }, + signalQuality: Object.fromEntries( + sensors.map((s) => [s.channel, s.quality]) + ), + }; +} + export type QualityState = 'ready' | 'settling' | 'adjust' | 'no-signal'; /** * Overall status above the plot (plan §5.1). The four headings are the - * product's wording; `action` names what to do where an action exists. + * product's wording; `action` names what to do where an action exists. The + * ready state stays light — the card mainly earns its place in the yellow and + * red states, where it gives real instructions. */ export const QUALITY_SCENARIOS: Record< QualityState, @@ -201,7 +216,7 @@ export const QUALITY_SCENARIOS: Record< > = { ready: { heading: 'Ready to explore', - action: 'Sit still and watch your signal, then start a lesson below.', + action: '', sensors: EXPLORE_CHANNELS.map((channel) => ({ channel, quality: SIGNAL_QUALITY.GREAT, @@ -210,11 +225,10 @@ export const QUALITY_SCENARIOS: Record< settling: { heading: 'Sensors are still settling', action: - 'Contact can improve over several minutes. Sit still and let the measurements calm down.', + 'Better contact means less static. Give the sensors a few minutes to settle, and keep still.', sensors: EXPLORE_CHANNELS.map((channel) => ({ channel, - quality: - channel === 'TP10' ? SIGNAL_QUALITY.GREAT : SIGNAL_QUALITY.OK, + quality: channel === 'TP10' ? SIGNAL_QUALITY.GREAT : SIGNAL_QUALITY.OK, })), }, adjust: { @@ -234,7 +248,7 @@ export const QUALITY_SCENARIOS: Record< 'no-signal': { heading: 'No signal detected', action: - 'Check that the headset is on your head and every sensor is touching your skin.', + 'Your headset is off or disconnected. Turn it on or reconnect it, then check that the sensors touch your skin.', sensors: EXPLORE_CHANNELS.map((channel) => ({ channel, quality: SIGNAL_QUALITY.DISCONNECTED, @@ -255,7 +269,7 @@ export const NOISE_DEFINITION = 'Noise is electrical activity the headset records that did not come from the brain signal we are trying to measure. Blinks, jaw tension, movement, and poor sensor contact can all create noise.'; export const NOISE_SETTLING_NOTE = - 'Not a sound — think of it as static in the recording. Sensor contact often improves over several minutes while the sensors sit on your skin; there is no fixed warm-up time. The live measurements tell you when you are ready.'; + 'Not a sound — think of it as static in the recording. Better contact means less static: it usually improves over several minutes as the sensors settle onto your skin, and there is no fixed warm-up time.'; /** Blink lesson steps (plan §5.3). `action` is the expected step at a glance. */ export const BLINK_STEPS: { @@ -274,39 +288,35 @@ export const BLINK_STEPS: { body: 'The lines are flat again. Before you blink, decide what the next blink will look like — then blink once and see whether you were right.', }, { - title: 'Blink several times so it is unmistakable', + title: 'Now blink several times in a row!', action: 'Blink hard, three or four times in a row.', - body: 'Each blink slams another hump into the front sensors. This is the loudest thing in most student recordings — and seeing it land every time is how you know your headset is really hearing you.', + body: 'Blinks are one of the loudest things in your signal. Seeing each one land clearly is good news: it means the front sensors are touching your skin and really picking you up.', }, { title: 'Compare the blinking interval with a quiet interval', - action: 'Look at both windows, same sensors and same scale.', + action: 'Now sit still, eyes open, for 5 seconds.', body: 'One frozen five seconds while you were blinking, one while you sat still. Your brain signal is in both — the blinks just tower over it. This is why researchers ask you to hold still.', }, ]; export const BLINK_NOT_DETECTED = - 'We cannot see your blinks yet. Check that AF7 and AF8 sit flat against your forehead, then try again. Detection is not required — Next stays open whenever you are ready to move on.'; + 'We cannot see your blinks yet. Check that AF7 and AF8 sit flat against your forehead, then try again.'; +export const EYES_INTRO_OPENER = + 'Let’s look at how your brain signal changes when you close your eyes.'; export const EYES_INTRO_BODY = 'Two sounds guide this activity. One chime means close your eyes now. Two chimes, about ten seconds later, mean open them again. Nothing on screen needs watching in between.'; -export const EYES_INTRO_BODY_2 = - 'When you press Begin, a visible 3–2–1 countdown comes first, so the start never surprises you.'; -export const EYES_INTERVAL_BODY = - 'Keep them closed until you hear two chimes. Sitting still is fine — the screen can wait.'; +export const EYES_PROXY_NOTE = + 'Muse has no sensors over the visual cortex — TP9 and TP10 behind the ears are the closest look at the back of your head.'; -export const EYES_END_BODY = - 'Two chimes just ended the activity. Your marked interval is saved on the plot below — take a look.'; +export const EYES_INTERVAL_BODY = 'Keep them closed until you hear two chimes.'; -export const ALPHA_RESULT_BODY = - 'The teal band marks the ten seconds your eyes were closed. The back of your head hums a steady rhythm when it has nothing to look at — that hum is alpha, and it is the seeing part of your brain getting louder.'; +export const EYES_END_BODY = 'Let’s look at your brainwaves.'; +export const ALPHA_RESULT_BODY = + 'With your eyes closed, the seeing part of your brain has nothing to look at — and it gets louder.'; export const ALPHA_NO_EFFECT_BODY = - 'Alpha did not increase in this interval. That is a real result, not a failed lesson — alpha is clearest in some people and nearly invisible in others. Blinking still worked, and that one really was your eyelid.'; - -export const EYES_PROXY_NOTE = - 'Muse has no sensors over the visual cortex — TP9 and TP10 behind the ears are the closest available posterior-side look at alpha.'; - + 'That is a real result, not a failed lesson — this change is clearest in some people and nearly invisible in others.'; export const ALPHA_EXAMPLE_CAPTION = 'Some recordings look like this. Yours will be yours.'; \ No newline at end of file diff --git a/src/renderer/utils/eeg/traceColors.ts b/src/renderer/utils/eeg/traceColors.ts new file mode 100644 index 00000000..99e23933 --- /dev/null +++ b/src/renderer/utils/eeg/traceColors.ts @@ -0,0 +1,11 @@ +import { interpolateRainbow } from 'd3'; + +/** + * Stable per-channel trace colors sampled from d3's rainbow (i / n), so any + * channel count gets distinct hues without a fixed palette. Used where traces + * must keep their color while the signal-quality colors change underneath + * (the noise demonstration). d3 ships no types; the import is untyped. + */ +export function traceColors(count: number): string[] { + return Array.from({ length: count }, (_, i) => interpolateRainbow(i / count)); +} From 37046d395700276b8f4fc01788c56bd15c7ae4cd Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 27 Sep 2026 16:29:59 -0400 Subject: [PATCH 4/6] =?UTF-8?q?design(WS3):=20review=20fixes=20=E2=80=94?= =?UTF-8?q?=20hugging=20head=20diagram=20with=20state=20glyphs,=20intro=20?= =?UTF-8?q?headline,=20plain-language=20copy,=20accurate=20blink=20label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/Explore/ExploreParts.tsx | 85 +++++++++++++++++++ .../components/Explore/ExploreScreens.tsx | 27 ++---- src/renderer/components/Explore/fixtures.ts | 4 +- 3 files changed, 96 insertions(+), 20 deletions(-) diff --git a/src/renderer/components/Explore/ExploreParts.tsx b/src/renderer/components/Explore/ExploreParts.tsx index f0a37bbf..754176ac 100644 --- a/src/renderer/components/Explore/ExploreParts.tsx +++ b/src/renderer/components/Explore/ExploreParts.tsx @@ -1,5 +1,7 @@ import React, { ReactNode, useEffect, useId, useRef, useState } from 'react'; import type { EEGSnapshot, PlotAnnotation } from '../../../shared/eegVizTypes'; +import { PLOTTING_INTERVAL, SIGNAL_QUALITY } from '../../constants/constants'; +import SignalQualityIndicatorComponent from '../SignalQualityIndicatorComponent'; import { Button } from '../ui/button'; import { cn } from '../ui/utils'; import { @@ -12,6 +14,7 @@ import { QUALITY_STATE_TONE, QualityState, REVIEW_SEGMENT_SCALE, + SensorStatus, } from './fixtures'; import { traceColors } from '../../utils/eeg/traceColors'; @@ -612,6 +615,88 @@ export function Countdown({ value }: { value: 3 | 2 | 1 }) { ); } +/** + * The existing head diagram with a shape cue next to each electrode (check / + * dash / cross / ring), so per-sensor state never depends on color alone. The + * coordinates mirror `SignalQualityIndicatorSVG`'s Muse electrode positions. + */ +const ELECTRODE_XY: Record = { + TP9: { x: 98.87, y: 455.81 }, + AF7: { x: 208.33, y: 166.08 }, + AF8: { x: 467.66, y: 166.08 }, + TP10: { x: 571.87, y: 455.81 }, +}; + +function StateGlyph({ + x, + y, + quality, +}: { + x: number; + y: number; + quality: SIGNAL_QUALITY; +}) { + const stroke = { + stroke: '#1a1a1a', + strokeWidth: 7, + fill: 'none', + strokeLinecap: 'round' as const, + strokeLinejoin: 'round' as const, + }; + const gx = x + 34; + const gy = y - 34; + if (quality === SIGNAL_QUALITY.GREAT) + return ( + + ); + if (quality === SIGNAL_QUALITY.OK) + return ; + if (quality === SIGNAL_QUALITY.BAD) + return ( + + + + + ); + return ; +} + +export function HeadDiagram({ + sensors, + observable, + height = 200, +}: { + sensors: SensorStatus[]; + observable: Parameters[0]['signalQualityObservable']; + height?: number; +}) { + return ( +
+ s.channel)} + /> + + {sensors.map(({ channel, quality }) => { + const at = ELECTRODE_XY[channel]; + return at ? ( + + ) : null; + })} + +
+ ); +} + /** * The review view: an eyes-open segment above an eyes-closed segment of equal * length, both on one µV scale so the change is directly comparable. `compact` diff --git a/src/renderer/components/Explore/ExploreScreens.tsx b/src/renderer/components/Explore/ExploreScreens.tsx index 6a6462a3..cb6d9a0b 100644 --- a/src/renderer/components/Explore/ExploreScreens.tsx +++ b/src/renderer/components/Explore/ExploreScreens.tsx @@ -1,11 +1,9 @@ import React, { ReactNode, useMemo, useState } from 'react'; import { of } from 'rxjs'; import type { EEGSnapshot, PlotAnnotation } from '../../../shared/eegVizTypes'; -import { PLOTTING_INTERVAL } from '../../constants/constants'; import { EXPLORE_LESSONS } from '../../constants/exploreLessons'; import eegArt from '../../assets/common/EEG.png'; import { traceColors } from '../../utils/eeg/traceColors'; -import SignalQualityIndicatorComponent from '../SignalQualityIndicatorComponent'; import { Button } from '../ui/button'; import { cn } from '../ui/utils'; import { @@ -31,6 +29,7 @@ import { Countdown, FixturePlot, FrozenStrip, + HeadDiagram, LessonStepPanel, NoiseDefinitionCard, PlotCard, @@ -81,8 +80,8 @@ export function ExploreDisconnected({ onConnect }: { onConnect(): void }) { 1. See whether your signal is usable. 2. Learn what “noise” means here — it is not a sound. - 3. Make your own blink, then your alpha rhythm, show up on the - plot. + 3. Make your own blink show up on the plot — then watch the + seeing part of your brain get louder when you close your eyes.
@@ -184,14 +183,9 @@ export function ExploreSurface({ ) : ( )} -
-
- s.channel)} - /> +
+
+
- {EYES_INTRO_OPENER} - {EYES_INTRO_BODY} -
+ EYES_INTRO_BODY ) : phase === 'countdown' ? ( 'Close your eyes when you hear the single chime.' ) : phase === 'interval' ? ( @@ -461,7 +452,7 @@ export function EyesClosedView({ } nextLabel={ { - intro: 'Begin eyes-closed activity', + intro: 'Begin', countdown: 'Starting…', interval: 'Recording…', end: 'See your result', diff --git a/src/renderer/components/Explore/fixtures.ts b/src/renderer/components/Explore/fixtures.ts index 211de93a..12c6edb9 100644 --- a/src/renderer/components/Explore/fixtures.ts +++ b/src/renderer/components/Explore/fixtures.ts @@ -98,7 +98,7 @@ const blinkBand = ( id: `blink-${id}`, startTime, endTime, - label: 'blink · eye muscle, not brain', + label: 'blink · from your eyes, not your brain', tone: 'blink', }); @@ -303,7 +303,7 @@ export const BLINK_NOT_DETECTED = 'We cannot see your blinks yet. Check that AF7 and AF8 sit flat against your forehead, then try again.'; export const EYES_INTRO_OPENER = - 'Let’s look at how your brain signal changes when you close your eyes.'; + 'Let’s look at how your brain signal changes when you close your eyes'; export const EYES_INTRO_BODY = 'Two sounds guide this activity. One chime means close your eyes now. Two chimes, about ten seconds later, mean open them again. Nothing on screen needs watching in between.'; From 2877f7ded49b76dc915bfdc1384b855d209d9562 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 27 Sep 2026 18:38:38 -0400 Subject: [PATCH 5/6] design(WS3): defer to existing signal-quality components, channel-stable rainbow colors --- .../components/Explore/Explore.stories.tsx | 5 + .../components/Explore/ExploreParts.tsx | 98 ++----------------- .../components/Explore/ExploreScreens.tsx | 61 +++++++----- src/renderer/utils/eeg/traceColors.ts | 14 +-- 4 files changed, 58 insertions(+), 120 deletions(-) diff --git a/src/renderer/components/Explore/Explore.stories.tsx b/src/renderer/components/Explore/Explore.stories.tsx index cf378baf..a8a8b336 100644 --- a/src/renderer/components/Explore/Explore.stories.tsx +++ b/src/renderer/components/Explore/Explore.stories.tsx @@ -71,12 +71,17 @@ function Surface({ } & Partial>) { const sensors = QUALITY_SCENARIOS[state === 'waiting' ? 'ready' : state].sensors; + const [hoveredChannel, setHoveredChannel] = React.useState( + null + ); return ( diff --git a/src/renderer/components/Explore/ExploreParts.tsx b/src/renderer/components/Explore/ExploreParts.tsx index 754176ac..beb53040 100644 --- a/src/renderer/components/Explore/ExploreParts.tsx +++ b/src/renderer/components/Explore/ExploreParts.tsx @@ -1,12 +1,11 @@ import React, { ReactNode, useEffect, useId, useRef, useState } from 'react'; import type { EEGSnapshot, PlotAnnotation } from '../../../shared/eegVizTypes'; -import { PLOTTING_INTERVAL, SIGNAL_QUALITY } from '../../constants/constants'; -import SignalQualityIndicatorComponent from '../SignalQualityIndicatorComponent'; import { Button } from '../ui/button'; import { cn } from '../ui/utils'; import { ALPHA_EXAMPLE_CAPTION, CLOSED_SEGMENT, + EXPLORE_CHANNELS, NOISE_DEFINITION, NOISE_SETTLING_NOTE, OPEN_SEGMENT, @@ -14,9 +13,8 @@ import { QUALITY_STATE_TONE, QualityState, REVIEW_SEGMENT_SCALE, - SensorStatus, } from './fixtures'; -import { traceColors } from '../../utils/eeg/traceColors'; +import { channelColor } from '../../utils/eeg/traceColors'; /** Small uppercase label for lesson step counters and section titles. */ export const stepLabel = @@ -362,7 +360,7 @@ export function QualitySummary({ className={cn( 'flex flex-none flex-wrap items-baseline gap-x-[14px] gap-y-[2px]', !light && - 'rounded-lg border border-gray-200 bg-white px-[18px] py-[12px]', + 'rounded-lg border border-gray-200 bg-white px-[18px] py-[10px]', className )} > @@ -615,88 +613,6 @@ export function Countdown({ value }: { value: 3 | 2 | 1 }) { ); } -/** - * The existing head diagram with a shape cue next to each electrode (check / - * dash / cross / ring), so per-sensor state never depends on color alone. The - * coordinates mirror `SignalQualityIndicatorSVG`'s Muse electrode positions. - */ -const ELECTRODE_XY: Record = { - TP9: { x: 98.87, y: 455.81 }, - AF7: { x: 208.33, y: 166.08 }, - AF8: { x: 467.66, y: 166.08 }, - TP10: { x: 571.87, y: 455.81 }, -}; - -function StateGlyph({ - x, - y, - quality, -}: { - x: number; - y: number; - quality: SIGNAL_QUALITY; -}) { - const stroke = { - stroke: '#1a1a1a', - strokeWidth: 7, - fill: 'none', - strokeLinecap: 'round' as const, - strokeLinejoin: 'round' as const, - }; - const gx = x + 34; - const gy = y - 34; - if (quality === SIGNAL_QUALITY.GREAT) - return ( - - ); - if (quality === SIGNAL_QUALITY.OK) - return ; - if (quality === SIGNAL_QUALITY.BAD) - return ( - - - - - ); - return ; -} - -export function HeadDiagram({ - sensors, - observable, - height = 200, -}: { - sensors: SensorStatus[]; - observable: Parameters[0]['signalQualityObservable']; - height?: number; -}) { - return ( -
- s.channel)} - /> - - {sensors.map(({ channel, quality }) => { - const at = ELECTRODE_XY[channel]; - return at ? ( - - ) : null; - })} - -
- ); -} - /** * The review view: an eyes-open segment above an eyes-closed segment of equal * length, both on one µV scale so the change is directly comparable. `compact` @@ -831,7 +747,9 @@ export function AlphaExampleCard() { compact open={OPEN_SEGMENT} closed={CLOSED_SEGMENT} - colors={traceColors(OPEN_SEGMENT.channels.length)} + colors={OPEN_SEGMENT.channels.map((ch) => + channelColor(ch, EXPLORE_CHANNELS) + )} scale={REVIEW_SEGMENT_SCALE} />

@@ -859,7 +777,7 @@ export function ErrorBanner({ return (

@@ -879,7 +797,7 @@ export function ErrorBanner({
{unsupported && ( -
+
{unsupported}
)} diff --git a/src/renderer/components/Explore/ExploreScreens.tsx b/src/renderer/components/Explore/ExploreScreens.tsx index cb6d9a0b..f0a19fb1 100644 --- a/src/renderer/components/Explore/ExploreScreens.tsx +++ b/src/renderer/components/Explore/ExploreScreens.tsx @@ -1,9 +1,12 @@ import React, { ReactNode, useMemo, useState } from 'react'; import { of } from 'rxjs'; import type { EEGSnapshot, PlotAnnotation } from '../../../shared/eegVizTypes'; +import { PLOTTING_INTERVAL } from '../../constants/constants'; import { EXPLORE_LESSONS } from '../../constants/exploreLessons'; import eegArt from '../../assets/common/EEG.png'; -import { traceColors } from '../../utils/eeg/traceColors'; +import { channelColor } from '../../utils/eeg/traceColors'; +import ExploreSensorCard from '../ExploreSensorCard'; +import SignalQualityIndicatorComponent from '../SignalQualityIndicatorComponent'; import { Button } from '../ui/button'; import { cn } from '../ui/utils'; import { @@ -12,6 +15,7 @@ import { BLINK_NOT_DETECTED, BLINK_STEPS, CLOSED_SEGMENT, + EXPLORE_CHANNELS, EYES_END_BODY, EYES_INTRO_BODY, EYES_INTRO_OPENER, @@ -29,7 +33,6 @@ import { Countdown, FixturePlot, FrozenStrip, - HeadDiagram, LessonStepPanel, NoiseDefinitionCard, PlotCard, @@ -39,11 +42,13 @@ import { stepLabel, } from './ExploreParts'; -const SECTION_LABEL = - 'm-0 text-[14px] font-bold uppercase tracking-[0.5px] text-ink-muted'; const BODY_TEXT = 'm-0 !text-[16px] leading-normal !tracking-normal [text-wrap:pretty]'; +/** Channel colors from the full device list, so down-selected views agree. */ +const colorsFor = (channels: string[]) => + channels.map((channel) => channelColor(channel, EXPLORE_CHANNELS)); + /** * Redesigned disconnected landing: what Explore is, one primary action, and * what waits on the other side. Pairs with Home's Explore card. @@ -74,17 +79,6 @@ export function ExploreDisconnected({ onConnect }: { onConnect(): void }) { Takes about 30 seconds
-
-

Once you're connected

-
- 1. See whether your signal is usable. - 2. Learn what “noise” means here — it is not a sound. - - 3. Make your own blink show up on the plot — then watch the - seeing part of your brain get louder when you close your eyes. - -
-
@@ -138,6 +132,9 @@ export interface ExploreSurfaceProps { snapshot: EEGSnapshot | null; /** Quality colors per channel: the main surface teaches signal quality. */ colors: string[]; + /** Shared hover state between the head diagram and the sensor card. */ + hoveredChannel: string | null; + onHoveredChannelChange(channel: string | null): void; onStartLesson(): void; /** Above the status summary, e.g. the stream-error banner. */ banner?: ReactNode; @@ -153,16 +150,19 @@ export function ExploreSurface({ sensors, snapshot, colors, + hoveredChannel, + onHoveredChannelChange, onStartLesson, banner, }: ExploreSurfaceProps) { const waiting = quality === 'waiting'; - const head = useMemo( - () => (waiting ? null : of(qualitySample(sensors))), + const sample = useMemo( + () => (waiting ? null : qualitySample(sensors)), [waiting, sensors] ); + const head = useMemo(() => (sample ? of(sample) : null), [sample]); return ( -
+
{banner} {waiting ? (
)} -
-
- +
+
+ s.channel)} + hoveredChannel={hoveredChannel} + onHoveredChannelChange={onHoveredChannelChange} + /> + s.channel)} + sample={sample} + hoveredChannel={hoveredChannel} + onHoveredChannelChange={onHoveredChannelChange} + />
('wide'); - const colors = traceColors(snapshot.channels.length); + const colors = colorsFor(snapshot.channels); const caught = (annotations ?? []).length; return (
@@ -488,7 +501,7 @@ export function EyesClosedView({ @@ -499,7 +512,7 @@ export function EyesClosedView({ > diff --git a/src/renderer/utils/eeg/traceColors.ts b/src/renderer/utils/eeg/traceColors.ts index 99e23933..da744963 100644 --- a/src/renderer/utils/eeg/traceColors.ts +++ b/src/renderer/utils/eeg/traceColors.ts @@ -1,11 +1,13 @@ import { interpolateRainbow } from 'd3'; /** - * Stable per-channel trace colors sampled from d3's rainbow (i / n), so any - * channel count gets distinct hues without a fixed palette. Used where traces - * must keep their color while the signal-quality colors change underneath - * (the noise demonstration). d3 ships no types; the import is untyped. + * A channel's trace color from d3's rainbow, indexed by its position in the + * device's full channel list so the color never changes when a view + * down-selects channels. d3 ships no types; the import is untyped. */ -export function traceColors(count: number): string[] { - return Array.from({ length: count }, (_, i) => interpolateRainbow(i / count)); +export function channelColor( + name: string, + allChannels: readonly string[] +): string { + return interpolateRainbow(allChannels.indexOf(name) / allChannels.length); } From b5762d813c111bf8b4dc4fe8cce77887420a5398 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 27 Sep 2026 18:51:03 -0400 Subject: [PATCH 6/6] design(WS3): split stream-error and unsupported-channel states, fix blink copy --- .../components/Explore/Explore.stories.tsx | 25 +++++++++++- .../components/Explore/ExploreParts.tsx | 38 +++++++------------ src/renderer/components/Explore/fixtures.ts | 11 +++++- 3 files changed, 47 insertions(+), 27 deletions(-) diff --git a/src/renderer/components/Explore/Explore.stories.tsx b/src/renderer/components/Explore/Explore.stories.tsx index a8a8b336..784e7560 100644 --- a/src/renderer/components/Explore/Explore.stories.tsx +++ b/src/renderer/components/Explore/Explore.stories.tsx @@ -17,6 +17,8 @@ import { COMPARISON_RATIO, EYES_CLOSED_LIVE, LIVE_SNAPSHOT, + NO_AF8_SENSORS, + NO_AF8_SNAPSHOT, NO_SIGNAL_SNAPSHOT, QUALITY_SCENARIOS, QualityState, @@ -358,7 +360,7 @@ export const AlphaNoEffect: Story = { ), }; -/** X03 — Stream error and unsupported channels: both say exactly what to do. */ +/** X03 — StreamError: the stream stopped; the message says exactly what to do. */ export const StreamError: Story = { render: () => ( + } + /> + ), +}; + +/** X04 — UnsupportedChannels: this headset reports no AF8; the fixture agrees. */ +export const UnsupportedChannels: Story = { + render: () => ( + } /> diff --git a/src/renderer/components/Explore/ExploreParts.tsx b/src/renderer/components/Explore/ExploreParts.tsx index beb53040..7445fcc3 100644 --- a/src/renderer/components/Explore/ExploreParts.tsx +++ b/src/renderer/components/Explore/ExploreParts.tsx @@ -765,42 +765,32 @@ export function ErrorBanner({ body, actionLabel, onAction, - unsupported, }: { title: string; body: string; actionLabel: string; onAction(): void; - /** Unsupported-channel note, when the headset reports unusable channels. */ - unsupported?: string; }) { return (
-
-
- - ! - -
-
{title}
-
{body}
-
+
+ + ! + +
+
{title}
+
{body}
-
- {unsupported && ( -
- {unsupported} -
- )} +
); } \ No newline at end of file diff --git a/src/renderer/components/Explore/fixtures.ts b/src/renderer/components/Explore/fixtures.ts index 12c6edb9..8f463848 100644 --- a/src/renderer/components/Explore/fixtures.ts +++ b/src/renderer/components/Explore/fixtures.ts @@ -113,6 +113,15 @@ export const NO_SIGNAL_SNAPSHOT = makeSnapshot(EXPLORE_CHANNELS, 5000, { gain: 0.04, }); +/** A headset that reports no AF8: the unsupported-channel state. */ +export const NO_AF8_CHANNELS = ['TP9', 'AF7', 'TP10']; +export const NO_AF8_SENSORS: SensorStatus[] = NO_AF8_CHANNELS.map( + (channel) => ({ channel, quality: SIGNAL_QUALITY.GREAT }) +); +export const NO_AF8_SNAPSHOT = makeSnapshot(NO_AF8_CHANNELS, 5000, { + seed: 13, +}); + /** Blink step 1: one blink, one marked response. */ export const BLINK_ONE = makeSnapshot(FRONTAL_CHANNELS, 5000, { seed: 21, @@ -280,7 +289,7 @@ export const BLINK_STEPS: { { title: 'Blink once and find the marked response', action: 'Blink once, then keep still and watch AF7 and AF8.', - body: 'A blink drops one big slow hump onto the two front sensors — your eyelid muscle moving, not your brain thinking. When we spot one, the plot marks it with a gold band.', + body: 'A blink drops one big slow hump onto the two front sensors — from your eyes moving, not your brain thinking. When we spot one, the plot marks it with a gold band.', }, { title: 'Predict what another blink will do',