Files
JamBuddy/docs/design/dashboard-polish.md
T

27 KiB
Raw Blame History

Dashboard polish — rail + licks (task D-70)

Concept doc for sprint-dashboard-polish. Revises the rail/licks layout that docs/design/one-screen.md §4 and D-51 established, per the user directive of 2026-07-13 ("this looks amazing" + six refinements). No code here — this specs the two bounded implementation tasks L-70 (rail) and L-71 (licks) and proves the numbers.

Design tokens only (tailwind.config.js): surface #0f0f0f, panel #1a1a1a, border #2a2a2a, accent #a855f7, plus the SVG note language already mirrored in MiniPiano/ChordDiagram/LickCard (amber #f59e0b secondary, ACCENT_SOFT #c084fc chord-tone, BASS_RING #fbbf24). No new colour is introduced.

The column geometry we build against (from D-51 / one-screen.md §4, unchanged):

right column          500px
  section border+p-2  18
  row border+p-1.5    14
  vertical scrollbar  17 (classic Windows) … 8 (the thin bar we add in §2)
 = row interior        451px worst case (classic) · 460px (thin) · 468 (none)

All fits below are proven against the conservative 451px interior; the thin scrollbar we introduce in §2 only ever makes them more comfortable.


0. Scope boundary — dashboard only (user refinement 2026-07-13)

"perhaps within the knowledge center it could be nice but leave it for now as is, only in the main JAM FULL SCREEN we need to optimize what we show."

The net boundary (two settled user clarifications, 2026-07-13):

  • Dashboard (always-open band + ⛶ jam-view fullscreen) = full visual optimisation: guitar ≤4, 2×2 smaller piano, uniform licks, dark scoped scrollbars, no ▶.
  • Knowledge Center dock (KnowledgeDock's VoicingsSection + LicksSection, the Circle of Fifths) and ChordDetailModal = layout UNCHANGED (all shapes, normal piano, current lick size) except the ▶ play buttons are removed — the user decided "then leave them off, better not," so ▶ comes off everywhere, not just the rail.

So there are two kinds of change with two scopes: the visual layout changes are dashboard-only (gated behind the dashboard fork), while the ▶ removal is a deliberate global change across every mount.

The trap is shared components:

component dashboard mount Knowledge Center / modal mount how they differ today
VoicingBrowser GlanceRail rows + heard-live (dense) VoicingsSection + ChordDetailModal (no dense) the dense flag already forks them
LickCard LicksStrip (size="thumb") LicksSection (size="full") the size prop already forks them
MiniPiano rail thumbs (size="thumb") modal/ExplorePanel (size="thumb"/"full") needs a new size="mini" variant
PianoLickCard LicksStrip (new wiring) never mounted no KC concern at all

The rule: gate the visual optimisations behind the dashboard fork and change no default there; remove ▶ globally. Concretely:

  • VoicingBrowservisual, dashboard-only: guitar ≤4 + piano 2×2 apply only under dense (the flag only dashboard mounts pass); non-dense (VoicingsSection + ChordDetailModal) keeps all shapes + the normal piano layout. Global: the ▶ is removed from the component outright (§3) — no mount wants it anymore.
  • MiniPiano: the smaller keyboard is a new additive size="mini"; thumb and full are byte-untouched, so the modal/ExplorePanel render identically.
  • LickCard: the uniform footprint is imposed at the dashboard-strip mount (a wrapper box), not in LickCard — its default footprint is unchanged. LickCard itself is edited only if it must lose a ▶ (it has none — see §3.3), so the dock's LicksSection (size="full") renders unchanged. PianoLickCard is all-new to the strip.
  • Scrollbar CSS: a scoped .dark-scroll class on the dashboard scrollers only — the KC's own scrolling keeps the OS default.

Restated L-70/L-71 byte-identity DoD: the dock's VoicingsSection / LicksSection and ChordDetailModal render byte-identical to today EXCEPT the ▶ buttons are gone (the D-41/D-51 non-dense-mount precedent — Critic diff-checks it: the only permitted diff in those mounts is the removed play button + its now-dead wiring).


1.1 What changes structurally

Today the rail's guitar station shows a separate own-cell (the KB play's recommended shape, accent border, "play" badge — GlanceRail StationRow :215-231) plus VoicingBrowser's full gallery of every placeable shape (matchingShapes :76 returns all). Two problems: (a) the recommended shape is usually also in matchingShapes, so it renders twice; (b) maj/min/dom7 have 5-6 placeable shapes, so own-cell + gallery = 6-7 cells that wrap to a second line.

Decision: centralise the ≤4 rule inside VoicingBrowser and delete the separate own-cell. GlanceRail passes the recommended shape down; the browser renders it as the badged first cell and caps the total.

  • New VoicingBrowser props (additive, default-off so modal/dock stay byte-identical): recommended (a shape object) and max (number).
  • GlanceRail (dense rail) passes recommended={st.shape} and max={4}.
  • Non-dense mounts (ChordDetailModal Guitar tab, KnowledgeDock VoicingsSection) pass neither → they keep showing all shapes with no cap (a detail view should be exhaustive; the ≤4 fit constraint is the rail's alone).

This removes the pre-existing duplicate and guarantees ≤4 in one place.

1.2 The top-4 selection rule (exact ordering + tiebreak)

matchingShapes(quality, rootPc) still returns every placeable shape. The rail then orders and slices:

  1. Recommended first — the KB play's shape (recommended, matched into the list by label; if it was filtered out as unplaceable, prepend it anyway). Badged play, accent border — "the answer" prominence the old own-cell had, now cell #1 of the row.
  2. Open-position forms — shapes with an Array.isArray(shape.frets) grip (open chords). They sit at the nut, sound the most idiomatic, and are what a player reaches for first.
  3. Movable / barre forms by lowest base fret ascending — closest to the nut first (easiest hand position, most common voicing). Base fret is the value matchingShapes already computes (mod12(rootPc OPEN_PCS[idx]), 0→12).
  4. Tiebreak at equal category/base fret: fewer muted strings first (count of 'x' in frets/offsets — a fuller voicing wins), then the shape's declared order in GUITAR_SHAPES[quality] (stable, honours the KB author's priority). Deterministic — no reflow flicker as the root moves.

Take the first 4 (recommended + up to 3 more, recommended deduped so it never repeats). Qualities with <4 placeable shapes just show what they have — the rule is a cap, never padding; nothing is invented to reach four.

1.3 It fits one line

Guitar GalleryCell (dense, no play button — see §3): ChordDiagram thumb 75px + p-1.5 (12) + border (2) = 89px box. Four across:

4×89 + 3×6 (gap-1.5) = 356 + 18 = 374 ≤ 451 ✓   (a 5th would be 469 ✗)

Four shapes on one horizontal line, no wrap, no scroll — exactly the user's "4 guitar options visible so it would fit without scrolling."


2. Piano: 2×2 of compact MiniPianos (dashboard dense only)

2.1 The grid

Under dense the four pianoVoicing styles (root / shell / rootlessA / rootlessB, PIANO_STYLES :67) render as a 2-column × 2-row CSS grid (grid-cols-2 gap-1.5) — one clean 2×2 per chord. Non-dense (VoicingsSection, ChordDetailModal) keeps today's flex-wrap gallery at MiniPiano size="thumb" (0.8) with ▶ — byte-identical. As with guitar, the separate own-cell is dropped for piano; the 2×2 is the chord's voicings. If the station's authored voicing corresponds to one of the four styles (match on voicing.style), that cell gets the accent border — the "recommended" signal, for free, without a fifth cell. (Fuzzy-authored voicings that match no style → no highlight; honest.)

Because the own-cell is gone, the piano grid spans the full row interior (the basis-[320px] flex-1 gallery column grows to ~451px), so each of the two grid columns is (451 6)/2 = 222.5px.

2.2 The scale — arithmetic

MiniPiano VOICING thumb geometry: baseW = 22·(7·OCTAVES + 1) + 2, SVG_W = baseW · scale, SVG_H = (60 + 4)·scale. Today thumb scale = 0.8:

crop baseW SVG_W @0.8 cell box @0.8 (+14) two-up + gap
1-octave 178 142.4 156.4 318.8 ≤ 451 ✓
2-octave 332 265.6 279.6 565.2 > 451 ✗

So 0.8 cannot put two 2-octave crops side by side (the brief's 532>456). The 2-octave crop (the rootless 7th-chord voicings — rootlessA/B of min7/dom7/ maj7 whose span pushes past one octave) dictates the scale. Solve for a column of 222.5px:

332·scale + 14 ≤ 222.5  →  scale ≤ 208.5 / 332 = 0.628 (the ceiling)

Chosen: a new uniform size="mini" at scale 0.60 — below the 0.628 ceiling, leaving a robust ~19px margin for sub-pixel rounding and the classic-scrollbar worst case:

crop @ 0.60 SVG_W cell box (+14) two-up + gap (6) fits 451?
1-octave 106.8 120.8 247.6 ✓ (203 spare)
2-octave 199.2 213.2 432.4 ✓ (18.6 spare)

grid-cols-2 sizes every cell to the 222.5px column, so 1- and 2-octave cells share one uniform grid — a true 2×2 at both crops. A thumb crop never exceeds 2 octaves (a 4-note pianoVoicing spans ≤24 semitones; OCTAVES = ceil((maxNote octStart·12)/12) ≤ 2), so 2-octave is the proven worst case — no 3-octave escape hatch needed.

2.3 Legibility at 0.60 — the marks stay honest

White key = 22·0.60 = 13.2px wide (vs ChordDiagram's 11px string gap — comparable, legibly playable). The root/bass signal is carried primarily by colour, which does not shrink in meaning: root key = full accent #a855f7, other tones = ACCENT_SOFT #c084fc, bass = amber ring #fbbf24 — the established tier language. The "R" glyph and the amber ring are secondary reinforcement. Because the SVG viewBox scales text with the keys, at 0.60 the R renders ~4.8px and the ring ~1.2px — thin. So size="mini" bumps the in-SVG R fontSize (8→10 in viewBox units) and the bass-ring strokeWidth (2→2.5) so both stay readable at the smaller render. This is an additive branch in MiniPiano.jsx (Muse-owned SVG renderer); the existing thumb/full paths are byte-untouched, so the modal and dock render identically.

2.4 Row height (piano)

MiniPiano mini SVG_H = 64·0.60 = 38.4px. Piano GalleryCell (dense, no play): caption ~13 + gap-1.5 (6) + SVG 38.4 + p-1.5 (12) + border (2) = 71.4px. Two rows + grid row-gap (6) = 148.8px for the 2×2 block — versus the old gallery's ~3 wrapped cell-lines of ~118px each (~366px). Nearly halved (§6).


3. Remove ▶ everywhere (deliberate global removal)

Standing principle (memory): glance over audio. The user settled it — "then leave them off, better not." So ▶ comes off every mount: the dashboard rail, the dashboard strip, and the Knowledge Center + ChordDetailModal. Because no mount wants playback, the machinery is deleted outright, not gated — this is intended, not a side-effect.

3.1 VoicingBrowser.jsx (L-70) — delete the play machinery

  • The PlayButton component (:103-121).
  • import { playVoicing, guitarShapeToNotes } from '../lib/chordAudio' (:62).
  • handleRef + stopCurrent (:184-188) and the useEffect(() => stopCurrent, [chordKey]) cleanup (:191) — both now dead.
  • playGuitar / playPiano handlers (:197-211).
  • The <PlayButton> inside GalleryCell (:149) and its playLabel/onPlay props; the playLabel/onPlay passed at each call site (:243-244, :280-281).
  • The mic-feedback microcopy (:300-309) — nothing plays anywhere now, so the caveat is false; delete it.
  • Now-unused after the above: the useRef/useEffect imports and the chordKey local (only the deleted effect read it). Update the file-header comment.

This is the only permitted change to the non-dense (VoicingsSection / ChordDetailModal) render — those mounts are otherwise byte-identical (all shapes, normal piano layout); the visual dense-fork of §1/§2 leaves them alone.

3.2 GlanceRail.jsx (L-70) — dashboard-exclusive

GlanceRail is mounted only by the JamGuide rail, so its edits never leak:

  • Delete the footer microcopy "▶ previews play through your speakers…" (:292-295); replace with nothing (or a quiet "voicings follow the loop").
  • Delete the own-cell <figure> block (:215-231) — folded into VoicingBrowser per §1; pass recommended={st.shape} + max={4}.
  • Update the header comment (":45 …every ▶ lives inside the gallery").

3.3 The licks strip (L-71)

  • LickCard.jsx has no play path — the guitar tab card never had a ▶ (verified: no PlayButton, no chordAudio import). Nothing to remove there; the dock's size="full" LickCards are unchanged.
  • PianoLickCard.jsx is the only lick ▶. Remove: import { playVoicing, stopAll } from '../lib/chordAudio' (:73); the PREVIEW_BPM/currentSeq/ stopLick/playLick sequencer (:167-198); the PlayButton component (:484-502) and its render (:606-609); the useEffect(() => () => stopLick(), []) unmount silence (:530, then drop the now-unused useEffect import). Keep the resolveDegree import — realization needs it.

3.4 chordAudio.js stays — verified importers

grep chordAudio src/** → importers are VoicingBrowser.jsx (▶ deleted in §3.1), PianoLickCard.jsx (▶ deleted in §3.3), and BassPatternCard.jsx (untouched — the bass-rail pattern previews the user did not ask to remove, out of both locks); the rest are doc comments in voicings.js. ChordDetailModal.jsx does not import it directly — it plays via VoicingBrowser, so deleting that ▶ removes the modal's too, exactly as the user's global decision intends. So chordAudio.js stays (for BassPatternCard), but nothing in the voicing/lick UI plays.

3.5 The shared-sequencer blocker is eliminated

D-60 flagged that BassPatternCard and PianoLickCard each hold a private module-level currentSeq, so their previews could layer once both mount — the reason PianoLickCard was never wired into the strip. Removing PianoLickCard's playback deletes its currentSeq entirely: there is no piano sequencer left to conflict with. The strip becomes purely visual; BassPatternCard's sequencer lives only under the BASS selector (where the strip is hidden anyway). So PianoLickCard wires into the strip (§5) with no currentSeq collision — confirmed.


4. Hidden-but-scrollable dark scrollbars

4.1 Mechanism — a scoped utility, defined once in index.css (L-70)

/* Thin, dark, overlay-feel scrollbars for the jam dashboard's scrollers.
   Scoped (a class), NOT global — the KnowledgeDock/Debug/Tuner/Settings
   below keep the OS default. Raw hex is unavoidable here: ::-webkit-scrollbar
   pseudo-elements are not reachable by Tailwind utilities. The values mirror
   the design tokens (border #2a2a2a, panel #1a1a1a). */
.dark-scroll {
  scrollbar-width: thin;                    /* Firefox */
  scrollbar-color: #2a2a2a transparent;     /* thumb=border token · track transparent */
}
.dark-scroll::-webkit-scrollbar { width: 8px; height: 8px; }        /* WebKit/Blink */
.dark-scroll::-webkit-scrollbar-track { background: transparent; }
.dark-scroll::-webkit-scrollbar-thumb {
  background: #2a2a2a; border-radius: 4px;  /* border token */
}
.dark-scroll::-webkit-scrollbar-thumb:hover { background: #3a3a3a; }
  • Scoped, not global (*): the recommendation is a .dark-scroll opt-in class. Global would restyle every scroller in the app (the whole normal-mode page, the dock, Settings' full-screen scroller). The user's ask is about the dashboard's rail; keep the blast radius there.
  • Raw-hex exception, flagged to Maestro: the DoD says "no raw hex outside tailwind.config.js," but ::-webkit-scrollbar cannot consume a Tailwind class. index.css (which already carries the raw #0f0f0f body background) is the correct home; the values are documented as mirroring the border token. No new colour — #3a3a3a hover is a one-step lift of the same family; if Maestro prefers, use panel #1a1a1a for the base and border #2a2a2a for hover instead (both existing tokens). Either is fine.
  • Contrast: a #2a2a2a thumb on the #0f0f0f/#1a1a1a surface is intentionally quiet — it is chrome, not content, so it is exempt from AA text contrast; it is still clearly grabbable (the point of "make them black or something").

4.2 Where it applies — every dashboard scroll container

# scroller file:line task note
1 rail right column xl:overflow-y-auto JamGuide.jsx:484 L-71 the headline visible bar
2 left column (jam-view) xl:overflow-y-auto JamGuide.jsx:475 L-71 consistency
3 LicksStrip horizontal overflow-x-auto JamGuide.jsx:888 L-71 the licks row
4 per-cell overflow-x-auto (voicing) VoicingBrowser.jsx:148 L-70 rarely triggers now (§2) — apply for the edge case
5 timeline/keyboard overflow-x-auto PianoLickCard.jsx:576,585 L-71 wide-lick edge case

File-collision resolution (the brief's ask): the rail's outer vertical scroller lives in JamGuide.jsx, which is L-71's exclusive lock — not GlanceRail, not App.jsx. So the class definition ships in index.css (L-70), and each className="… dark-scroll" application is made by whichever task owns the file it lives in: L-70 applies it in VoicingBrowser (#4), L-71 applies it in JamGuide (#1-3) and PianoLickCard (#5). The class exists before L-71 runs because L-71 depends-on L-70 (already in the ledger). Zero shared files. The visible rail bar is styled in L-71 — acceptable, because that scroller was always a JamGuide-column concern, not a GlanceRail one.


5. Licks: uniform size + follow the instrument

5.1 The strip follows the global instrument

instrument already reaches the strip: App's global selector → JamGuide instrument prop (:177) → LicksStrip({ …, instrument }) (:433-445, :863). Today the strip always renders LickCard (guitar), even under PIANO, with an apologetic heading "(no piano licks in the KB yet)" (:885). Change:

  • guitarLickCard (unchanged renderer).
  • pianoPianoLickCard (wire it in — the D-60 component, never mounted). Needs rootPc + chordLabel from the playhead context station (which carries rootPc/quality/label), and each lick carries its own quality.
  • bass → the strip is already gated off (:432/:439 instrument !== 'bass'); render a slim honest line "No bass licks in the KB yet" instead of nothing, so the section doesn't silently vanish when a bassist is selected.

The licks reader licksFor(id) (:136) is hardcoded to instruments.guitar.licks — L-71 generalises it to licksFor(id, instrument), reading instruments.piano.licks under piano, and filtering piano licks to the structured ones (those with a notes array) so prose-only entries don't render as PianoLickCard placeholders (see §5.3).

5.2 Uniform footprint — measure, target, change

Both are wrapped by the strip in w-[220px] (closed) / w-[340px] (open) (:894), so width is already uniform. The gap is height — the two SVGs auto-size by different aspect ratios:

  • LickCard thumb (layoutTab): viewBox 96 units tall (17 + 5·14 + 9), TabSvg maxWidth = width·1.3; inside a 204px inner box it renders ~125px tall. Card ≈ 163px.
  • PianoLickCard thumb (layoutLick): height = 14 + plotH + 8 (+10 beat row), plotH = max(26, range·semi)varies with the lick's pitch range, so a wide lick is much taller than a narrow one and neither matches LickCard.

Target: a shared thumb card of 220 × 150px (closed), 340px wide (open). Imposed at the dashboard-strip mount, not inside the cards (per the scope refinement — do not touch LickCard's default footprint). The LicksStrip wrapper (JamGuide, L-71) that already sets w-[220px]/w-[340px] (:894) also sets a fixed content height and normalises the card's SVG to fill it, e.g. class="… h-[150px] [&_svg]:!h-[104px] [&_svg]:!w-full". The cards' SVGs already carry a viewBox, so preserveAspectRatio="xMidYMid meet" (the SVG default) scales the tab / timeline to the 104px box and centres it — zero edit to LickCard or PianoLickCard internals. Every card — guitar tab or piano roll — then occupies the same box, "all the same size," and flips instrument in place. (The maxWidth: width·1.3/1.4 inline caps mean a short lick renders narrower than 220 and centres — the card box is uniform; content is centred.)

Scope proof (KC untouched): the fixed-box CSS lives on the LicksStrip wrapper only — a dashboard-exclusive element. LickCard's and PianoLickCard's own geometry is unchanged, so the dock's LicksSection size="full" LickCards (:817) render byte-identical (the L-71 DoD asserts it). PianoLickCard has no KC mount at all.

5.3 KB coverage + empty-per-style behaviour

Verified against the KB (instruments.piano.licks with a structured notes array — PianoLickCard-renderable):

style guitar licks piano licks (structured)
jazz, blues, gospel, rnb ✓ (P-60/P-61)
pop prose only (over/description) — not renderable
bossa, country, funk, reggae, rock
  • Guitar: all 10 styles have tab licks — the strip works everywhere.
  • Piano: structured, renderable licks exist for jazz, blues, gospel, rnb only. Pop's piano.licks are the old prose education entries (no notes) — the structured filter drops them, so pop reads as empty under piano too.
  • Empty-per-style: today the strip returns null when empty (hides). Keep hide-on-empty for guitar (the default; a silent gap is fine). For piano with zero structured licks (pop + the 5 styles above), show a slim honest line — "No {style} piano licks yet" — because the user actively switched to piano and a vanished section is confusing there. Same slim line for bass.

6. Recomputed budget

Per-station row (row p-1.5 12 + border 2 + header block ~54 + gallery):

gallery block new row pre-polish row
Guitar 4 cells ×1 line, no ▶ = 105px ~173px ~207px (5-6 cells, ▶, wraps)
Piano 2×2 mini, no ▶ = 148.8px ~217px ~445px (0.8, ▶, 3 wrapped lines)

Rail overhead (section p-2 16 + border 2 + h4 title ~22 + footer ~40) ≈ 80px; rows joined by gap-2 (8px).

loop instrument new total pre-polish Δ
4-chord guitar 80 + 4·173 + 24 = 796 ~932 15%
4-chord piano 80 + 4·217 + 24 = 972 ~1884 48%
8-chord guitar 80 + 8·173 + 56 = 1520 ~1720 12%
8-chord piano 80 + 8·217 + 56 = 1872 ~3640 49%

Headline: the piano rail nearly halves (48%), and the guitar row is now a guaranteed single line (≤4, never wraps). Every total still exceeds the 500px column, so the rail remains the dashboard's one vertical scroller — which is exactly what §2's dark thin scrollbar dresses, and what the user licensed ("it can scroll if we need to"). The polish makes each row compact and the scroll pretty; it does not (and need not) fit a whole loop in 500px.


7. Migration order + bounded scopes

L-70 — the rail. Files (file-disjoint from L-71): src/components/GlanceRail.jsx, src/components/VoicingBrowser.jsx, src/components/MiniPiano.jsx (new size="mini" + R/ring bump — additive), src/index.css (define .dark-scroll). Does: guitar ≤4 recommended-first, dense-only (§1); piano 2×2 mini @0.60, dense-only (§2); ▶ deleted globally from VoicingBrowser, own-cell + footer from GlanceRail (§3.1-3.2); .dark-scroll defined + applied to VoicingBrowser's cell scroller (§4 #4). MiniPiano.jsx is safe to lock here — no other polish task touches it (PianoLickCard/LickCard carry their own keyboard geometry and do not import MiniPiano). DoD byte-identity: the non-dense VoicingBrowser render (VoicingsSection + ChordDetailModal) is byte-identical to today except the ▶ is removed; MiniPiano thumb/full byte-untouched (new mini is additive).

L-71 — the licks. Files: src/components/JamGuide.jsx (LicksStrip + licksFor + the fixed-box wrapper + the scroller classNames), src/components/PianoLickCard.jsx. LickCard.jsx is not edited — it has no ▶ and its footprint is imposed from the strip wrapper (§5.2), so it drops out of the lock (dock LicksSection unaffected). Does: uniform 220×150 thumb via the strip wrapper (§5.2); instrument branch + PianoLickCard wiring + piano/bass empty states (§5.1/5.3); ▶ deleted from PianoLickCard (§3.3); .dark-scroll applied to JamGuide's three scrollers + PianoLickCard's (§4 #1-3,5). DoD byte-identity: the dock LicksSection's size="full" LickCards render byte-identical to today (no ▶ existed to remove there).

Serialise L-70 → L-71 (already the ledger's depends-on): they are file-disjoint (no shared file — the .dark-scroll class is defined in L-70's index.css and merely referenced by L-71's JSX, which is not a file edit collision), and L-71 needs L-70's class to exist. JamGuide.jsx belongs to exactly one task (L-71), so the rail-column scrollbar has a single owner — no shared-file clash. Critic gates each; C-70 closes the sprint.

Rejected alternatives

  1. Shrink MiniPiano's global thumb scale (0.8→0.6) instead of a new mini. Rejected — it also shrinks the ChordDetailModal and dock thumbs, which have room to spare and benefit from the larger keys; a scoped mini variant keeps those byte-identical and confines the change to the rail.
  2. Give 2-octave piano crops their own full-width row (mixed cell sizes) at a larger scale (~0.7). Rejected — it breaks the clean "2×2 for each chord" the user asked for; a uniform 0.60 grid keeps every chord a tidy 2×2 and 0.60 is still legibly playable (13.2px keys ≈ the guitar diagram's string gap).
  3. Keep the separate own-cell and cap the gallery to 3. Rejected — it leaves the ≤4 rule split across two components (own-cell in GlanceRail, cap in VoicingBrowser) and preserves the today's recommended/gallery duplicate; centralising in VoicingBrowser (§1.1) is one place, one rule, no dupe.
  4. Global * scrollbar styling. Rejected — restyles the entire app (dock/Debug/Tuner/Settings) for a dashboard-scoped ask; a .dark-scroll opt-in class is surgical.