Skip to content

Rebuild the control panel to the design handoff - #326

Merged
IanMayo merged 12 commits into
mainfrom
claude/control-design-implementation-9a2bt9
Sep 18, 2026
Merged

IanMayo merged 12 commits into
mainfrom
claude/control-design-implementation-9a2bt9

Conversation

@IanMayo

@IanMayo IanMayo commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor

Implements the GramFrame_UI_Control_Panel handoff — 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

# Column Module
1 Mode rail (154px) components/ModeButtons.js
2 Guidance (264px, or a 40px rail) components/GuidancePanel.js
3 Cursor readouts (210px) components/CursorReadout.js
4 Style (222px) components/StylePanel.js
5 Annotation tables (the rest) components/AnnotationTables.js
  • 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 armed tool. They used to sit inside Pan's row, which made one row of five four controls wide. Clear is not here.
  • Guidance — permanent, immediately beside the rail. Each line splits into a trigger in a fixed 76px track and an outcome beside it, so a mode's four gestures compare down one column instead of being read out of four sentences. It collapses to a 40px rail, remembered per user. The preference is genuinely three-state (true/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.
  • Cursor readouts — the panel's anchor and the only part styled as an instrument. Two targets, named by the kicker: the pointer, or the selected feature's own values. updateUniversalCursorReadouts stands aside while something is selected. No intensity legend — one was drawn and removed.
  • Style panel with twin target tabs — state.styleTarget records 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, getSelectedFeature in the new core/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.
  • Three annotation tables — count chips (hidden at zero) and instructional empty states. The selected row is reversed out whole rather than bordered: an accent border collided with the feature colours the rows themselves carry. Clear all annotations moved to the foot of the sidebands column — it removes annotations, not view state, and a destructive control among three view controls is one slip from an afternoon's work. It is offered on student pages too: "it will be gone tomorrow" is no answer to a student who has mislabelled a gram and wants to start the exercise again today.

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 in tests/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.

  • The view scrolls back to the very start of a recording. The top edge of the plot area is the playhead and drag-seek resumes from whatever time that edge shows, so a lower bound of one window made the opening seconds of every recording impossible to put under the playhead — visible at the bottom of the view, but unreachable by dragging. 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.
  • The cross-mode gestures appear in every mode. 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 everywhere — 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 each mode's own lines.
  • Selecting a feature scrolls a player's view to it. 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, and only while paused, since the follow loop owns the view during playback. It moves the view, never the playhead.
  • The selected feature is marked on the gram. Selection was visible only in the control row, so the panel could read "Selected: Marker 3" while three identical crosses sat on the plot with nothing to tell them apart. rendering/selectionHalo.js redraws 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).
  • More contrast across the panel. Ground #161826 → #0d0e18, surface #232532 → #171926, ink #e9e9ed → #f7f7fa, muted .62 → .70. Body text on the surface goes from ~13:1 to ~16:1 and muted text from ~5.4:1 to ~8.5:1. Recesses deepen to match.

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 of keyboardControl.js
  • core/selection.js — setting and clearing the selection and the consequences that follow, also out of keyboardControl.js, which is about arrow keys: selection is what they act on, not part of how they work. With core/selectionTarget.js, a leaf that describes what is selected so the readout column and the player can both ask without a cycle, this took keyboardControl.js from 698 lines to 415
  • core/panelRefresh.js — one copy of "redraw the tables", not three
  • core/preferences.js — the two chrome preferences, out of the annotation store
  • core/pointerScope.js — the image-bounds gate on pointer delivery, where the one mode that opts out of it is explained
  • rendering/selectionHalo.js — what a selected feature looks like on the gram
  • player/transportKeys.js, player/bookmarks.js, components/tableScroll.js, components/SymbolSizeTrial.js, utils/guidanceContent.js

A MarkerOwner capability joins PinSetOwner so the panel's Delete finds what to remove without naming a mode (ADR-017). Six module caps fall; state.js and types.js rise, each with the reason recorded in hygiene-baseline.json. instanceStateReachIns falls 144 → 141, specTypeErrors 509 → 503 and uncoveredUnitLines 411 → 409.

Checks

  • yarn typecheck — clean
  • yarn lint — clean
  • yarn hygiene — passes
  • yarn test:unit — 298 passed
  • yarn test — 516 passed, 0 failed
  • WebKit smoke (playwright.smoke.config.ts) — runs in CI and passes

New 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.js and tests/unit/guidance-content.test.js. tests/control-row.spec.js is 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, where boundingBox() returns coordinates the mouse cannot reach and a drag quietly does nothing rather than failing.

Worth a look before merge

  • The panel is now a fixed 250px rather than shrinking to its content. On a page whose guidance is short it used to be ~184px, so the gram sits ~66px lower there. That is the design's stated height and the product owner's decision, but it is a real cost to the gram on those pages.
  • A selected pin set inverts every one of its visible number plates, which on a dense set is a dozen or more chips changing at once — a much larger change in the gram than a selected marker's single label. It is what was asked for and it reverses on deselect, but it is the part of the highlight most worth judging on screen.
  • docs/Control-Panel.md is new and records what was decided and what was rejected (onboarding, docking the transport, an accent tint for the selected row).
  • The four new mode glyphs are the handoff's own line drawings rather than Phosphor icons, which the handoff offered as an alternative.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CKL4JMQaFw5TmDSpASuV5L


Generated by Claude Code

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
@github-actions

github-actions Bot commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor

PR Preview

Preview 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
claude and others added 4 commits September 18, 2026 08:54
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
@IanMayo
IanMayo merged commit 80d2db2 into main Sep 18, 2026
6 checks passed
@IanMayo
IanMayo deleted the claude/control-design-implementation-9a2bt9 branch September 18, 2026 10:10
github-actions Bot added a commit that referenced this pull request Sep 18, 2026
@IanMayo IanMayo mentioned this pull request Sep 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants