Repository navigation
Rebuild the control panel to the design handoff - #326
Merged
Merged
Conversation
The panel was five bordered boxes of equal weight: nothing in it was the first thing to read, per-mode guidance had nowhere permanent to live, and it was never clear whether the colour slider was setting the style of the next feature or restyling the one just clicked. This is about rank, not content — almost everything that was in the panel is still in it. Five columns, separated by hairlines rather than boxed: - **Mode rail.** Five tools stacked, each with a glyph beside its word, and the view controls (zoom out, zoom in, fit) in a footer beneath them where they stay whichever mode is armed — they act on the view, not on the tool. They used to sit inside Pan's row, which made one row of five four controls wide. - **Guidance**, permanent and immediately beside the rail. Each line is a trigger in a fixed track and an outcome beside it, so a mode's four gestures compare down one column. Collapses to a 40px rail, remembered per user; with no stored choice the column decides by the panel's width (a container query, not a viewport one — one page can carry two grams of different widths). - **Cursor readouts**, the panel's anchor and the only part styled as an instrument. Reads the pointer, or the selected feature when there is one, and its kicker says which. - **Style panel with twin target tabs.** `state.styleTarget` records whether colour/symbol/pin write the defaults or restyle the selection; arming "New features" does not give up the selection. One gate, `getSelectedFeature` in the new `core/featureStyle.js`, decides, so the tab and the behaviour cannot disagree. One colour control, not two. The symbol popup shows all seven marks at the size and colour they will take, and houses the temporary size trial. Marker labels are edited here rather than in a per-row dialog, which is deleted. - **Three annotation tables** with count chips and instructional empty states. The selected row is reversed out whole rather than bordered: an accent border collided with the feature colours the rows carry. "Clear all annotations" moved to the foot of the sidebands column — it removes annotations, not view state. The transport bar is rebuilt to match and gains time bookmarks: Bookmark or B flags the playhead, each flag can be clicked to jump, and the saved list removes them. They are playback chrome, not annotation, so they are in memory for the life of the page and never reach the annotation store. Splits that came with it, each because two things were sharing a module by accident rather than by design: core/featureStyle.js (restyling, out of keyboardControl), core/panelRefresh.js (one copy of "redraw the tables", not three), core/preferences.js (the two chrome preferences, out of the annotation store), player/transportKeys.js, player/bookmarks.js, components/tableScroll.js and components/SymbolSizeTrial.js. Five module caps fall as a result; state.js and types.js rise, with the reasons recorded in hygiene-baseline.json. Test helpers now scroll the gram into view before a drag: the panel's fixed 250px height puts the lower half of the component below the fold on a 720px viewport, where boundingBox() returns coordinates the mouse cannot reach and a drag quietly does nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKL4JMQaFw5TmDSpASuV5L
Contributor
PR PreviewPreview deployment is ready! View Preview Last updated: 2026-09-18T09:24:46.286Z |
CI caught what my local run could not: `yarn hygiene` skips the coverage ratchet when no summary is present, so the twenty-odd lines the guidance rows added to `utils/secureHTML.js` — a module the unit lane cannot load at all, since it builds DOM — went straight onto the uncovered count. Rather than shrink the module, move the part that was worth testing out of it. `utils/guidanceContent.js` decides which sections a mode's guidance has and whether each line carries a trigger: four shapes from two optional fields and a union, all of it arithmetic over plain objects. `secureHTML.js` is left with one loop over a settled list, which is the half no test could ever reach. `resolveGuidance` is total, and the tests pin what it does with the malformed input the old code would have drawn as an empty heading or a blank row. Uncovered lines fall 411 -> 409. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKL4JMQaFw5TmDSpASuV5L
The top edge of the plot area IS the playhead, and drag-seek resumes from whatever time that edge shows. `clampViewTop`'s lower bound was one window above the start, so the opening `window-seconds` of every recording could not be put under the playhead — visible at the bottom of the view, but unreachable by dragging, and so unreplayable by the gesture an analyst reaches for. The blank the bound was avoiding is blank the view already showed and already accepted: in the opening seconds the bound resolved to the playhead, newest row at the top with nothing yet beneath it. It forbade at second thirty exactly what it permitted at second three. Opening the clamp alone was not enough. The drag stalled around two seconds, because the press point fell into the blank the drag itself was bringing into view and `events.js` refuses to deliver a pointer event that is not over the gram: before 13.14 -> 11.28 -> 9.42 -> 7.56 -> 5.71 -> 3.85 -> 1.99 -> 1.99 after ... -> 0 So `BaseMode.acceptsOffImageDrag()`: false everywhere, because a marker has to land on the gram and an off-image pointer stays a cancelled drag; true for Pan on an audio-sourced gram alone, where scrolling to the start *means* bringing blank space on screen. The bounds gate itself moves to `core/pointerScope.js`, which is where the one mode that opts out of it is explained, and events.js falls 445 -> 423. Two tests: the drag reaching viewTop 0 without stalling, and a drag-back-and-release that resumes playback from under a second. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKL4JMQaFw5TmDSpASuV5L
…contrast Three things the redesigned panel made possible or made obvious. **The cross-mode gestures, everywhere.** Wheel zoom and pan, the wheel-button drag and Shift + drag region zoom are resolved centrally, ahead of mode delegation, so they have always worked in every mode — but they were listed in Pan's guidance alone, because Pan is the initial mode and the old panel had room for them nowhere else. An analyst who armed Cross Cursor first therefore never learnt that Shift + drag zooms. `withNavigationGuidance` appends them beneath a mode's own lines, so no mode has to remember to. **Selecting a feature scrolls to it on a player.** A recording is one tall gram read through a window a few tens of seconds high, so a feature ten minutes in is usually off screen: selecting its row lit up a row, wrote two readouts and pointed the style panel at something the analyst could not see. `revealTime` moves the view only when the time is off screen — scrolling a visible feature into the middle would move the gram under the eye on every row click — and only while paused, since the follow loop owns the view during playback. It moves the view, never the playhead: play still resumes from where the audio is. Dragging the gram remains the gesture that means "play from here". Selection is now its own module. `core/selection.js` holds setting, clearing and the five consequences; `core/selectionTarget.js` describes what is selected and is a leaf, so the readout column and the player can both ask without a cycle. Both came out of `keyboardControl.js`, which is about arrow keys — selection is what they act on, not part of how they work — taking it from 524 lines to 415. **More contrast.** The ground drops to #0d0e18 and the surface to #171926, the ink rises to #f7f7fa, and the muted tier to .70 alpha: body text on the surface now reads at ~16:1 rather than ~13:1 and muted text at ~8.5:1 rather than ~5.4:1, which is what a panel read beside a bright gram in a lit room needs. Recesses deepen to match the darker ground. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKL4JMQaFw5TmDSpASuV5L
Selection was visible only in the control row — the table row inverted, the readouts retargeted, the style panel re-armed — and not at all on the plot the analyst is looking at, so the panel could read "Selected: Marker 3" while three identical crosses sat on the gram with nothing to tell them apart. Two treatments, for the two kinds of thing a feature is made of. Its geometry gets a **halo**: the same geometry redrawn underneath itself, wider and in translucent white. That was chosen over the alternatives because it is the only one that generalises — a crosshair, a shaped symbol and a stack of forty pin lines all take the same cue from the same code — and because it is independent of the feature's colour, which the tables learnt the hard way when an accent border collided with the colours the rows themselves carry. Corner brackets read well on a marker and badly on a tall pin set; dimming every other feature is unmissable but destroys the comparison between features, which is usually why several exist; a pulse has faded by the time the eye arrives. Labels are the exception, deliberately: a white glow behind a white plate says nothing, so a selected feature's plate is inverted instead — dark plate, light text, the same reversal its table row performs, so selection reads as one idea in both places. A pass over the finished overlay rather than a flag threaded through every renderer: selection changes far more often than the features do, so a click adds and removes a few elements instead of rebuilding the gram. The pass walks the feature's own elements up to the overlay children they belong to rather than testing every child, so a drag of a selected pin set does not cost a query per drawn pin per frame. The copies carry no data-* id, so nothing that looks a feature up in the DOM can find one. Also carries the type fixes the previous commit needed: `withNavigationGuidance` now states that its return always has `sections`, which is what its own unit test reads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKL4JMQaFw5TmDSpASuV5L
The first cut inverted the label plate of any selected feature, which broke three plate tests and, on looking at it, deserved to. A marker's label is one plate naming one feature, so inverting it echoes its table row exactly. A pin set's labels are a per-member index drawn a dozen or more times: inverting all of them reads as a change of mode rather than a selection, and it costs those numbers the legibility over any gram that the white plate was introduced for (issue #243). The set's halo already says which set is selected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKL4JMQaFw5TmDSpASuV5L
Harmonics and sidebands now reverse their number plates when the set is selected, exactly as a selected marker's label does. The previous commit held them back on the grounds that a per-member index inverted a dozen times reads as a change of mode; on review that is not the call to make here — one treatment for "this is selected" across every feature is what makes the cue legible, and issue #243's guarantee is about contrast rather than about white specifically: light text on a dark plate clears the same contiguous rectangle a dark-on-white one does, which is the whole point of a plate over a halo. The three plate tests read the resting treatment, so they clear the selection a newly created set arrives with; the selection tests assert the inversion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKL4JMQaFw5TmDSpASuV5L
In use the frequency at the cursor is by far the most important number in the panel, but the readout column set time above it at the same size, so the two competed for the eye and neither was the first read. Frequency is now the only large, lit reading. Time sits immediately beneath it at half the size, captioned, in the same grey as the panel's labels, and the doppler speed at the foot takes that same second rank rather than staying in ink at 19px — two secondary rows in different colours would have read as a third rank. The hairline between the two large readings goes with the second large reading. The LEDs keep their accessible names and classes, so no locator changes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CrHtVussSR8ZxsxisaepJM
The fast lane on this PR fails on four cases in tests/unit/multi-tab-merge.test.js, exactly as the base branch does: they left `now` to the wall clock while asserting on tombstones dated 2026-09-05, so they have failed on every run since the seven-day TTL passed. Not this PR's change, and not the design branch's either; main already carries the fix in 9147c49 (PR #333), which this commit ports verbatim. It no-ops once the design branch takes main. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CrHtVussSR8ZxsxisaepJM
Make frequency the one lit reading, with time beneath it
Brings the redesign up to main: the site restructure and its link check, the painting controls (normalisation, frame averaging, the level span and scope, the colour-map radio row), zoom about the pointer, and the release of v0.2.0. Five files conflicted. CLAUDE.md keeps both sides' recent-changes entries; Gram-Modes.md keeps the mode rail's account of the Pan button and the view controls, which supersedes main's hand-only text; gramframe.css takes main's colour-map row rules alongside the redesign's transport bar; playerView.js keeps `revealTime`, which the redesign added after the clampViewTop change both sides carry; and hygiene-baseline.json records the merged caps for state.js and types.js, each side having raised them deliberately for its own fields, with a note saying so. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CrHtVussSR8ZxsxisaepJM
tests/player-colour-map.spec.js waited for exactly five ready players on the painting fixture, the count from before #334 and #335 grew the page: it now carries six live players (the seventh table is the deliberately bad one, which never becomes an instance). Five is a count the page only passes through, and once two players finish in the same frame it is never observed, so the wait times out. main's own run on the #335 merge fails on this test. Six is what tests/player-painting.spec.js already waits for. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CrHtVussSR8ZxsxisaepJM
This was referenced Sep 18, 2026
Merged
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the
GramFrame_UI_Control_Panelhandoff — turns 5a, 5b, 5c and 2a, which are the agreed design. Turns 1–4 are superseded explorations and are not implemented.The plot area is unchanged: the gram, the axes and every overlay are drawn exactly as before.
The problem
Five bordered boxes of equal visual weight. Nothing in the panel was the first thing to read, per-mode guidance had nowhere permanent to live, and it was never clear whether the colour slider was setting the style of the next feature or restyling the one just clicked. This is about rank, not content — almost everything that was in the panel is still in it.
The five columns
components/ModeButtons.jscomponents/GuidancePanel.jscomponents/CursorReadout.jscomponents/StylePanel.jscomponents/AnnotationTables.jstrue/false/absent): with no stored choice the column decides by the panel's own width, via a container query rather than a viewport media query, because one page can carry a full-width gram and a half-width one.updateUniversalCursorReadoutsstands aside while something is selected. No intensity legend — one was drawn and removed.state.styleTargetrecords whether the controls write the defaults or restyle the selection, and arming "New features" does not give up the selection, so an analyst can set what comes next while still nudging the row they are on. One gate,getSelectedFeaturein the newcore/featureStyle.js, decides, so the tab an analyst can see and what a colour click does cannot disagree. One colour control, not two. The symbol popup shows all seven marks at the size and colour they will take, and houses the temporary size trial. Marker labels are edited here, in a field beside the same marker's colour and symbol; the per-row dialog is deleted.Transport bar (5c)
Rebuilt to match, still below the gram, where the scrub track lines up with the time axis it refers to. Gains time bookmarks: Bookmark (or
B) flags the playhead, each flag can be clicked to jump, and the "n saved" list removes them. They are playback chrome rather than annotation — a marker says what was measured and belongs to the gram, a bookmark says where to listen again and belongs to the sitting — so they live in memory for the life of the page and never reach the annotation store (asserted intests/transport-bookmarks.spec.js). The volume slider is kept, though 5c does not draw it: dropping a shipped control would have been a quiet regression.Follow-ups since the first pass
Four changes asked for once the redesign was on screen, plus one player fix.
clampViewTop's floor is now 0, and Pan opts into off-image pointer delivery so the drag does not stall once the press point falls into the blank it is creating. The window below the start is blank, which is the same blankness the opening seconds already showed while the first window played.withNavigationGuidanceappends them beneath each mode's own lines.revealTimemoves the view only when the time is off screen, and only while paused, since the follow loop owns the view during playback. It moves the view, never the playhead.rendering/selectionHalo.jsredraws the feature's own geometry beneath itself, wider and in translucent white — the only treatment that generalises to a crosshair, a shaped symbol and a stack of forty pin lines alike, and one that is independent of the feature's colour. Labels invert instead (dark plate, light text), the same reversal the table row performs, for markers and pin-set number plates alike. Rejected: corner brackets (fine on a marker, poor on a tall pin set), dimming every other feature (unmissable, but it destroys the comparison between features), a pulse (faded by the time the eye arrives).Structure
Splits that came with it, each because two things were sharing a module by accident rather than by design:
core/featureStyle.js— restyling, out ofkeyboardControl.jscore/selection.js— setting and clearing the selection and the consequences that follow, also out ofkeyboardControl.js, which is about arrow keys: selection is what they act on, not part of how they work. Withcore/selectionTarget.js, a leaf that describes what is selected so the readout column and the player can both ask without a cycle, this tookkeyboardControl.jsfrom 698 lines to 415core/panelRefresh.js— one copy of "redraw the tables", not threecore/preferences.js— the two chrome preferences, out of the annotation storecore/pointerScope.js— the image-bounds gate on pointer delivery, where the one mode that opts out of it is explainedrendering/selectionHalo.js— what a selected feature looks like on the gramplayer/transportKeys.js,player/bookmarks.js,components/tableScroll.js,components/SymbolSizeTrial.js,utils/guidanceContent.jsA
MarkerOwnercapability joinsPinSetOwnerso the panel's Delete finds what to remove without naming a mode (ADR-017). Six module caps fall;state.jsandtypes.jsrise, each with the reason recorded inhygiene-baseline.json.instanceStateReachInsfalls 144 → 141,specTypeErrors509 → 503 anduncoveredUnitLines411 → 409.Checks
yarn typecheck— cleanyarn lint— cleanyarn hygiene— passesyarn test:unit— 298 passedyarn test— 516 passed, 0 failedplaywright.smoke.config.ts) — runs in CI and passesNew coverage:
tests/style-panel.spec.js(twin tabs, readout retarget, inverted row, chips and empty states),tests/transport-bookmarks.spec.js,tests/selection-highlight.spec.jsandtests/unit/guidance-content.test.js.tests/control-row.spec.jsis rewritten for the rail and the guidance collapse.Two test-helper fixes are behavioural, not cosmetic: drags now
scrollIntoViewIfNeeded()first, because the panel's fixed 250px height puts the lower half of the component below the fold on a 720px viewport, whereboundingBox()returns coordinates the mouse cannot reach and a drag quietly does nothing rather than failing.Worth a look before merge
docs/Control-Panel.mdis new and records what was decided and what was rejected (onboarding, docking the transport, an accent tint for the selected row).🤖 Generated with Claude Code
https://claude.ai/code/session_01CKL4JMQaFw5TmDSpASuV5L
Generated by Claude Code