7 Commits

Author SHA1 Message Date
vadimwit 89b3e323ea ledger: L-70/L-71/L-74b done (combined-gate PASS) — rail/licks polish live 2026-07-13 12:59:39 +01:00
vadimwit 6885fc3ce1 fix(dashboard): Try-this card visible for any detected/rolled loop, not only live input (task L-74b)
The rotating Try-this card was gated on a live-detected currentChord,
so a rolled Jam Roulette (which locks a key + loop but leaves the
history empty until you play) showed nothing. TryThis now falls back
to the first loop chord that yields a substitution, so the card is
visible whenever a loop is on screen in a locked key; the live-playing
path is unchanged and honest-empty (no key / no loop) is preserved.
Critic PASS (combined gate: renders for the rolled-jam case, no
infinite render).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 12:58:40 +01:00
vadimwit 19b1a4ff91 feat(licks): dashboard licks strip — uniform compact cards following the instrument (task L-71)
The dashboard licks strip now follows the global GUITAR/PIANO/BASS
selector: guitar shows LickCard tabs, piano shows the PianoLickCard
piano-roll (jazz/blues/gospel/rnb), bass shows an honest empty line.
Every strip card is a uniform compact 220x150 box (tab/roll normalized
to a 104px-tall panel) so the licks stop hogging space. Play buttons
removed from the strip. Knowledge Center LicksSection + LickCard left
byte-identical. .dark-scroll applied to the rail/strip scrollers.
Critic PASS (combined gate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 12:58:40 +01:00
vadimwit aae69e8a7b feat(rail): voicings rail polish — 4 guitar shapes, 2x2 mini piano, no play buttons, dark scrollbars (task L-70)
Dashboard voicings rail: guitar galleries capped to 4 shapes
(recommended-first, then open, then movable by base fret; prop-gated so
the Knowledge Center and chord modal keep all shapes); piano voicings
render as a 2x2 grid of size="mini" (scale 0.60) keyboards under the
rail's dense path only. Play buttons removed globally from
VoicingBrowser per the user ("leave them off, better not") — the
Knowledge Center + modal render byte-identical minus the buttons.
A scoped .dark-scroll utility (thin, #2a2a2a on transparent) dresses
the dashboard scrollers. Critic PASS (combined gate: byte-identity
proven, zero play buttons everywhere, contract intact).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 12:58:39 +01:00
vadimwit c16e9f1827 ledger: L-74b done (TryThis loop-fallback visibility); batch-gate plan for rail/licks 2026-07-13 12:40:48 +01:00
vadimwit 9e21df127c ledger: D-70 accepted; L-70/L-71 claimed (rail/licks build); play buttons global per user 2026-07-13 12:34:15 +01:00
vadimwit 3fdc72ebd4 docs(design): dashboard-polish concept (task D-70) — rail/licks optimizations, accepted to unblock the build 2026-07-13 12:33:37 +01:00
9 changed files with 874 additions and 301 deletions
+4 -4
View File
@@ -72,9 +72,9 @@ Standing principles (memory): scroll > click; nothing duplicated; one global ins
| id | title | domain | status | depends-on | files (lock) | definition of done |
|----|-------|--------|--------|-----------|--------------|--------------------|
| M-08 | Seed `sprint-dashboard-polish` | maestro | done | — | `docs/agents/LEDGER.md` | seeded |
| D-70 | Rail + licks layout concept doc: (a) guitar ≤4 shapes with the selection rule (open + common movable, lowest-position-first — name it); (b) piano 4 voicings as a 2×2 grid of compact MiniPianos — pick the MiniPiano thumb scale that fits two side-by-side in the ~456px column interior and two rows within a sane row height, honest math; (c) all ▶ removed from the rail's guitar+piano cells AND the licks strip (name every removal site); (d) hidden-but-scrollable scrollbars — the mechanism (webkit ::-webkit-scrollbar thin + dark thumb, and Firefox scrollbar-width/color; overlay where supported) and WHERE it applies (the rail's overflow-y column, any inner scrollers); (e) licks: uniform card footprint (thumb size parity between LickCard and PianoLickCard) + the strip follows the global instrument (guitar→piano licks on PIANO; bass honest empty); (f) recompute the row heights + the 500px column budget with the new smaller cells; migration order with bounded L-70/L-71 scopes (keep them file-disjoint or serialize on JamGuide.jsx). No user gate: pick strongest, record rationale + ≥2 rejected alternatives | design | claimed | — | `docs/design/dashboard-polish.md` | every change specced with honest numbers; the 2×2 piano scale chosen + proven to fit; bounded impl scopes |
| L-70 | Implement the voicings rail per D-70: guitar ≤4, 2×2 smaller piano, ▶ removed, dark hidden scrollbars | engineering | backlog | D-70 | `src/components/GlanceRail.jsx`, `src/components/VoicingBrowser.jsx`, `src/index.css` (scrollbar CSS) (+ per doc — re-lock at promotion) | rail matches the spec; no ▶; guitar ≤4; piano 2×2; scrollbars hidden+dark+functional; build + smoke green |
| L-71 | Implement the licks strip per D-70: uniform card size, follow the global instrument (wire PianoLickCard), ▶ removed | engineering | backlog | D-70, L-70 | `src/components/JamGuide.jsx` (LicksStrip), `src/components/LickCard.jsx`, `src/components/PianoLickCard.jsx` (+ per doc — re-lock at promotion) | licks uniform; piano licks show under PIANO; no ▶; bass honest; build + smoke green |
| D-70 | Rail + licks layout concept doc: (a) guitar ≤4 shapes with the selection rule (open + common movable, lowest-position-first — name it); (b) piano 4 voicings as a 2×2 grid of compact MiniPianos — pick the MiniPiano thumb scale that fits two side-by-side in the ~456px column interior and two rows within a sane row height, honest math; (c) all ▶ removed from the rail's guitar+piano cells AND the licks strip (name every removal site); (d) hidden-but-scrollable scrollbars — the mechanism (webkit ::-webkit-scrollbar thin + dark thumb, and Firefox scrollbar-width/color; overlay where supported) and WHERE it applies (the rail's overflow-y column, any inner scrollers); (e) licks: uniform card footprint (thumb size parity between LickCard and PianoLickCard) + the strip follows the global instrument (guitar→piano licks on PIANO; bass honest empty); (f) recompute the row heights + the 500px column budget with the new smaller cells; migration order with bounded L-70/L-71 scopes (keep them file-disjoint or serialize on JamGuide.jsx). No user gate: pick strongest, record rationale + ≥2 rejected alternatives | design | done (`3fdc72e`; final scope: play buttons OFF EVERYWHERE per user; guitar-4/2x2-piano/uniform-licks dashboard-scoped) | — | `docs/design/dashboard-polish.md` | every change specced with honest numbers; the 2×2 piano scale chosen + proven to fit; bounded impl scopes |
| L-70 | Implement the voicings rail per D-70: guitar ≤4, 2×2 smaller piano, ▶ removed, dark hidden scrollbars | engineering | done `aae69e8` (combined-gate PASS) | D-70 | `src/components/GlanceRail.jsx`, `src/components/VoicingBrowser.jsx`, `src/index.css` (scrollbar CSS) (+ per doc — re-lock at promotion) | rail matches the spec; no ▶; guitar ≤4; piano 2×2; scrollbars hidden+dark+functional; build + smoke green |
| L-71 | Implement the licks strip per D-70: uniform card size, follow the global instrument (wire PianoLickCard), ▶ removed | engineering | done `19b1a4f` (combined-gate PASS; L-74b `6885fc3` visibility fix too) | D-70 | `src/components/JamGuide.jsx` (LicksStrip), `src/components/LickCard.jsx`, `src/components/PianoLickCard.jsx` (+ per doc — re-lock at promotion) | licks uniform; piano licks show under PIANO; no ▶; bass honest; build + smoke green |
| D-72 | Same-style related-progressions design (user answer = reuse existing KB): decide the presentation — when a style is active, RelatedProgressions leads with same-style siblings reframed as "variations / sections to try in {style}" (labels/section framing that reads as bridge/chorus/variation without new content); how the active style is known (roulette seed carries it; live detection's match yields it — name the prop/source); whether cross-style entries stay as a secondary "other styles with these changes" section or are dropped when a style is locked; empty/edge states. Files disjoint from the rail/licks chain (RelatedProgressions.jsx only) → runs in PARALLEL | design | done | — | `docs/design/related-same-style.md` | presentation spec'd against the real RelatedProgressions/match.js; active-style source named; L-72 bounded |
| L-72 | Implement D-72: RelatedProgressions leads with same-style variations when a style is active; reframed labels; cross-style demoted/dropped per the doc. **Unblocked 2026-07-13** (no song forms). Resolutions: (finding-A) when a style IS locked, DROP the secondary cross-style section — same-style only, per goal #6 + the user's "not necessarily other styles"; keep cross-style ONLY when no style is locked (match.matched===false). (finding-B) null the role phrase when a same-style sibling shares NO transitions with the loop (no false "variation" claim — just name + level). Re-pin smoke §8 labels ('shares I7→V7'→'shorter form' etc.; scores stable) | engineering | claimed | D-72 | `src/components/RelatedProgressions.jsx`, `scripts/smoke.mjs` (§8 re-pin) | same-style-only when locked; honest cross-style only when unlocked; role phrase honest (finding-B); build + smoke green — **PASS** (`see commit` — findings A/B verified; gate added 2 durable smoke assertions, 893/893) |
| D-73 | **"Try this" chord-substitution feature** design (engine rules + UI): for a loop chord `{rootPc,quality}` in the detected key/style, a SMALL curated set (~3-4) of alternative chords — recommend the categories (relative/diatonic-third sub, borrowed-minor colour e.g. IV→iv, extension/colour e.g. maj7/add9/sus, a circle-of-fifths / secondary-dominant move) — each with a plain one-line WHY that teaches. Concrete worked examples for the user's AmCF case (both A-minor and C-major readings), arithmetically correct (the gate WILL recompute). The UI surface: where it mounts in the jam dashboard (per-station in the rail? a "Try this" line under the loop? — decide, keep it glanceable, tappable→ChordDetailModal), honest circle-of-fifths tie-in (a lens for the relative/neighbour subs, NOT claimed for borrowed/extension), edge states (no key / atonal), keep-it-simple (learnable, not a reharm engine). Use a SEPARATE component (disjoint from RelatedProgressions.jsx). No user gate: pick strongest, ≥2 rejected alternatives | design | done (returned once — 3 copy/plumbing fixes verified: chordRootPC→noteIndex circular-import, ♭6 flat spelling, mediant claim; rules all recomputed correct; `c2207ef`) | — | `docs/design/try-this-subs.md` | substitution categories + rules + why-copy spec'd and arithmetically honest; AmCF worked; UI surface + mount decided; L-73/L-74 bounded |
@@ -82,7 +82,7 @@ Standing principles (memory): scroll > click; nothing duplicated; one global ins
| L-74 | "Try this" UI per D-73, with VARY/ROTATE (user decided 2026-07-13: "more surprising, more jam-like, keeps offering new ideas"): a new `TryThis.jsx` that, for the current playhead chord, shows ONE suggestion at a time (matches "try this" = a single nudge) and CYCLES to the next valid substitution each time the loop completes a pass (playhead wraps to station 0 — watch the position prop). Sequential cycling through the engine's ordered valid set (softest-first on first appearance) so the player eventually learns all options and never repeats until exhausted; a small "1 of N" / dot indicator; 0 subs → no card; 1 sub → static (no rotation). Tappable→ChordDetailModal; tokens/AA; glanceable. Supersedes D-73's static ≤4-chip display for the UI (engine/rules unchanged). Mount App.jsx-only in relatedSlot above RelatedProgressions (disjoint) | engineering | backlog | D-73, L-73 | `src/components/TryThis.jsx` (new), `src/App.jsx` (mount) | one fresh honest sub per chord, cycling each loop pass; zero-click; empties honest; build + smoke green | **done** `038fdfa` (Critic PASS — 2-pass rotation trace Dm→Fm verified, contract-clean)
| C-70 | Sprint-end sweep + PR #3 update (covers rail/licks + related + try-this) | quality | backlog | L-70, L-71, L-72, L-74 | (none — verification) | all green; PR updated |
> Sequencing (three parallel chains, file-disjoint): (A) D-70 → L-70 → L-71 (rail/licks); (B) D-72✓ → L-72 (related, same-style); (C) D-73 → L-73 → L-74 (try-this subs — theory.js + new TryThis.jsx, disjoint from A/B). C-70 closes after all. Critic gates every task.
> Sequencing (three parallel chains, file-disjoint): (A) D-70 → L-70 → L-71 (rail/licks); (B) D-72✓ → L-72 (related, same-style); (C) D-73 → L-73 → L-74 (try-this subs). ALL DONE except A's builds. **L-74b (2026-07-13, in-review):** TryThis loop-fallback so the card is visible for a rolled/detected loop without live input (user "i dont see rotating cards") — TryThis.jsx only, SSR-verified. **Batch plan:** L-70 ‖ L-71 ‖ L-74b (disjoint) → ONE combined gate (rail/licks + dock byte-identity + full-app SSR + contract) → commit all → push both remotes. C-70 closes.
---
+510
View File
@@ -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 `<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)
```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.
+30 -51
View File
@@ -28,21 +28,20 @@
// "loop"). This is where the retired RoadmapTrack's education
// folds in (D-40 §2) theory.js `guideTones` / `voiceLeadingPairs`
// / `soloScale`, read-only imports.
// gallery first cell = the station's OWN voicing (guitar: the KB play's
// recommended shape badged "play", when present; piano: the
// threaded/authored voicing labeled honestly, e.g. "LH 3-5-7-9"
// the accordion's collapsed-thumb value survives here) + the full
// VoicingBrowser gallery (show={instrument}, dense). Cells
// FLEX-WRAP rows never scroll horizontally; the piano worst case
// (~1,470px of cells) wraps to a second cell line instead (§4).
// gallery the full VoicingBrowser (show={instrument}, dense) with the
// station's OWN voicing passed as `recommended` so it renders as the
// badged, accent-bordered first cell INSIDE the browser (no separate
// own-cell, no dupe dashboard-polish.md §1.1/§2.1). Guitar caps at
// 4 recommended-first shapes on one line (§1); piano is a 2×2 of
// `size="mini"` keyboards (§2). No anywhere (§3).
//
// Focus semantics (D-40 §4 the pin, simplified): with everything always
// expanded there is nothing left to hold open, so tapping a row header TOGGLES
// that station as focused. The PARENT owns the state and the onFocusChord
// emission (the D-03 fretboard guide-tone contract, byte-compatible); a focused
// row shows an "aim on fretboard" chip; tap again (or the loop changes) to
// clear. This component never emits focus-chord itself and NEVER triggers
// audio on its own every lives inside the gallery, behind a user gesture.
// clear. This component never emits focus-chord itself and never triggers audio
// the previews were removed everywhere (dashboard-polish.md §3).
//
// NO auto-scroll (D-40 §4/§6.2 step 1): the band lives in page flow, where
// scrollIntoView's nearest scroller is the DOCUMENT it would yank the whole
@@ -61,8 +60,6 @@
// keyMode key mode name (soloScale's minor-key dominant nudge)
import { NOTES, guideTones, voiceLeadingPairs, soloScale } from '../lib/theory'
import ChordDiagram from './ChordDiagram'
import MiniPiano from './MiniPiano'
import VoicingBrowser from './VoicingBrowser'
const pcName = (pc) => NOTES[((pc % 12) + 12) % 12]
@@ -139,9 +136,12 @@ function TransitionChip({ pair, wraps }) {
function StationRow({
st, isNow, isNext, isFocused, onToggleFocus, instrument, keyRoot, keyMode, rail, wraps,
}) {
// The station's own voicing the first gallery cell (D-40 §4).
const ownGuitar = instrument === 'guitar' && st.shape ? st.shape : null
const ownPiano = instrument === 'piano' && st.voicing ? st.voicing : null
// The station's own voicing passed to VoicingBrowser as `recommended` so it
// renders as the badged, accent-bordered first cell inside the gallery (guitar:
// a shape; piano: a voicing matched by style). The separate own-cell is gone
// (dashboard-polish.md §1.1/§2.1 it centralises the 4 rule and kills the old
// recommended/gallery duplicate).
const recommended = instrument === 'guitar' ? (st.shape ?? null) : (st.voicing ?? null)
// Active row: unmistakable (accent ring + tint). Focused-but-not-now rows get
// the softer accent border; everything else recedes to the 0.85 opacity floor
@@ -201,39 +201,19 @@ function StationRow({
<TransitionChip pair={rail} wraps={wraps} />
</div>
{/* Gallery: own-voicing cell FIRST (the recommended cell full-accent
border reads as "the answer", one-screen.md §4; same size, no new
colour) + the full dense browser; cells WRAP, never scroll
horizontally (D-40 §4). Browser basis 320px: a guitar own-cell
(89px) still shares line one (89+6+320 = 415 451) so the first
visual line shows play + 3 shapes = 4 across, while a piano
own-cell (156.4px) can never share it (156.4+6+320 = 482.4 > 468
even scrollbar-free) the recommended voicing sits alone on line
one, prominence for free, and the gallery pairs at the FULL row
interior below it (the 279.6+156.4 mixed-pair fit needs all 451px). */}
<div className="flex flex-wrap items-start gap-1.5">
{(ownGuitar || ownPiano) && (
<figure className="flex shrink-0 flex-col items-center gap-1.5 rounded-md border border-accent bg-surface p-1.5">
<figcaption className="flex max-w-full items-center gap-1.5 text-[11px] font-medium leading-tight text-gray-300">
{ownGuitar && (
<span className="rounded bg-accent px-1 py-0.5 text-[9px] font-bold uppercase tracking-wider text-black">
play
</span>
)}
<span className="break-words">{ownGuitar ? ownGuitar.label : ownPiano.label}</span>
</figcaption>
{ownGuitar ? (
<ChordDiagram shape={ownGuitar} keyRoot={keyRoot} rootPc={st.rootPc} size="thumb" />
) : (
<MiniPiano voicing={ownPiano} size="thumb" />
)}
</figure>
)}
<div className="min-w-0 flex-1 basis-[320px]">
{/* dense: the rail shows the mic-feedback microcopy once, below. */}
<VoicingBrowser rootPc={st.rootPc} quality={st.quality} show={instrument} dense />
</div>
</div>
{/* ── Gallery (dashboard-polish.md §1/§2): the recommended voicing is now
cell #1 INSIDE the browser (badged "play", accent border "the
answer" prominence the old own-cell had, no dupe). Guitar caps at 4
recommended-first cells on one line; piano is a 2×2 of mini
keyboards. Full row interior, no separate own-cell. */}
<VoicingBrowser
rootPc={st.rootPc}
quality={st.quality}
show={instrument}
dense
recommended={recommended}
max={4}
/>
</div>
)
}
@@ -287,11 +267,10 @@ export default function GlanceRail({
))}
</div>
{/* Mic-feedback microcopy ONCE for the whole rail (D-31 §2.5); the
gallery mounts run `dense` and suppress their per-mount copy. */}
{/* No anywhere anymore (dashboard-polish.md §3 "leave them off,
better not"); the rail is purely visual and follows the loop. */}
<p className="mt-2 text-[11px] text-gray-500">
previews play through your speakers while the mic is live, detection may
hear them. Nothing plays automatically.
Voicings follow the loop the playhead highlights the chord you're on.
</p>
</section>
)
+102 -41
View File
@@ -6,6 +6,7 @@ import GlanceRail, { AimDots, SoloLabel } from './GlanceRail'
import BassPatternCard from './BassPatternCard'
import VoicingBrowser from './VoicingBrowser'
import LickCard, { TechniqueLegend } from './LickCard'
import PianoLickCard from './PianoLickCard'
import { ExploreSection, VoicingsSection, LevelChips } from './ExplorePanel'
import { pianoVoicingChain } from '../lib/piano'
import { parseChord } from '../lib/voicings'
@@ -132,8 +133,16 @@ function recipeVoicing(recipe, rootPc, quality) {
//
// `licksFor` was a closure-local inside LicksSection; lifted to module scope
// during the L-33 restructure (D-31 §5) so the strip shares it instead of
// duplicating the defensive read. Licks are guitar-only in the KB (C-20 schema).
function licksFor(id) {
// duplicating the defensive read. Guitar licks are tab entries (LickCard);
// piano licks (D-70 §5.1) are the STRUCTURED entries only those carrying a
// `notes` array PianoLickCard can realize so prose-only piano education
// entries never render as placeholder cards. The dock's LicksSection reads
// guitar (its default arg), so its behaviour is unchanged.
function licksFor(id, instrument = 'guitar') {
if (instrument === 'piano') {
const l = kb?.[id]?.instruments?.piano?.licks
return Array.isArray(l) ? l.filter(x => Array.isArray(x?.notes)) : []
}
const l = kb?.[id]?.instruments?.guitar?.licks
return Array.isArray(l) ? l : []
}
@@ -425,23 +434,25 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
</p>
)
// The licks strip (left column). Matched loops sort by the playhead
// station; heard-live falls back to the live chord's quality key (e.g. a
// "dom7" lick fits a live G7). Bass hides it (guitar tab licks are noise
// to a bassist mid-jam); LicksStrip also hides itself when empty.
const licksStrip = instrument !== 'bass' && match.matched ? (
// The licks strip (left column). It now FOLLOWS the global instrument
// (D-70 §5.1): guitar tab LickCards, piano PianoLickCards realized over
// the playhead root, bass an honest "no bass licks" line. Matched loops
// sort by the playhead station; heard-live falls back to the live chord's
// quality key (e.g. a "dom7" lick fits a live G7). The live-chord context
// carries rootPc so PianoLickCard can realize its degrees. LicksStrip itself
// decides the empty behaviour per instrument (guitar hides; piano/bass show a
// slim honest line).
const licksContext = match.matched
? contextStation
: liveChord
? { rn: '', quality: liveChord.type, label: currentChord, rootPc: liveChord.rootPc }
: null
const licksStrip = (match.matched || liveChord) ? (
<LicksStrip
styleId={activeStyle}
levels={ALL_LEVELS}
instrument={instrument}
context={contextStation}
/>
) : instrument !== 'bass' && liveChord ? (
<LicksStrip
styleId={activeStyle}
levels={ALL_LEVELS}
instrument={instrument}
context={{ rn: '', quality: liveChord.type, label: currentChord }}
context={licksContext}
/>
) : null
@@ -472,7 +483,7 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
</div>
)}
{relatedSlot != null && (
<div className={'order-4 xl:order-none min-w-0' + (fill ? ' xl:flex-1 xl:min-h-0 xl:overflow-y-auto' : '')}>
<div className={'order-4 xl:order-none min-w-0' + (fill ? ' xl:flex-1 xl:min-h-0 xl:overflow-y-auto dark-scroll' : '')}>
{relatedSlot}
</div>
)}
@@ -481,7 +492,7 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
{/* RIGHT — the suggested-voicings rail (the one contained scroller) */}
<div
className={
'order-2 xl:order-none min-w-0 xl:w-[500px] xl:shrink-0 xl:overflow-y-auto ' +
'order-2 xl:order-none min-w-0 xl:w-[500px] xl:shrink-0 xl:overflow-y-auto dark-scroll ' +
(fill ? 'xl:h-full' : 'xl:max-h-[calc(100vh_-_1.5rem)]')
}
>
@@ -844,52 +855,101 @@ function LicksSection({ styles, levels, onToggleLevel }) {
)
}
// LicksStrip glanceable licks below the rail (L-33, D-31 §2.4)
// LicksStrip glanceable licks below the rail (L-33, D-31 §2.4; D-70 §5)
//
// Thumb LickCards for the active style, level-filtered, sorted current-station-
// Thumb lick cards for the active style, level-filtered, sorted current-station-
// context-first via the token-boundary matcher above. The "fits X now" accent
// ring + microcopy are STRIP-OWNED chrome rendered AROUND the card LickCard
// itself is untouched and shows chordContext only at size="full". Licks are
// guitar-only in the KB, so under the piano tab the strip still shows them and
// the heading says so. Style has no licks (or the level filter empties it)
// the strip hides entirely: an empty state would steal glance space to say
// nothing. Tap a thumb the card enlarges inline (comfort, not information).
// ring + caption are STRIP-OWNED chrome rendered AROUND the card the cards
// themselves are untouched and show chordContext only at size="full".
//
// FOLLOWS THE GLOBAL INSTRUMENT (D-70 §5.1):
// guitar LickCard (tab); piano PianoLickCard, realized over the playhead
// root (context.rootPc); bass the KB has no bass strip licks, so a slim
// honest line renders instead of the section vanishing.
// Piano licks are the STRUCTURED ones only (`licksFor(id,'piano')` filters to
// entries with a `notes` array) jazz/blues/gospel/rnb ship them; other styles
// read as empty under piano.
//
// UNIFORM FOOTPRINT (D-70 §5.2), imposed AT THE STRIP MOUNT, not inside the
// cards: every closed thumb sits in a fixed 220×150 box whose SVG is normalised
// to h-104/w-full (`h-[150px] [&>div]:h-full [&_svg]:!h-[104px] [&_svg]:!w-full`).
// preserveAspectRatio (SVG default) scales each tab / piano-roll to that box and
// centres it, so a guitar card and a piano card occupy the SAME box zero edit
// to LickCard/PianoLickCard geometry, so the dock's size="full" cards are
// byte-identical. Tapping enlarges a card inline (size="full", unclamped).
//
// EMPTY BEHAVIOUR (D-70 §5.3): guitar hides on empty (a silent gap is fine, the
// default); piano/bass show a slim honest line the user actively switched
// instruments there, so a vanished section would be confusing.
//
// styleId KB style whose licks to show (the active style)
// levels the shared foundation/intermediate filter
// instrument current instrument tab (piano honest "guitar licks" heading)
// context { rn, quality, label } of the playhead station (or the live
// chord in the no-loop fallback); null no context sort
function LicksStrip({ styleId, levels, instrument, context }) {
// Inline enlarge (one card at a time); reset when the style changes.
const [expandedId, setExpandedId] = useState(null)
useEffect(() => { setExpandedId(null) }, [styleId])
// instrument the global GUITAR/PIANO/BASS selector
// context { rn, quality, label, rootPc } of the playhead station (or the
// live chord in the no-loop fallback); rootPc realizes piano
// licks, label/rn/quality drive the context sort; null no sort
function EmptyLicksLine({ children }) {
return (
<p className="rounded-xl border border-dashed border-border px-3 py-2 text-xs text-gray-500">
{children}
</p>
)
}
const visible = licksFor(styleId).filter(l => levels[lickLevel(l)])
function LicksStrip({ styleId, levels, instrument, context }) {
// Inline enlarge (one card at a time); reset when the style/instrument changes.
const [expandedId, setExpandedId] = useState(null)
useEffect(() => { setExpandedId(null) }, [styleId, instrument])
const styleLabel = kb?.[styleId]?.meta?.label ?? styleId
// Bass: no strip licks in the KB honest line, never a vanished section.
if (instrument === 'bass') {
return <EmptyLicksLine>No bass licks in the KB yet.</EmptyLicksLine>
}
const isPiano = instrument === 'piano'
const visible = licksFor(styleId, instrument).filter(l => levels[lickLevel(l)])
const fitted = visible.filter(l => lickFitsContext(l, context))
const rest = visible.filter(l => !lickFitsContext(l, context))
const sorted = [...fitted, ...rest]
if (sorted.length === 0) return null
if (sorted.length === 0) {
// Piano: honest line (the player just switched to piano). Guitar: hide.
return isPiano
? <EmptyLicksLine>No {styleLabel} piano licks in the KB yet.</EmptyLicksLine>
: null
}
const styleLabel = kb?.[styleId]?.meta?.label ?? styleId
const fitLabel = typeof context?.label === 'string' ? context.label : null
const rootPc = Number.isFinite(context?.rootPc) ? context.rootPc : 0
// Fixed-box normaliser applied only when closed (open cards grow freely).
const thumbBox = 'h-[150px] [&>div]:h-full [&_svg]:!h-[104px] [&_svg]:!w-full'
return (
<section
className="rounded-2xl border border-border bg-panel p-3"
aria-label={`${styleLabel} guitar licks`}
aria-label={`${styleLabel} ${instrument} licks`}
>
<h4 className="mb-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
{styleLabel} licks · guitar
{instrument === 'piano' ? ' (no piano licks in the KB yet)' : ''}
{styleLabel} licks · {instrument}
{fitted.length > 0 && fitLabel ? ` · fits ${fitLabel} first` : ''}
</h4>
<div className="flex items-start gap-2 overflow-x-auto pb-1" role="list">
<div className="flex items-start gap-2 overflow-x-auto pb-1 dark-scroll" role="list">
{sorted.map((l, idx) => {
const id = l?.id ?? `lick-${idx}`
const isFit = idx < fitted.length // sorted = fitted first, then rest
const isOpen = expandedId === id
const card = isPiano ? (
<PianoLickCard
lick={l}
rootPc={rootPc}
chordLabel={fitLabel ?? undefined}
size={isOpen ? 'full' : 'thumb'}
/>
) : (
<LickCard lick={l} size={isOpen ? 'full' : 'thumb'} />
)
return (
<div key={id} role="listitem" className={`shrink-0 ${isOpen ? 'w-[340px]' : 'w-[220px]'}`}>
<button
@@ -900,10 +960,11 @@ function LicksStrip({ styleId, levels, instrument, context }) {
className={
'block w-full rounded-lg text-left outline-none transition ' +
'focus-visible:ring-2 focus-visible:ring-accent ' +
(isFit ? 'ring-1 ring-accent' : '')
(isFit ? 'ring-1 ring-accent ' : '') +
(isOpen ? '' : thumbBox)
}
>
<LickCard lick={l} size={isOpen ? 'full' : 'thumb'} />
{card}
</button>
{isFit && fitLabel && (
<p className="mt-1 text-center text-[10px] font-medium text-accent">
+11 -5
View File
@@ -24,6 +24,11 @@
// octave-end at-or-above the highest (plus the closing high C) so a
// 1-octave shell renders ONE octave (~140px) instead of 23 (~266390px)
// and Jam Guide station thumbs sit level with guitar ChordDiagram thumbs.
// `size="mini"` (L-70, dashboard-polish.md §2) is the SAME cropped window at
// scale 0.60 (vs thumb 0.8) so two cells fit one grid column of the rail's
// ~451px interior (2-octave cell 213.2px < 222.5px); it bumps the in-SVG "R"
// glyph (810 vu) and the bass-ring stroke (22.5) so both stay legible at
// the smaller render. `thumb`/`full` are byte-untouched `mini` is additive.
// `size="full"` keeps the wide C-anchored 23-octave window: the enlarged
// view benefits from register context, and its output stays byte-identical
// to the D-10 behaviour.
@@ -177,6 +182,7 @@ function LegacyPiano({ rootPc, lh = [], rh = [] }) {
//
function VoicingPiano({ voicing, size }) {
const isFull = size === 'full'
const isMini = size === 'mini' // L-70: cropped-thumb window at 0.60 for the dashboard 2×2 grid
const notes = Array.isArray(voicing?.notes) ? voicing.notes : []
const rootPc = ((voicing?.rootPc ?? (notes.length ? notes[0] : 0)) % 12 + 12) % 12
const bass = typeof voicing?.bass === 'number' ? voicing.bass : (notes.length ? Math.min(...notes) : null)
@@ -199,7 +205,7 @@ function VoicingPiano({ voicing, size }) {
: Math.max(1, Math.ceil((maxNote - octStart * 12) / 12))
// White keys: OCTAVES full octaves + 1 trailing C closing the top octave.
const TOTAL_WHITES = WHITE_PCS.length * OCTAVES + 1
const scale = isFull ? 1 : 0.8
const scale = isFull ? 1 : isMini ? 0.6 : 0.8
const baseW = WW * TOTAL_WHITES + 2
const SVG_W = baseW * scale
const SVG_H = (WH + (isFull ? 26 : 4)) * scale
@@ -267,12 +273,12 @@ function VoicingPiano({ voicing, size }) {
{hl?.isBass && (
<rect
x={x + 1} y={2} width={WW - 3} height={WH - 2}
rx={2} fill="none" stroke={BASS_RING} strokeWidth={2}
rx={2} fill="none" stroke={BASS_RING} strokeWidth={isMini ? 2.5 : 2}
/>
)}
{hl?.isRoot && (
<text x={x + (WW - 1) / 2} y={WH - 8}
textAnchor="middle" fill="white" fontSize={8} fontWeight="bold">
textAnchor="middle" fill="white" fontSize={isMini ? 10 : 8} fontWeight="bold">
R
</text>
)}
@@ -292,12 +298,12 @@ function VoicingPiano({ voicing, size }) {
{hl?.isBass && (
<rect
x={x + 1} y={2} width={BW - 2} height={BH - 2}
rx={2} fill="none" stroke={BASS_RING} strokeWidth={2}
rx={2} fill="none" stroke={BASS_RING} strokeWidth={isMini ? 2.5 : 2}
/>
)}
{hl?.isRoot && (
<text x={x + BW / 2} y={BH - 5}
textAnchor="middle" fill="white" fontSize={6} fontWeight="bold">
textAnchor="middle" fill="white" fontSize={isMini ? 8 : 6} fontWeight="bold">
R
</text>
)}
+7 -77
View File
@@ -49,14 +49,12 @@
// (MiniPiano's cropped-window geometry), technique chips, tips, source
// mirroring LickCard's full-size behaviour.
//
// Playback ()
// BassPatternCard's exact pattern: sequential single-note playVoicing calls
// (order lives in setTimeout scheduling playVoicing sorts/dedupes, wrong for
// a melody), beats at a fixed preview tempo (even eighths beatless), ONE
// sequence module-wide + stopAll() so it never layers over other previews,
// unmount silences. Techniques are visual-only in playback (same precedent).
// No playback (D-70 §3)
// The strip is purely visual glance over audio (the user's settled call, "then
// leave them off, better not"). The play path (sequencer + PlayButton) was
// removed with the rest of the s across the app; the card only renders now.
//
// Wiring contract (future LicksStrip integration Luthier)
// Wiring contract (LicksStrip integration Luthier, L-71)
// <PianoLickCard lick={} rootPc={011} chordLabel="Dm7" size="thumb|full" />
// lick one entry of a piano pack's top-level `licks` array
// rootPc the LIVE chord root pitch class (from the loop station)
@@ -67,10 +65,8 @@
// Design tokens (tailwind.config.js) SVG fills can't read Tailwind classes,
// so the constants below mirror the tokens (LickCard/MiniPiano convention).
import { useEffect } from 'react'
import { NOTES, CHORD_TYPES } from '../lib/theory'
import { resolveDegree } from './JamGuide'
import { playVoicing, stopAll } from '../lib/chordAudio'
// Vocabulary (hand-synced with validate-kb.mjs PIANO_LICK_TECHNIQUES; the
// smoke §7b-piano guard enforces set-equality add to BOTH or neither)
@@ -162,41 +158,6 @@ export function realizePianoLick(lick, rootPc) {
}))
}
// Sequential playback (module-level: one lick at a time, app-wide)
const PREVIEW_BPM = 96 // BassPatternCard's relaxed preview tempo
let currentSeq = null // { timeouts: number[], handles: {stop}[] }
function stopLick() {
if (!currentSeq) return
for (const t of currentSeq.timeouts) clearTimeout(t)
for (const h of currentSeq.handles) h.stop()
currentSeq = null
}
function playLick(realized) {
stopLick()
stopAll() // never layer over a VoicingBrowser (or any other) preview
const beatMs = 60000 / PREVIEW_BPM
const hasBeats = realized.every((n) => Number.isFinite(n.beat))
const times = realized.map((n, i) => (hasBeats ? (n.beat - 1) * beatMs : (i * beatMs) / 2))
const seq = { timeouts: [], handles: [] }
realized.forEach((n, i) => {
// Ring until the next distinct onset (equal beats = a dyad, same onset);
// the last note gets one beat. Small floor so tight ornaments still sound.
const nextT = times.slice(i + 1).find((t) => t > times[i])
const durMs = Math.max(160, (nextT !== undefined ? nextT - times[i] : beatMs) + 120)
seq.timeouts.push(
setTimeout(() => {
// abs is already in chordAudio's note space (0 = C3) no offset.
seq.handles.push(playVoicing([n.abs], { strumMs: 0, durMs, gain: 0.5 }))
}, times[i]),
)
})
currentSeq = seq
}
// Timeline layout (x = beat/column, y = pitch)
const PAD_T = 14 // headroom for grace glyphs above the top pill
@@ -479,28 +440,6 @@ function TechniqueChip({ tech }) {
)
}
// Same pill as BassPatternCard / VoicingBrowser (classes mirrored so every
// gallery reads identically).
function PlayButton({ ariaLabel, onClick }) {
return (
<button
type="button"
aria-label={ariaLabel}
onClick={onClick}
className={
'inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full border border-accent ' +
'bg-surface px-2.5 text-xs font-semibold text-accent outline-none transition ' +
'hover:bg-accent hover:text-black focus-visible:ring-2 focus-visible:ring-accent'
}
>
<svg aria-hidden="true" viewBox="0 0 12 12" className="h-3 w-3 fill-current">
<path d="M2.5 1.5v9l8-4.5z" />
</svg>
Play
</button>
)
}
function PlaceholderCard({ name, size }) {
return (
<div
@@ -525,10 +464,6 @@ export default function PianoLickCard({ lick, rootPc, chordLabel, size = 'full'
const realized = realizePianoLick(lick, rootPc)
const name = typeof lick?.name === 'string' && lick.name.trim() ? lick.name : 'Untitled lick'
// Unmount (loop/style/instrument change) silences any running sequence
// module-level state, so this is idempotent across sibling cards.
useEffect(() => () => stopLick(), [])
if (!realized) {
return <PlaceholderCard name={lick ? name : null} size={size} />
}
@@ -573,7 +508,7 @@ export default function PianoLickCard({ lick, rootPc, chordLabel, size = 'full'
)}
{/* The pitch timeline */}
<div className="max-w-full overflow-x-auto">
<div className="max-w-full overflow-x-auto dark-scroll">
<TimelineSvg
layout={layout}
ariaLabel={`Melody for ${name} over ${chordName}: ${pitchNames}`}
@@ -582,7 +517,7 @@ export default function PianoLickCard({ lick, rootPc, chordLabel, size = 'full'
{/* Keyboard view (full): the actual keys, numbered in strike order */}
{full && (
<div className="max-w-full overflow-x-auto">
<div className="max-w-full overflow-x-auto dark-scroll">
<LickKeyboard
realized={realized}
ariaLabel={`Keys for ${name} over ${chordName}, numbered in playing order`}
@@ -602,11 +537,6 @@ export default function PianoLickCard({ lick, rootPc, chordLabel, size = 'full'
{full && source && (
<span className="text-[10px] text-gray-500 italic leading-snug">{source}</span>
)}
<PlayButton
ariaLabel={`Play ${name} over ${chordName}`}
onClick={() => playLick(realized)}
/>
</div>
)
}
+63 -25
View File
@@ -67,44 +67,82 @@ const CATEGORY_TAG = {
secondary_dominant: 'V7',
}
// Compute the substitution set for a chord NAME at loop index `pos` (1 when the
// chord is not a loop station). The next station's root pc gates Rule D (secondary
// dominant of the next chord). Returns { name, pos, subs } or null when the name
// won't parse. Reuses parseChordName / chordRootPC / suggestSubstitutions never
// re-derives theory.
function subsForChord(name, pos, loopArr, keyInfo) {
const parsed = parseChordName(name)
if (!parsed) return null
let nextRootPc
if (loopArr && pos >= 0) {
const pc = chordRootPC(loopArr[(pos + 1) % loopArr.length])
if (pc >= 0) nextRootPc = pc
}
return { name, pos, subs: suggestSubstitutions(parsed, keyInfo, { nextRootPc }) }
}
// Choose the SUBJECT chord the card speaks about (pure no hooks):
// (a) the live currentChord, if it parses and yields 1 sub (the playing case
// whether or not it's a loop station; unchanged behaviour);
// (b) else the FIRST loop chord that yields 1 sub so a rolled/detected loop
// in a locked key shows the card immediately, with no live input;
// (c) else null no loop and no valid live chord honest empty.
function pickSubject(loopArr, keyInfo, currentChord) {
const pos = loopArr ? loopArr.indexOf(currentChord) : -1
const live = subsForChord(currentChord, pos, loopArr, keyInfo)
if (live && live.subs.length) return live
if (loopArr) {
for (let i = 0; i < loopArr.length; i++) {
const cand = subsForChord(loopArr[i], i, loopArr, keyInfo)
if (cand && cand.subs.length) return cand
}
}
return null
}
export default function TryThis({ loop, keyInfo, currentChord, onChordClick }) {
const [cycle, setCycle] = useState(0)
const lastPosRef = useRef(null)
const lastChordRef = useRef(null)
const lastNameRef = useRef(null)
const target = parseChordName(currentChord)
const loopArr = Array.isArray(loop) && loop.length ? loop : null
const position = loopArr && target ? loopArr.indexOf(currentChord) : -1
const subject = pickSubject(loopArr, keyInfo, currentChord)
// Next station's root pc gates Rule D (secondary dominant of the next chord).
let nextRootPc
if (loopArr && position >= 0) {
const pc = chordRootPC(loopArr[(position + 1) % loopArr.length])
if (pc >= 0) nextRootPc = pc
}
const subs = target ? suggestSubstitutions(target, keyInfo, { nextRootPc }) : []
// Position/name that DRIVE rotation. When following a live loop chord this is its
// playhead index (wrap advance). When following a live chord not in the loop it
// is 1 (advance on chord change). In the (b) fallback the subject is a fixed loop
// station with no playhead pos is stable and name is stable, so the effect fires
// once and the shown idea holds steady (visible, no flicker).
const rotationPos = subject ? subject.pos : -1
const subjectName = subject ? subject.name : null
// Rotation: advance one idea each loop pass (playhead position wraps toward 0).
// With no loop (position 1), advance on each genuine currentChord change so
// the nudge still refreshes as the player moves. Detection watches the previous
// position/chord across renders via refs (no side effects during render).
// With no loop position (1), advance on each genuine subject-chord change so the
// nudge refreshes as the player moves. Refs carry the previous pos/name across
// renders (no side effects during render). Runs unconditionally (before returns).
useEffect(() => {
const prevPos = lastPosRef.current
const prevChord = lastChordRef.current
lastPosRef.current = position
lastChordRef.current = currentChord
if (position >= 0) {
setCycle(c => advanceOnWrap(c, prevPos, position))
} else if (prevChord != null && prevChord !== currentChord) {
const prevName = lastNameRef.current
lastPosRef.current = rotationPos
lastNameRef.current = subjectName
if (subjectName == null) return
if (rotationPos >= 0) {
setCycle(c => advanceOnWrap(c, prevPos, rotationPos))
} else if (prevName != null && prevName !== subjectName) {
setCycle(c => c + 1)
}
}, [position, currentChord])
}, [rotationPos, subjectName])
// 0 subs no card (no key, atonal, or a chord with no honest sub).
const picked = pickSub(subs, cycle)
// No subject (no loop + no valid live chord) or 0 subs no card.
if (!subject) return null
const picked = pickSub(subject.subs, cycle)
if (!picked) return null
const subs = subject.subs
const subjectChord = subject.name
const { sub, idx, total } = picked
const showIndicator = total > 1
const isCircle = CIRCLE_CATEGORIES.has(sub.category)
@@ -113,11 +151,11 @@ export default function TryThis({ loop, keyInfo, currentChord, onChordClick }) {
return (
<section
className="rounded-2xl border border-border bg-panel p-3"
aria-label={`Try this instead of ${currentChord}`}
aria-label={`Try this instead of ${subjectChord}`}
>
<h4 className="mb-2 flex items-baseline justify-between gap-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
<span>
Try this instead of {currentChord}
Try this instead of {subjectChord}
{keyInfo?.root ? ` · in ${keyInfo.root} ${keyInfo.mode ?? 'major'}` : ''}
</span>
{showIndicator && (
+131 -98
View File
@@ -9,20 +9,19 @@
// Guitar section every placeable `GUITAR_SHAPES[quality]` entry from
// src/lib/voicings.js (open shapes only in their native key, movable shapes
// only when the whole grip fits under fret 15), each cell = shape label +
// <ChordDiagram size="thumb"/> + its own .
// <ChordDiagram size="thumb"/>.
// Piano section all four src/lib/piano.js `pianoVoicing` styles
// (root / shell / rootlessA / rootlessB), each cell = the voicing's honest
// label (e.g. "rootless A (3-5-7-9)") + <MiniPiano voicing size="thumb"/> +
// its own .
// label (e.g. "rootless A (3-5-7-9)") + <MiniPiano voicing/>.
//
// Playback: src/lib/chordAudio.js (L-20). ONE live {stop} handle for the whole
// gallery any stops the previous sound before starting (chord change and
// unmount also stop it), so previews never layer. First click is the user
// gesture that lazily creates the AudioContext.
// No playback (L-70 / dashboard-polish.md §3): the per-cell preview was removed
// from EVERY mount the user settled "leave them off, better not." The component
// is now purely visual; src/lib/chordAudio.js is no longer imported here.
//
// Mount points (wired by L-21/L-22, NOT here): Knowledge Center Voicings
// section, ChordDetailModal Guitar/Piano tabs (show="guitar"/"piano", L-25),
// and the Jam Guide station-enlarge view. This component stays pure & prop-driven.
// and the Jam Guide station rail / heard-live view (dense). This component stays
// pure & prop-driven.
//
// Layout: each instrument section is a flex-wrap gallery of fixed-content-width
// cells, so it reflows to fewer columns (down to one cell per row) inside a
@@ -52,14 +51,26 @@
// two-octave + one-octave piano pair fit (279.6 + 6 + 156.4 = 442
// 451px row interior even with a classic Windows scrollbar in
// the rail's scroller; it was 452 vs 456, a ~4px squeak, before).
// Every pre-existing mount renders identically with no prop.
// L-70 (dashboard-polish.md §2) additionally renders the piano
// styles as a 2×2 grid of `size="mini"` keyboards under dense, and
// applies the `.dark-scroll` utility to each cell's scroller. Every
// pre-existing mount renders identically with no prop.
// recommended a shape object (guitar) or voicing object (piano) marking the
// KB play's own voicing (dashboard rail only). Guitar: matched into
// the list by `label`, floated to cell #1, badged "play" + accent
// border, and the list reordered recommended-first (§1.2). Piano:
// matched by `voicing.style`, that 2×2 cell gets the accent border
// (§2.1). Default null no highlight, order untouched.
// max cap the guitar list to this many cells (dashboard rail passes 4).
// Undefined no cap (all placeable shapes). Off-rail mounts pass
// neither `recommended` nor `max`, so the guitar list keeps its raw
// `matchingShapes` declared order byte-identical to today.
import { useEffect, useMemo, useRef } from 'react'
import { useMemo } from 'react'
import ChordDiagram from './ChordDiagram'
import MiniPiano from './MiniPiano'
import { GUITAR_SHAPES } from '../lib/voicings'
import { pianoVoicing } from '../lib/piano'
import { playVoicing, guitarShapeToNotes } from '../lib/chordAudio'
import { NOTES, CHORD_TYPES } from '../lib/theory'
// Standard-tuning open-string pitch classes, low-E first (mirrors ChordDiagram).
@@ -90,6 +101,57 @@ function matchingShapes(quality, rootPc) {
})
}
// Lowest base fret of a movable shape under ChordDiagram's placement convention
// (root-at-open-string fret-12 barre). Open shapes sit at the nut (0).
function baseFretOf(shape, rootPc) {
if (Array.isArray(shape.frets)) return 0
const idx = 6 - shape.rootStr
let bf = mod12(rootPc - (OPEN_PCS[idx] ?? 4))
if (bf === 0) bf = 12
return bf
}
// Count of muted strings in a shape's grip (fewer = a fuller voicing wins ties).
function mutedCount(shape) {
const arr = Array.isArray(shape.frets) ? shape.frets : shape.offsets
return Array.isArray(arr) ? arr.filter((v) => v === 'x').length : 0
}
// The rail's max, recommended-first ordering (dashboard-polish.md §1.2). Only
// invoked when `recommended` and/or `max` are supplied (the dashboard rail)
// non-dense mounts skip it entirely and keep `matchingShapes` declared order,
// so VoicingsSection/ChordDetailModal stay byte-identical.
// 1. recommended first (matched into the placeable list by label; prepended
// even if it was filtered out as unplaceable);
// 2. open-position forms (Array.isArray(frets));
// 3. movable/barre by lowest base fret ascending;
// 4. tiebreak: fewer muted strings, then declared order in GUITAR_SHAPES.
// Then slice to `max` (recommended deduped so it never repeats). <max placeable
// shapes just show what exist the rule caps, never pads.
function orderGuitarShapes(shapes, rootPc, recommended, max) {
const declaredIndex = new Map(shapes.map((s, i) => [s, i]))
let rec = null
if (recommended?.label) {
rec = shapes.find((s) => s.label === recommended.label) ?? recommended
}
const rest = shapes.filter((s) => s.label !== rec?.label)
rest.sort((a, b) => {
const aOpen = Array.isArray(a.frets) ? 0 : 1
const bOpen = Array.isArray(b.frets) ? 0 : 1
if (aOpen !== bOpen) return aOpen - bOpen
if (aOpen === 1) {
const bf = baseFretOf(a, rootPc) - baseFretOf(b, rootPc)
if (bf !== 0) return bf
}
const mc = mutedCount(a) - mutedCount(b)
if (mc !== 0) return mc
return (declaredIndex.get(a) ?? 0) - (declaredIndex.get(b) ?? 0)
})
const ordered = rec ? [rec, ...rest] : rest
const list = Number.isFinite(max) ? ordered.slice(0, max) : ordered
return { list, recLabel: rec?.label ?? null }
}
// "C", "Cm7", "Cmaj7" display name from the app's canonical chord model.
function chordName(rootPc, quality) {
const q = CHORD_TYPES[quality] ? quality : 'maj'
@@ -98,28 +160,6 @@ function chordName(rootPc, quality) {
// Small presentational atoms
// Per-cell . Small accent text sits on bg-surface (#0f0f0f), where accent
// #a855f7 measures 4.8:1 AA for small text (surface-background rule).
function PlayButton({ ariaLabel, onClick }) {
return (
<button
type="button"
aria-label={ariaLabel}
onClick={onClick}
className={
'inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full border border-accent ' +
'bg-surface px-2.5 text-xs font-semibold text-accent outline-none transition ' +
'hover:bg-accent hover:text-black focus-visible:ring-2 focus-visible:ring-accent'
}
>
<svg aria-hidden="true" viewBox="0 0 12 12" className="h-3 w-3 fill-current">
<path d="M2.5 1.5v9l8-4.5z" />
</svg>
Play
</button>
)
}
function SectionHeading({ children }) {
return (
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-gray-400">
@@ -128,47 +168,76 @@ function SectionHeading({ children }) {
)
}
// One gallery cell: label on top, diagram thumb, its own underneath.
// One gallery cell: label on top, diagram/keyboard thumb underneath.
// bg-surface inside the bg-panel section gives the cells a quiet inlay border;
// label is gray-300 on surface (AA comfortable at 11px semibold).
// `dense` (D-51): p-1.5 instead of p-2 4px off each cell's box width, part of
// the right-column margin-hardening. Non-dense output is byte-identical.
function GalleryCell({ label, playLabel, onPlay, dense = false, children }) {
// `recommended` (L-70, dashboard-polish.md §1.2): the KB play's shape/style
// accent border + a "play" badge (finger-this, not audio; is gone), the
// prominence the old GlanceRail own-cell carried. Only ever set under the
// dashboard rail, so non-recommended cells stay byte-identical.
function GalleryCell({ label, recommended = false, dense = false, children }) {
return (
<figure className={`flex min-w-0 flex-col items-center gap-1.5 rounded-md border border-border bg-surface ${dense ? 'p-1.5' : 'p-2'}`}>
<figcaption
className="max-w-full break-words text-center text-[11px] font-medium leading-tight text-gray-300"
title={label}
>
{label}
</figcaption>
<figure className={`flex min-w-0 flex-col items-center gap-1.5 rounded-md border ${recommended ? 'border-accent' : 'border-border'} bg-surface ${dense ? 'p-1.5' : 'p-2'}`}>
{recommended ? (
<figcaption
className="flex max-w-full items-center gap-1.5 text-center text-[11px] font-medium leading-tight text-gray-300"
title={label}
>
<span className="rounded bg-accent px-1 py-0.5 text-[9px] font-bold uppercase tracking-wider text-black">
play
</span>
<span className="break-words">{label}</span>
</figcaption>
) : (
<figcaption
className="max-w-full break-words text-center text-[11px] font-medium leading-tight text-gray-300"
title={label}
>
{label}
</figcaption>
)}
{/* Scroll guard: MiniPiano's SVG has a fixed pixel width (up to ~266px
for a 2-octave thumb window); scroll inside the cell on very narrow
viewports rather than letting it break the wrap layout. */}
<div className="max-w-full overflow-x-auto">{children}</div>
<PlayButton ariaLabel={playLabel} onClick={onPlay} />
viewports rather than letting it break the wrap layout. `dark-scroll`
(L-70 §4) dresses this scroller on the dashboard rail only (dense)
the KC/modal keep the OS default, so non-dense stays byte-identical. */}
<div className={`max-w-full overflow-x-auto${dense ? ' dark-scroll' : ''}`}>{children}</div>
</figure>
)
}
// The gallery
export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'both', dense = false }) {
export default function VoicingBrowser({
rootPc = 0, quality = 'maj', show = 'both', dense = false, recommended = null, max,
}) {
const pc = mod12(Number.isFinite(rootPc) ? rootPc : 0)
const name = chordName(pc, quality)
const chordKey = `${pc}:${quality}`
// Section gating (D-23; 'bass' added by D-41 per D-40 §3). 'guitar' hides
// the piano section, 'piano' hides the guitar section, 'bass' hides BOTH
// (neither gallery is honest for a bassist the one-liner below renders
// instead, so the dock's VoicingsSection under the global BASS selector
// stops showing guitar+piano). Anything else (incl. the 'both' default)
// shows both. Hooks stay unconditional; the shared stop-handle discipline
// (stop on chord change / unmount) is untouched by hiding a section.
// shows both. Hooks stay unconditional.
const showGuitar = show !== 'piano' && show !== 'bass'
const showPiano = show !== 'guitar' && show !== 'bass'
const guitarShapes = useMemo(() => matchingShapes(quality, pc), [quality, pc])
const allGuitarShapes = useMemo(() => matchingShapes(quality, pc), [quality, pc])
// Guitar list: the dashboard rail (recommended and/or max supplied) reorders
// recommended-first + caps to `max` (§1.2); every other mount keeps the raw
// `matchingShapes` declared order untouched byte-identical to today.
const guitarView = useMemo(() => {
if (!recommended && !Number.isFinite(max)) {
return { list: allGuitarShapes, recLabel: null }
}
return orderGuitarShapes(allGuitarShapes, pc, recommended, max)
}, [allGuitarShapes, pc, recommended, max])
const guitarShapes = guitarView.list
const pianoOptions = useMemo(
() =>
PIANO_STYLES.map((style) => ({
@@ -177,38 +246,10 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
})),
[pc, quality],
)
// One live playback handle for the whole gallery: any new play (or chord
// change, or unmount) stops the previous sound first the L-20 {stop}
// contract, so previews never layer or leak.
const handleRef = useRef(null)
const stopCurrent = () => {
handleRef.current?.stop()
handleRef.current = null
}
// Chord change cleanup silences the old preview; same cleanup covers unmount.
useEffect(() => stopCurrent, [chordKey])
// Known advisory (L-20 gate): when a movable shape's root lands on an open
// string (base fret 0), ChordDiagram draws the fret-12 octave barre while
// guitarShapeToNotes places the grip at the open position the SAME chord,
// one octave lower than drawn. Deliberately left as-is on both sides.
function playGuitar(shape) {
stopCurrent()
handleRef.current = playVoicing(guitarShapeToNotes(shape, { rootPc: pc }), {
strumMs: 45, // a light strum reads "guitar"
durMs: 1800,
})
}
function playPiano(voicing) {
stopCurrent()
handleRef.current = playVoicing(voicing?.notes ?? [], {
strumMs: 15, // near-block chord reads "piano"
durMs: 1800,
})
}
// Piano "recommended" (dashboard rail): the station's authored voicing matches
// one of the four styles that 2×2 cell gets the accent border (§2.1). Guitar-
// shape `recommended` objects have no `.style`, so this stays null off-rail.
const recStyle = recommended && typeof recommended.style === 'string' ? recommended.style : null
return (
<div className="flex w-full min-w-0 flex-wrap gap-2">
@@ -241,8 +282,7 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
<GalleryCell
key={`${shape.label}-${i}`}
label={shape.label}
playLabel={`Play ${name}${shape.label} guitar voicing`}
onPlay={() => playGuitar(shape)}
recommended={guitarView.recLabel !== null && shape.label === guitarView.recLabel}
dense={dense}
>
<ChordDiagram shape={shape} rootPc={pc} size="thumb" />
@@ -265,10 +305,14 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
</div>
)}
{/* dense (dashboard rail, §2.1): the four styles render as a tidy 2×2
grid of compact `size="mini"` keyboards (two per column fit the
~451px row interior). Non-dense keeps today's flex-wrap gallery at
size="thumb" byte-identical. */}
<div
role="group"
aria-label={`${name} piano voicings — every style shown, each playable`}
className={`flex flex-wrap items-stretch ${dense ? 'gap-1.5' : 'gap-2'}`}
className={dense ? 'grid grid-cols-2 gap-1.5' : 'flex flex-wrap items-stretch gap-2'}
>
{/* pianoVoicing() output carries no rootPc, and without it VoicingPiano
falls back to the LOWEST voice for its "R" badge wrong for rootless
@@ -277,11 +321,10 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
<GalleryCell
key={style}
label={voicing.label}
playLabel={`Play ${name}${voicing.label} piano voicing`}
onPlay={() => playPiano(voicing)}
recommended={recStyle !== null && style === recStyle}
dense={dense}
>
<MiniPiano voicing={{ ...voicing, rootPc: pc }} size="thumb" />
<MiniPiano voicing={{ ...voicing, rootPc: pc }} size={dense ? 'mini' : 'thumb'} />
</GalleryCell>
))}
</div>
@@ -297,16 +340,6 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
</p>
)}
{/* Mic-feedback caveat, per the L-20 header + D-20 §3 (microcopy tier).
Skipped under `dense`, where the GlanceRail shows the SAME microcopy
once for the whole rail (D-31 §2.5) instead of per gallery and under
'bass', where there is no to caveat. */}
{!dense && (showGuitar || showPiano) && (
<p className="w-full basis-full text-[11px] text-gray-500">
Previews play through your speakers while the mic is live, detection may
hear them.
</p>
)}
</div>
)
}
+16
View File
@@ -6,3 +6,19 @@ body {
color: #f5f5f5;
font-family: system-ui, -apple-system, sans-serif;
}
/* Thin, dark, overlay-feel scrollbars for the jam dashboard's scrollers
(dashboard-polish.md §4). Scoped (a class), NOT global the KnowledgeDock /
Debug / Tuner / Settings 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; }