From d4292d41b9d06b6ba2051d60e4ebbb8939561909 Mon Sep 17 00:00:00 2001 From: Jung Dae Suh Date: Wed, 1 Jul 2026 13:35:54 -0400 Subject: [PATCH] fix(web): remediate GUI Crucible findings (a11y, honesty, states, perf) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopts the existing theme.css token system; minimal token-driven diffs. - Perf: per-field useStore selectors so the shell no longer re-renders on every time-cursor scrub. - Honesty: the "live backend" badge keys off the backend's per-machine `mock` flag (was !!API_BASE, which reads "live" over a mock machine list); RotatingTab label follows suit. Adds mock?: boolean to MachineInfo. - UI states: SensorsTab empty state for incomplete geometry meta; RotatingTab "computing mode map…" placeholder + persistent scrubber while mode_number loads; ErrorBoundary retry; PullControl shot validation (+ inputMode/aria-invalid); equilibrium control disabled + "(coming soon)" while its node is absent (#43). - a11y: keyboard-operable tabs / shot list / resize divider / collapse headers; aria-pressed toggles; slider labels; role=status/aria-live + progressbar on the live-pull progress. - Docs/tests: qsTransforms.phiPeak docstring corrected (signed argmax is intentional) + signed-vs-|·| test; ErrorBoundary retry test; cross-tab theme-sync store listener + tests. Frontend 28 tests pass; lint/typecheck/build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- gui/web/src/App.tsx | 63 ++++++++++++----- gui/web/src/components/ErrorBoundary.test.tsx | 22 +++++- gui/web/src/components/ErrorBoundary.tsx | 13 ++++ gui/web/src/components/PullControl.tsx | 20 ++++-- .../components/tabs/QuasiStationaryTab.tsx | 24 +++++-- gui/web/src/components/tabs/RotatingTab.tsx | 69 ++++++++++--------- gui/web/src/components/tabs/SensorsTab.tsx | 19 +++-- gui/web/src/lib/DraggableDivider.tsx | 45 +++++++++--- gui/web/src/lib/api.ts | 4 ++ gui/web/src/lib/qsTransforms.test.ts | 10 +++ gui/web/src/lib/qsTransforms.ts | 4 +- gui/web/src/store.test.ts | 30 ++++++++ gui/web/src/store.ts | 13 ++++ gui/web/src/test/setup.ts | 6 ++ gui/web/src/theme.css | 28 ++++++-- 15 files changed, 294 insertions(+), 76 deletions(-) create mode 100644 gui/web/src/store.test.ts diff --git a/gui/web/src/App.tsx b/gui/web/src/App.tsx index 6d8303f..1b719dd 100644 --- a/gui/web/src/App.tsx +++ b/gui/web/src/App.tsx @@ -22,44 +22,75 @@ const TABS: { id: TabId; label: string }[] = [ ]; export default function App() { - const { machines, machine, tab, loadingMachines, init, setMachine, setTab } = useStore(); + // Per-field selectors: subscribing to the whole store re-rendered the entire + // shell on every cursor scrub (setCursorMs); App doesn't read cursorMs. + const machines = useStore((s) => s.machines); + const machine = useStore((s) => s.machine); + const tab = useStore((s) => s.tab); + const loadingMachines = useStore((s) => s.loadingMachines); + const init = useStore((s) => s.init); + const setMachine = useStore((s) => s.setMachine); + const setTab = useStore((s) => s.setTab); useEffect(() => { void init(); }, [init]); + // Honest data-source badge: a live backend with zero fetched shots still serves + // the mock machines, so key off the SELECTED machine's `mock` flag (from the + // backend), falling back to usingLiveBackend only when the flag is absent. + const selected = machines.find((m) => m.id === machine); + const mock = selected?.mock ?? !usingLiveBackend(); + const badgeText = !mock + ? "● live backend" + : usingLiveBackend() + ? "○ demo data (no shots fetched)" + : "○ offline / demo"; + return (
Magnetics 3D magnetic-sensor analysis - {usingLiveBackend() ? "● live backend" : "○ offline / demo"} + {badgeText}
-
+
{TABS.map((t) => ( -
setTab(t.id)}> +
+ ))}
{!machine ? ( diff --git a/gui/web/src/components/ErrorBoundary.test.tsx b/gui/web/src/components/ErrorBoundary.test.tsx index 42529e0..c893c34 100644 --- a/gui/web/src/components/ErrorBoundary.test.tsx +++ b/gui/web/src/components/ErrorBoundary.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, expect, test, vi } from "vitest"; import ErrorBoundary from "./ErrorBoundary"; @@ -28,3 +28,23 @@ test("renders children unchanged when nothing throws", () => { ); expect(screen.getByText("healthy panel")).toBeInTheDocument(); }); + +test("Retry clears the error and recovers in place when the child stops throwing", () => { + let shouldThrow = true; + function Flaky(): React.ReactElement { + if (shouldThrow) throw new Error("transient payload"); + return
recovered panel
; + } + render( + + + , + ); + expect(screen.getByText(/failed to render/i)).toBeInTheDocument(); + + // The underlying condition is fixed; Retry should re-render children in place. + shouldThrow = false; + fireEvent.click(screen.getByRole("button", { name: /retry/i })); + expect(screen.getByText("recovered panel")).toBeInTheDocument(); + expect(screen.queryByText(/failed to render/i)).not.toBeInTheDocument(); +}); diff --git a/gui/web/src/components/ErrorBoundary.tsx b/gui/web/src/components/ErrorBoundary.tsx index 7b26287..00d03b0 100644 --- a/gui/web/src/components/ErrorBoundary.tsx +++ b/gui/web/src/components/ErrorBoundary.tsx @@ -48,6 +48,19 @@ export default class ErrorBoundary extends Component { Try a different shot or reload; other tabs are unaffected. +
+
); } diff --git a/gui/web/src/components/PullControl.tsx b/gui/web/src/components/PullControl.tsx index 178726a..1ca8589 100644 --- a/gui/web/src/components/PullControl.tsx +++ b/gui/web/src/components/PullControl.tsx @@ -60,6 +60,10 @@ export default function PullControl() { if (!usingLiveBackend()) return null; const needsCreds = backend === "remote" || backend === "mdsthin"; const num = (s: string) => (s.trim() === "" ? undefined : Number(s)); + // Gate the Pull button: clearing the field or typing non-digits would otherwise + // POST shot 0/NaN to /api/fetch. + const shotNum = Number(shot); + const shotValid = Number.isFinite(shotNum) && shotNum > 0; async function pull() { setBusy(true); @@ -123,7 +127,8 @@ export default function PullControl() { ))} )} - setShot(e.target.value)} placeholder="shot number" /> setCursorMs(parseFloat(e.target.value))} @@ -1252,23 +1273,6 @@ export default function RotatingTab({ machine }: { machine: string }) {
- {/* FFT Overlap slider */} -
- - setFftOverlap(parseInt(e.target.value))} - style={{ accentColor: "var(--accent)" }} - /> -
- {/* Probe Angles input fields (side-by-side) */}
Toroidal Probes (φ) @@ -1359,6 +1363,7 @@ export default function RotatingTab({ machine }: { machine: string }) {