From 3fdc72ebd4b69215f7497f0af32653d3279602e0 Mon Sep 17 00:00:00 2001 From: vadimwit Date: Mon, 13 Jul 2026 12:33:37 +0100 Subject: [PATCH] =?UTF-8?q?docs(design):=20dashboard-polish=20concept=20(t?= =?UTF-8?q?ask=20D-70)=20=E2=80=94=20rail/licks=20optimizations,=20accepte?= =?UTF-8?q?d=20to=20unblock=20the=20build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/dashboard-polish.md | 510 ++++++++++++++++++++++++++++++++ 1 file changed, 510 insertions(+) create mode 100644 docs/design/dashboard-polish.md diff --git a/docs/design/dashboard-polish.md b/docs/design/dashboard-polish.md new file mode 100644 index 0000000..c39ed4e --- /dev/null +++ b/docs/design/dashboard-polish.md @@ -0,0 +1,510 @@ +# 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: + +- `VoicingBrowser` — *visual, 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. Guitar: ≤4 shapes, recommended-first (dashboard `dense` only) + +### 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 `` 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 `
` 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) + +```css +/* 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: + +- `guitar` → `LickCard` (unchanged renderer). +- `piano` → `PianoLickCard` (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.