Compare commits
7 Commits
245d9f6fb2
...
89b3e323ea
| Author | SHA1 | Date | |
|---|---|---|---|
| 89b3e323ea | |||
| 6885fc3ce1 | |||
| 19b1a4ff91 | |||
| aae69e8a7b | |||
| c16e9f1827 | |||
| 9e21df127c | |||
| 3fdc72ebd4 |
@@ -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 Am–C–F 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; Am–C–F 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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 2–3 (~266–390px)
|
||||
// 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 (8→10 vu) and the bass-ring stroke (2→2.5) so both stay legible at
|
||||
// the smaller render. `thumb`/`full` are byte-untouched — `mini` is additive.
|
||||
// `size="full"` keeps the wide C-anchored 2–3-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>
|
||||
)}
|
||||
|
||||
@@ -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={0–11} 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
@@ -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 && (
|
||||
|
||||
@@ -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'}`}>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user