Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion TODOS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ Deferred and in-flight work. Keep this current — when something ships, delete
- [ ] **Hooks / function-component migration (AI-friendly).** Most student screens are still class components + `react-redux` `connect()` / `bindActionCreators` containers (`src/renderer/containers/`). Newer work (`EpochReviewer`, `LiveErpPane`, `RunComponent`, `AppShell`, the redesigned Clean/Analyze/Explore screens) is hooks. Incremental, screen-by-screen; do not big-bang. Pattern to copy: hooks + `useDispatch`/`useSelector` like `App.tsx`. Not a V1 blocker.
- [ ] **Epoch reviewer Phase 3 — onboarding layer.** Plain-language explanations of epochs + each artifact type, a **guided mode** (step through auto-flagged epochs with "why we flagged this," student confirms/overrides), channel legend tied to head position (Muse 10-20), student-facing tone. Builds on the Phase 0-2 reviewer (PRs #223/#224/#225). **Open question OQ3 (onboarding depth) is still unresolved** — how much curriculum (tooltips only vs. a real walkthrough), guided-mode-as-default? This is product-shaped, not architecture.
- [ ] **WorkflowNav stale gold border** (added 2026-09-27, found in the WS7 Analyze playtest) — intermittent: after reload → Home → open workspace (lands on `/design`) → Analyze, the PREPARE button keeps a computed gold `border-bottom` although its class is `border-transparent`, and the current ANALYZE button stays transparent. Also seen on `/clean`. `aria-current` and `areaForPath` are correct, so the route logic is fine. A clone inserted at the same spot computes transparent, and re-attaching the node (`display: none` → `''`) fixes it. That points at a Chromium style-invalidation issue, not a location bug. The same path is sometimes fine. In one run every class-driven style on the page went stale (Analyze tabs, the selected sensor), and any viewport resize restyled everything correctly. So this may be an artifact of the CDP-driven, occluded Electron window: confirm it in a visible, human-driven window before fixing anything.
- [ ] **Topo legend mislabels conditions** (added 2026-09-27, predates WS7) — `plt.legend(labels)` in `utils.py` `plot_topo` takes its handles from the current axes' lines, so the swatches don't match the conditions (House gets a blue line, Face none). Pass explicit handles, e.g. `Line2D` in each condition's palette color.

## And Then (V2: Real lesson Content)
- [ ] **Neuro content from Steve Azeka** — content that was taught in 2017 alongside the Brainwaves app. Hopefully neuroscience basics, visual and auditory system, EEG, etc. Might include content from https://github.com/kylemath/EEGEdu
Expand Down
28 changes: 19 additions & 9 deletions src/renderer/components/Clean/CleanReview.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import type { SuggestedRejection } from '../../actions';
import { PTP_THRESHOLD } from '../../constants/constants';
import EpochReviewer from '../CleanComponent/EpochReviewer';
Expand Down Expand Up @@ -76,6 +76,8 @@ export default function CleanReview(props: CleanReviewProps) {
const kept = total - props.rejected.size;
const { dataset } = props;
const busy = props.saveState === 'saving';
const [focus, setFocus] = useState<{ index: number } | null>(null);
useEffect(() => setFocus(null), [props.epochArrays]);

const rail = (
<>
Expand Down Expand Up @@ -271,6 +273,7 @@ export default function CleanReview(props: CleanReviewProps) {
badChannels={props.badChannels}
onToggleChannel={props.onToggleChannel}
codeToLabel={props.codeToLabel}
focus={focus}
/>
</FitPane>
{props.primerOpen && <PrimerPointer step={props.primerStep} />}
Expand Down Expand Up @@ -321,9 +324,14 @@ export default function CleanReview(props: CleanReviewProps) {
key={suggestion.index}
className="flex items-center gap-[10px] rounded-md border border-gray-200 px-[10px] py-[4px]"
>
<span className="flex-none text-[13px] font-bold text-ink">
<button
type="button"
className="flex-none cursor-pointer border-0 bg-transparent p-0 text-[13px] font-bold text-ink underline decoration-dotted underline-offset-2 hover:text-brand"
aria-label={`Show trial ${suggestion.index}`}
onClick={() => setFocus({ index: suggestion.index })}
>
Trial {suggestion.index}
</span>
</button>
<span className="min-w-0 flex-1 text-[13px] text-ink-muted">
{suggestion.reason}
</span>
Expand All @@ -337,9 +345,10 @@ export default function CleanReview(props: CleanReviewProps) {
variant="outline"
className="flex-none"
disabled={busy}
onClick={() =>
props.onRestoreSuggestion(suggestion.index)
}
onClick={() => {
setFocus({ index: suggestion.index });
props.onRestoreSuggestion(suggestion.index);
}}
>
Restore
</Button>
Expand All @@ -350,9 +359,10 @@ export default function CleanReview(props: CleanReviewProps) {
variant="outline-brand"
className="flex-none"
disabled={busy}
onClick={() =>
props.onAcceptSuggestion(suggestion.index)
}
onClick={() => {
setFocus({ index: suggestion.index });
props.onAcceptSuggestion(suggestion.index);
}}
>
Accept
</Button>
Expand Down
23 changes: 18 additions & 5 deletions src/renderer/components/CleanComponent/EpochReviewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ interface Props {
onToggleChannel: (name: string) => void;
// Optional map from numeric event code to a human-readable condition label.
codeToLabel?: Record<number, string>;
// A trial to page to and outline, e.g. a clicked auto-flag suggestion. A new
// object re-focuses the same trial after the student paged away.
focus?: { index: number } | null;
}

// Logical canvas size (scaled up for devicePixelRatio at draw time).
Expand All @@ -46,6 +49,7 @@ export default function EpochReviewer({
badChannels,
onToggleChannel,
codeToLabel,
focus,
}: Props): JSX.Element {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
// First epoch of the current page (absolute index).
Expand All @@ -68,6 +72,9 @@ export default function EpochReviewer({
[meta]
);

useEffect(() => {
if (focus) setStartEpoch(Math.floor(focus.index / perPage) * perPage);
}, [focus, perPage]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !epochArrays || !meta || meta.n_epochs === 0) {
Expand Down Expand Up @@ -139,7 +146,8 @@ export default function EpochReviewer({
ctx.stroke();
}

const cols = Math.max(1, Math.floor(colWidth));
// One bucket per device pixel, so the envelope is as sharp as the screen.
const cols = Math.max(1, Math.floor(colWidth * dpr));

for (let c = 0; c < visibleCount; c += 1) {
const absolute = clampedStart + c;
Expand Down Expand Up @@ -187,14 +195,15 @@ export default function EpochReviewer({
};

if (n_times > cols) {
// More samples than pixels: draw a vertical min→max line per column
// so sharp transients survive downsampling.
// More samples than pixels: trace each column's min and max as one
// connected path, so sharp transients survive downsampling.
const buckets = downsampleMinMax(series, cols);
ctx.beginPath();
for (let col = 0; col < buckets.length; col += 1) {
const x = colLeft + (col * colWidth) / buckets.length;
const [lo, hi] = buckets[col];
ctx.moveTo(x, toY(hi));
if (col === 0) ctx.moveTo(x, toY(hi));
else ctx.lineTo(x, toY(hi));
ctx.lineTo(x, toY(lo));
}
ctx.stroke();
Expand Down Expand Up @@ -311,14 +320,18 @@ export default function EpochReviewer({
{Array.from({ length: visibleCount }, (_, c) => {
const absolute = clampedStart + c;
const isRejected = rejected.has(absolute);
const isFocused = focus?.index === absolute;
return (
<button
key={absolute}
type="button"
aria-pressed={isRejected}
aria-current={isFocused || undefined}
aria-label={`${isRejected ? 'Restore' : 'Reject'} trial ${absolute}`}
title={`${isRejected ? 'Restore' : 'Reject'} trial ${absolute}`}
className="absolute cursor-pointer bg-transparent border-0 p-0 appearance-none"
className={`absolute cursor-pointer bg-transparent border-0 p-0 appearance-none ${
isFocused ? 'rounded-[2px] ring-2 ring-inset ring-accent' : ''
}`}
style={{
left: LABEL_GUTTER + c * colWidth,
width: colWidth,
Expand Down
13 changes: 7 additions & 6 deletions src/renderer/components/HomeLanding/HomeLanding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,16 +162,17 @@ export default function HomeLanding({
);

return (
<div className="mx-auto flex max-w-[1120px] flex-col gap-[40px] px-[56px] pb-[64px] pt-[48px] text-ink">
<div className="mx-auto flex max-w-[1120px] flex-col gap-[28px] px-[56px] pb-[64px] pt-[36px] text-ink">
<div className="flex flex-col gap-[10px]">
<h1 className="m-0 !text-[40px] !font-light !leading-tight !tracking-[0.4px]">
{firstTime ? 'Welcome to BrainWaves' : 'Welcome back'}
</h1>
<p className="m-0 max-w-[640px] !text-[18px] leading-normal !tracking-normal [text-wrap:pretty]">
{firstTime
? 'Run your first brain experiment today. Most classes start with Faces/Houses — it takes about 15 minutes.'
: 'Pick up where you left off, or start something new.'}
</p>
{firstTime && (
<p className="m-0 max-w-[640px] !text-[18px] leading-normal !tracking-normal [text-wrap:pretty]">
Run your first brain experiment today. Most classes start with
Faces/Houses — it takes about 15 minutes.
</p>
)}
</div>
{!firstTime && continueSection}
<div
Expand Down
12 changes: 9 additions & 3 deletions src/renderer/constants/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,16 @@ export enum SIGNAL_QUALITY {
DISCONNECTED = '#BFBFBF',
}

/**
* Per-channel signal variability (µV standard deviation per 250 ms epoch) at
* which a sensor reads noisy (BAD) or settling (OK); below GREAT it reads as not
* touching anything. Calibration knob: raised 50% after the v1.1 QA found
* decent TP9/TP10 contact marked noisy on a real Muse.
*/
export enum SIGNAL_QUALITY_THRESHOLDS {
BAD = 15,
OK = 10,
GREAT = 1.5, // Below 1.5 usually indicates not connected to anything
BAD = 22.5,
OK = 15,
GREAT = 1.5,
}

export enum FILE_TYPES {
Expand Down
30 changes: 25 additions & 5 deletions src/renderer/utils/webworker/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import numpy as np
from matplotlib import pyplot as plt
from matplotlib.lines import Line2D
from matplotlib.patches import Circle, Ellipse, Polygon
import pandas as pd # maybe we can remove this dependency

from mne import (concatenate_raws, concatenate_epochs, create_info, viz,
Expand Down Expand Up @@ -161,21 +163,39 @@ def load_clean_epochs(file_paths):


def plot_topo(epochs, conditions, palette):
"""Per-sensor condition ERPs laid out on a head outline.

MNE's plot_evoked_topo places the traces by sensor position but draws no
head, so the outline (circle, nose, ears) is added in figure coordinates on
the square figure. The legend gets explicit handles so each swatch matches
its condition.
"""
evokeds = [epochs[name].average() for name in (conditions)]
colors = palette[0:len(conditions)]

evoked_topo = viz.plot_evoked_topo(
evokeds, vline=None, color=palette[0:len(conditions)], show=False)
evokeds, vline=None, color=colors, show=False)
evoked_topo.patch.set_alpha(0)
evoked_topo.set_size_inches(9, 9)
for axis in evoked_topo.axes:
for line in axis.lines:
line.set_linewidth(2)

legend_loc = 0
ax = evoked_topo.axes[0]
center, radius = (0.5, 0.5), 0.45
outline = dict(transform=evoked_topo.transFigure, fill=False,
edgecolor='0.55', linewidth=1.5, zorder=1.5, clip_on=False)
ax.add_patch(Circle(center, radius, **outline))
ax.add_patch(Polygon([(0.46, 0.5 + radius * 0.99), (0.5, 0.5 + radius * 1.09),
(0.54, 0.5 + radius * 0.99)], closed=False, **outline))
for side in (-1, 1):
ax.add_patch(Ellipse((0.5 + side * radius * 1.02, 0.5), 0.04, 0.12,
**outline))

labels = [e.comment if e.comment else 'Unknown' for e in evokeds]
legend = plt.legend(labels, loc=legend_loc, prop={'size': 20})
txts = legend.get_texts()
for txt, col in zip(txts, palette):
handles = [Line2D([], [], color=c, linewidth=2) for c in colors]
legend = plt.legend(handles, labels, loc='lower left', prop={'size': 20})
for txt, col in zip(legend.get_texts(), colors):
txt.set_color(col)

return evoked_topo
Expand Down
Loading