diff --git a/TODOS.md b/TODOS.md index f490d170..4e30a1ed 100644 --- a/TODOS.md +++ b/TODOS.md @@ -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 diff --git a/src/renderer/components/Clean/CleanReview.tsx b/src/renderer/components/Clean/CleanReview.tsx index 2eb0ad82..2e4d6ad5 100644 --- a/src/renderer/components/Clean/CleanReview.tsx +++ b/src/renderer/components/Clean/CleanReview.tsx @@ -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'; @@ -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 = ( <> @@ -271,6 +273,7 @@ export default function CleanReview(props: CleanReviewProps) { badChannels={props.badChannels} onToggleChannel={props.onToggleChannel} codeToLabel={props.codeToLabel} + focus={focus} /> {props.primerOpen && } @@ -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]" > - + {suggestion.reason} @@ -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 @@ -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 diff --git a/src/renderer/components/CleanComponent/EpochReviewer.tsx b/src/renderer/components/CleanComponent/EpochReviewer.tsx index 9584f50b..ed4d99a2 100644 --- a/src/renderer/components/CleanComponent/EpochReviewer.tsx +++ b/src/renderer/components/CleanComponent/EpochReviewer.tsx @@ -21,6 +21,9 @@ interface Props { onToggleChannel: (name: string) => void; // Optional map from numeric event code to a human-readable condition label. codeToLabel?: Record; + // 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). @@ -46,6 +49,7 @@ export default function EpochReviewer({ badChannels, onToggleChannel, codeToLabel, + focus, }: Props): JSX.Element { const canvasRef = useRef(null); // First epoch of the current page (absolute index). @@ -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) { @@ -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; @@ -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(); @@ -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 (