feat(app): one-screen jam dashboard + jam view (task L-50, commit 2/2)

Two-column grid: LEFT compact instrument view (natural-scale caps
674/562/674, non-compact renders byte-identical) + licks strip +
related-progressions slot (L-51 fills); RIGHT the 500px suggested-
voicings rail, height-bounded with internal scroll (not sticky).
ProgressionSuggestions unmounted (file kept). Jam view: one button,
two layers — CSS h-screen lock with everything below the grid
unmounted, plus best-effort Promise-caught requestFullscreen; Escape
and fullscreenchange stay in sync. Audio contract grep: zero hits.
Critic PASS (SSR 73/73; gate applied a one-class legend restoration
for non-compact byte-identity, re-verified green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
vadimwit
2026-07-11 16:28:25 +01:00
parent 800fd43778
commit e58a7a624c
5 changed files with 330 additions and 165 deletions
+70 -21
View File
@@ -1,7 +1,6 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import AudioCapture from './components/AudioCapture'
import ProgressionBanner from './components/ProgressionBanner'
import ProgressionSuggestions from './components/ProgressionSuggestions'
import Fretboard from './components/Fretboard'
import BassFretboard from './components/BassFretboard'
import Tuner from './components/Tuner'
@@ -60,6 +59,36 @@ export default function App() {
const [showDrumView, setShowDrumView] = useState(false)
const [monoColor, setMonoColor] = useState(() => loadStored('wtf_monoColor', false))
// ── Jam view (task L-50, one-screen.md §1.1) — pure UI/layout state ──────────
// Layer 1: CSS lock — at xl the page root becomes h-screen overflow-hidden and
// everything below the dashboard is unmounted. Layer 2: best-effort browser
// fullscreen (Promise-caught — a refusal leaves layer 1 fully working). The
// toggle never starts/stops listening and never touches audio state.
const [jamView, setJamView] = useState(false)
function enterJamView() {
setJamView(true)
document.documentElement.requestFullscreen?.()?.catch(() => {})
}
function exitJamView() {
setJamView(false)
if (document.fullscreenElement) document.exitFullscreen?.()?.catch(() => {})
}
// Escape and the native fullscreen exit (any means) both restore normal flow —
// one state, never half-exited. Listeners active only while jamView.
useEffect(() => {
if (!jamView) return
const onKey = (e) => { if (e.key === 'Escape') exitJamView() }
const onFsChange = () => { if (!document.fullscreenElement) setJamView(false) }
window.addEventListener('keydown', onKey)
document.addEventListener('fullscreenchange', onFsChange)
return () => {
window.removeEventListener('keydown', onKey)
document.removeEventListener('fullscreenchange', onFsChange)
}
}, [jamView]) // eslint-disable-line react-hooks/exhaustive-deps
// ── Jam Guide → Fretboard cross-link (D-03) ──────────────────────────────────
// When a Roadmap station is tapped, JamGuide reports its {rootPc, quality}
// here and the main Fretboard highlights that chord's guide tones (3rd/7th).
@@ -436,7 +465,7 @@ export default function App() {
}
return (
<div className="min-h-screen bg-surface text-white p-3">
<div className={`min-h-screen bg-surface text-white p-3${jamView ? ' xl:h-screen xl:overflow-hidden xl:flex xl:flex-col' : ''}`}>
{/* ── Header ── */}
<header className="mb-2 flex items-center justify-between">
@@ -576,6 +605,21 @@ export default function App() {
</button>
</div>
)}
{/* ── Jam view toggle (L-50, one-screen.md §1.1) — layout-only ── */}
<button
type="button"
onClick={jamView ? exitJamView : enterJamView}
aria-pressed={jamView}
title={jamView ? 'Exit jam view' : 'Jam view — the one-screen dashboard, fullscreen'}
className={`ml-auto min-h-[32px] px-3 py-1 rounded-lg text-sm border transition-all outline-none focus-visible:ring-2 focus-visible:ring-accent ${
jamView
? 'bg-accent/20 border-accent text-accent font-semibold'
: 'border-border text-gray-400 hover:border-gray-500 hover:text-gray-200'
}`}
>
{jamView ? '✕ Exit' : '⛶ Jam view'}
</button>
</div>
<AudioCapture
@@ -613,25 +657,13 @@ export default function App() {
onChordClick={setSelectedChord}
/>
{/* ── Instrument + progressions row ── */}
<div className="flex gap-3 mb-3 items-stretch">
<div className="w-full lg:w-[70%] min-w-0">
{instrument === 'guitar' && <Fretboard keyInfo={effectiveKey} currentChord={currentChord} pentatonicOnly={false} monoColor={monoColor} jamFocusChord={jamFocusChord} />}
{instrument === 'bass' && <BassFretboard keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} />}
{instrument === 'piano' && <Piano keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} />}
</div>
<div className="hidden lg:block w-[30%] min-w-0 relative">
<div className="absolute inset-0">
<ProgressionSuggestions keyInfo={effectiveKey} currentChord={currentChord} />
</div>
</div>
</div>
{/* ── Jam Guide band — always open, right below the instrument row (L-40,
D-40 §1: CurrentJamPanel's old slot; the loop shows ONCE, in the
banner above). Follows the one global instrument selector. ── */}
{/* ── The jam dashboard grid (task L-50, one-screen.md §1/§6): JamGuide
owns the two-column layout — LEFT: compact instrument view (chosen
here, passed as the mainView slot) + licks strip + the related-
progressions slot (null until L-51); RIGHT: the suggested-voicings
rail. ProgressionSuggestions is unmounted (file kept) — its job
split into the rail + RelatedProgressions per the user directive.
Follows the one global instrument selector. ── */}
<JamGuide
detectedProgression={detectedProgression}
keyInfo={effectiveKey}
@@ -639,8 +671,23 @@ export default function App() {
currentChord={currentChord}
onFocusChord={setJamFocusChord}
instrument={instrument}
mainView={
<>
{instrument === 'guitar' && <Fretboard keyInfo={effectiveKey} currentChord={currentChord} pentatonicOnly={false} monoColor={monoColor} jamFocusChord={jamFocusChord} compact />}
{instrument === 'bass' && <BassFretboard keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} compact />}
{instrument === 'piano' && <Piano keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} compact />}
</>
}
relatedSlot={null}
fill={jamView}
/>
{/* ── Below the dashboard — the learning / behind-the-scenes area (page
scroll in normal mode; UNMOUNTED in jam view, one-screen.md §1.1/§3:
conditional mount, not `hidden`, so collapsed chrome can't leak
height). ── */}
{!jamView && (
<>
{/* ── Loop station ── */}
<LoopStation
slots={slots}
@@ -719,6 +766,8 @@ export default function App() {
onChordClick={setSelectedChord}
instrument={instrument}
/>
</>
)}
</div>
)
}
+32 -12
View File
@@ -33,7 +33,10 @@ function noteColor(isChordTone, isPenta, isScale, mono = false) {
return null
}
export default function BassFretboard({ keyInfo, currentChord, monoColor = false }) {
// `compact` (task L-50, one-screen.md §2): trimmed card chrome (p-3, legend
// merged onto the heading line) + a natural-width cap on the SVG (max-width =
// its viewBox width, so it never renders above scale 1.0).
export default function BassFretboard({ keyInfo, currentChord, monoColor = false, compact = false }) {
const { root, mode } = keyInfo ?? {}
if (!root) return null
@@ -44,19 +47,40 @@ export default function BassFretboard({ keyInfo, currentChord, monoColor = false
? new Set(getChordTones(currentChord).map(n => NOTES.indexOf(n)))
: new Set()
const heading = (
<p className={`text-sm text-gray-500 uppercase tracking-widest ${compact ? '' : 'mb-4'}`}>
Bass {root} {mode}
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
</p>
)
const legend = (
// Critic mechanical fix (L-50 gate): non-compact keeps HEAD's exact class
// string so the non-compact render stays byte-identical to the committed one.
<div className={compact ? 'flex items-center text-xs text-gray-500 flex-wrap gap-3' : 'mt-3 flex gap-5 text-xs text-gray-500'}>
<span><span className="text-accent"></span> Chord tone</span>
<span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}></span><span> Pentatonic</span>
<span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}></span><span> Scale</span>
</div>
)
return (
<div className="bg-panel border border-border rounded-2xl p-6">
<p className="text-sm text-gray-500 uppercase tracking-widest mb-4">
Bass {root} {mode}
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
</p>
<div className={`bg-panel border border-border rounded-2xl ${compact ? 'p-3' : 'p-6'}`}>
{compact ? (
<div className="mb-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-1">
{heading}
{legend}
</div>
) : (
heading
)}
<div>
<svg
viewBox={`0 0 ${BOARD_W} ${BOARD_H}`}
width="100%"
height="auto"
style={{ display: 'block' }}
style={{ display: 'block', ...(compact ? { maxWidth: BOARD_W } : null) }}
>
{/* Fretboard background */}
<rect x={NUT_X} y={PAD_T - 6} width={BOARD_W - NUT_X - 4} height={3 * STRING_H + 12}
@@ -139,11 +163,7 @@ export default function BassFretboard({ keyInfo, currentChord, monoColor = false
</svg>
</div>
<div className="mt-3 flex gap-5 text-xs text-gray-500">
<span><span className="text-accent"></span> Chord tone</span>
<span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}></span><span> Pentatonic</span>
<span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}></span><span> Scale</span>
</div>
{!compact && legend}
</div>
)
}
+42 -21
View File
@@ -37,7 +37,11 @@ function noteColor(isChordTone, isPenta, isScale, mono = false) {
return null
}
export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = false, monoColor = false, jamFocusChord = null }) {
// `compact` (task L-50, one-screen.md §2): trimmed card chrome (p-3, legend
// merged onto the heading line) + a natural-width cap on the SVG (max-width =
// its viewBox width, so it never renders above scale 1.0). No fret reduction,
// no transform scaling — the notes stay at their designed size.
export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = false, monoColor = false, jamFocusChord = null, compact = false }) {
const { root, mode } = keyInfo ?? {}
if (!root) return null
@@ -78,20 +82,49 @@ export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = fals
const focusLabel = pc =>
pc === focusThird ? '3' : pc === focusSeventh ? focusSeventhLabel : null
const heading = (
<p className={`text-sm text-gray-500 uppercase tracking-widest ${compact ? '' : 'mb-4'}`}>
Fretboard {root} {mode}
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
{hasFocus && <span className="text-accent ml-2"> guide tones</span>}
</p>
)
const legend = (
// Critic mechanical fix (L-50 gate): non-compact keeps HEAD's exact class
// string so the non-compact render stays byte-identical to the committed one.
<div className={compact ? 'flex flex-wrap items-center text-xs text-gray-500 gap-3' : 'mt-3 flex flex-wrap gap-5 text-xs text-gray-500'}>
<span><span className="text-accent"></span> Chord tone</span>
<span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}></span><span> Pentatonic</span>
<span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}></span><span> Scale</span>
{hasFocus && (
<span className="flex items-center gap-1">
<span
className="inline-block w-3 h-3 rounded-full border-2 border-accent"
/>
Guide tones (3 / {focusSeventhLabel})
</span>
)}
</div>
)
return (
<div className="bg-panel border border-border rounded-2xl p-6">
<p className="text-sm text-gray-500 uppercase tracking-widest mb-4">
Fretboard {root} {mode}
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
{hasFocus && <span className="text-accent ml-2"> guide tones</span>}
</p>
<div className={`bg-panel border border-border rounded-2xl ${compact ? 'p-3' : 'p-6'}`}>
{compact ? (
<div className="mb-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-1">
{heading}
{legend}
</div>
) : (
heading
)}
<div>
<svg
viewBox={`0 0 ${BOARD_W} ${BOARD_H}`}
width="100%"
height="auto"
style={{ display: 'block' }}
style={{ display: 'block', ...(compact ? { maxWidth: BOARD_W } : null) }}
>
{/* Fretboard background */}
<rect x={NUT_X} y={PAD_T - 6} width={BOARD_W - NUT_X - 4} height={5 * STRING_H + 12}
@@ -202,19 +235,7 @@ export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = fals
</svg>
</div>
<div className="mt-3 flex flex-wrap gap-5 text-xs text-gray-500">
<span><span className="text-accent"></span> Chord tone</span>
<span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}></span><span> Pentatonic</span>
<span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}></span><span> Scale</span>
{hasFocus && (
<span className="flex items-center gap-1">
<span
className="inline-block w-3 h-3 rounded-full border-2 border-accent"
/>
Guide tones (3 / {focusSeventhLabel})
</span>
)}
</div>
{!compact && legend}
</div>
)
}
+154 -99
View File
@@ -10,17 +10,24 @@ import { ExploreSection, VoicingsSection, LevelChips } from './ExplorePanel'
import { pianoVoicingChain } from '../lib/piano'
import { parseChord } from '../lib/voicings'
// ─── JamGuide.jsx — the jam BAND + the Knowledge Center DOCK ─────────────────
// ─── JamGuide.jsx — the jam-grid OWNER + the Knowledge Center DOCK ───────────
//
// Task L-40 (per docs/design/integrated-glance.md §5–§6.1) split the old
// four-section bottom dock in two:
// Task L-50 (per docs/design/one-screen.md §6) promoted the default export from
// "band" to the two-column jam dashboard grid:
//
// default export `JamGuide` — the always-open, zero-chrome jam band, mounted
// by App directly below the instrument row (CurrentJamPanel's old slot).
// Body: GlanceRail voicings + LicksStrip for the matched loop; heard-live
// single gallery when no loop is matched; a slim one-line hint when nothing
// is heard. The loop itself renders ONCE, in ProgressionBanner (D-40 §2) —
// RoadmapTrack is unmounted (file retired in place, deletion backlogged).
// default export `JamGuide` — renders the xl: two-column flex region.
// LEFT (flex-1): the `mainView` slot (App keeps choosing Fretboard /
// BassFretboard / Piano — JamGuide never imports them), the LicksStrip,
// and the `relatedSlot` (RelatedProgressions, mounted by App — null until
// L-51). RIGHT (500px): the suggested-voicings rail — GlanceRail /
// BassGuideRows / the heard-live fallback — inside the design's ONE
// justified internal scroller (height-bounded, NOT sticky, §1). Below xl
// the columns stack in jam-following order via display:contents + order
// classes: mainView → rail → licks → related (§7; rail unbounds).
// The `fill` prop (jam view, §1.1) swaps the rail's viewport-calc bound
// for h-full and makes the left column a flex stack whose related slot
// absorbs the remainder. The loop itself renders ONCE, in
// ProgressionBanner (D-40 §2).
// named export `KnowledgeDock` — the bottom collapsible browse/study area:
// Explore / Voicings / Licks & Techniques + the shared level filter (the
// old dock minus its jam section, which IS the band now).
@@ -149,7 +156,7 @@ function lickFitsContext(lick, context) {
return wanted.length > 0 && tokens.some(t => wanted.includes(t))
}
// ─── JamGuide — the always-open jam band (default export) ─────────────────────
// ─── JamGuide — the jam dashboard grid (default export) ───────────────────────
//
// Props:
// detectedProgression : string[] | null — the live detected loop (chord names)
@@ -158,12 +165,16 @@ function lickFitsContext(lick, context) {
// currentChord : string | undefined — most recent committed chord
// onFocusChord : fn({rootPc,quality}|null) — Fretboard guide-tone link (D-03)
// instrument : 'guitar' | 'piano' | 'bass' — App's global selector
// mainView : JSX slot — the compact instrument view (App-chosen; L-50)
// relatedSlot : JSX slot — RelatedProgressions (App-mounted; null until L-51)
// fill : boolean — jam view (one-screen.md §1.1): the grid fills
// App's h-screen column; rail bound becomes h-full
// The band shows ALL levels — a glance surface filters nothing (D-40 §5); the
// level filter lives in the KnowledgeDock only.
const ALL_LEVELS = { foundation: true, intermediate: true }
export default function JamGuide({ detectedProgression, keyInfo, chordHistory = [], currentChord, onFocusChord, instrument = 'guitar' }) {
export default function JamGuide({ detectedProgression, keyInfo, chordHistory = [], currentChord, onFocusChord, instrument = 'guitar', mainView = null, relatedSlot = null, fill = false }) {
// Style labels straight from the KB registry, via each style's meta.
const styles = useMemo(
() => Object.entries(kb).map(([id, style]) => ({ id, label: style?.meta?.label ?? id })),
@@ -343,96 +354,140 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
// re-sorting with the jam (D-31 §2.4).
const contextStation = stationVoicings[canonicalPos >= 0 ? canonicalPos : 0] ?? null
return (
<section className="mb-3" aria-label="Jam Guide — follows the loop">
{/* ── Micro-header — a line, not a button (D-40 §1: zero chrome) ── */}
<h3 className="mb-1.5 px-1 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
Jam Guide {headerLabel}
{match.matched && keyInfo?.root ? ` · in ${keyInfo.root} ${keyInfo.mode}` : ''}
</h3>
{match.matched ? (
instrument === 'bass' ? (
/* Bass rows (D-40 §3): authored pattern cards when the matched style
ships a bass pack (L-42), computed roots/fifths/approaches as the
honest fallback otherwise. The licks strip hides either way
(guitar tab licks are noise to a bassist mid-jam). */
<BassGuideRows
stations={stationVoicings}
activeIndex={canonicalPos}
keyMode={keyInfo?.mode}
plays={bassPlays}
/>
) : (
<div className="flex flex-col gap-3">
{/* The voicing rail — ALL stations expanded as vertical rows; the
playhead only highlights (D-41, D-40 §4). */}
<GlanceRail
stations={stationVoicings}
activeIndex={canonicalPos}
focusedIndex={focusedStation}
onFocus={setFocusedStation}
instrument={instrument}
keyRoot={keyRoot}
keyMode={keyInfo?.mode}
/>
<LicksStrip
styleId={activeStyle}
levels={ALL_LEVELS}
instrument={instrument}
context={contextStation}
/>
</div>
)
) : liveChord ? (
/* No loop matched, but chords are committing (D-31 §2.3): a single
"heard live" gallery, re-aimed on every chord commit. Auto-follow
only — nothing plays by itself. Bass: D-40 §3's prose forbids guitar/
piano galleries under BASS, so the live chord gets the same computed
root/fifth line (no next chord → no approach) instead. */
instrument === 'bass' ? (
<BassGuideRows
stations={[{ rootPc: liveChord.rootPc, quality: liveChord.type, label: currentChord, rn: '' }]}
activeIndex={0}
keyMode={keyInfo?.mode}
live
/>
) : (
<div className="flex flex-col gap-3">
<section
className="rounded-2xl border border-border bg-panel p-3"
aria-label={`Heard live — every ${instrument} voicing of ${currentChord}`}
>
<h4 className="mb-1 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
Heard live · {currentChord} every voicing
</h4>
<p className="mb-2 text-[11px] text-gray-500">
{detectedProgression?.length
? `Heard ${detectedProgression.join(' → ')} — no ${activeStyle} pattern matched yet; following the chord as it commits.`
: 'No repeating loop yet — following the chord as it commits.'}
</p>
<VoicingBrowser rootPc={liveChord.rootPc} quality={liveChord.type} show={instrument} />
</section>
{/* No station rn without a loop — context sort falls back to the
live chord's quality key (e.g. a "dom7" lick fits a live G7). */}
<LicksStrip
styleId={activeStyle}
levels={ALL_LEVELS}
instrument={instrument}
context={{ rn: '', quality: liveChord.type, label: currentChord }}
/>
</div>
)
) : (
/* Nothing heard yet — one slim line (~40px, D-40 §1): the idle band
must not waste main-module space. */
<p className="rounded-xl border border-dashed border-border px-3 py-2 text-xs text-gray-500">
// ── The rail (right column at xl / second block stacked): the suggested-
// voicings surface — GlanceRail, BassGuideRows, the heard-live gallery, or
// the honest idle line (one-screen.md §3, §4). ──
const railContent = match.matched ? (
instrument === 'bass' ? (
/* Bass rows (D-40 §3): authored pattern cards when the matched style
ships a bass pack (L-42), computed roots/fifths/approaches as the
honest fallback otherwise. The licks strip hides either way
(guitar tab licks are noise to a bassist mid-jam). */
<BassGuideRows
stations={stationVoicings}
activeIndex={canonicalPos}
keyMode={keyInfo?.mode}
plays={bassPlays}
/>
) : (
/* The voicing rail — ALL stations expanded as vertical rows; the
playhead only highlights (D-41, D-40 §4). */
<GlanceRail
stations={stationVoicings}
activeIndex={canonicalPos}
focusedIndex={focusedStation}
onFocus={setFocusedStation}
instrument={instrument}
keyRoot={keyRoot}
keyMode={keyInfo?.mode}
/>
)
) : liveChord ? (
/* No loop matched, but chords are committing (D-31 §2.3): a single
"heard live" gallery, re-aimed on every chord commit. Auto-follow
only — nothing plays by itself. Bass: D-40 §3's prose forbids guitar/
piano galleries under BASS, so the live chord gets the same computed
root/fifth line (no next chord → no approach) instead. */
instrument === 'bass' ? (
<BassGuideRows
stations={[{ rootPc: liveChord.rootPc, quality: liveChord.type, label: currentChord, rn: '' }]}
activeIndex={0}
keyMode={keyInfo?.mode}
live
/>
) : (
<section
className="rounded-2xl border border-border bg-panel p-3"
aria-label={`Heard live — every ${instrument} voicing of ${currentChord}`}
>
<h4 className="mb-1 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
Heard live · {currentChord} every voicing
</h4>
<p className="mb-2 text-[11px] text-gray-500">
{detectedProgression?.length
? `Heard ${detectedProgression.join(' → ')} — no ${activeStyle} pattern matched yet; voicings follow the next chord that commits.`
: 'Play a few bars — voicings and licks for your loop land here.'}
? `Heard ${detectedProgression.join(' → ')} — no ${activeStyle} pattern matched yet; following the chord as it commits.`
: 'No repeating loop yet — following the chord as it commits.'}
</p>
)}
<VoicingBrowser rootPc={liveChord.rootPc} quality={liveChord.type} show={instrument} />
</section>
)
) : (
/* Nothing heard yet — one slim line (~40px): the idle rail must not
waste dashboard space. */
<p className="rounded-xl border border-dashed border-border px-3 py-2 text-xs text-gray-500">
{detectedProgression?.length
? `Heard ${detectedProgression.join(' → ')} — no ${activeStyle} pattern matched yet; voicings follow the next chord that commits.`
: 'Play a few bars — voicings and licks for your loop land here.'}
</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 ? (
<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 }}
/>
) : null
// ── The grid (one-screen.md §1, §6): left flex-1 / right 500px at xl;
// stacked below xl in jam-following order (mainView → rail → licks →
// related) via display:contents on the left wrapper + order classes — one
// mount per surface, no duplicates (§7). The rail wrapper is the design's
// ONE justified internal scroller: height-bounded in normal mode, h-full
// in jam view (`fill`), NOT sticky (§1). ──
return (
<section
className={
'mb-3 flex flex-col gap-3 xl:flex-row' +
(fill ? ' xl:mb-0 xl:flex-1 xl:min-h-0' : ' xl:items-start')
}
aria-label="Jam Guide — follows the loop"
>
{/* LEFT — instrument view · licks · related progressions */}
<div className={'contents xl:flex xl:flex-col xl:gap-3 xl:flex-1 xl:min-w-0' + (fill ? ' xl:min-h-0' : '')}>
{mainView != null && (
<div className={'order-1 xl:order-none min-w-0' + (fill ? ' xl:shrink-0' : '')}>
{mainView}
</div>
)}
{licksStrip != null && (
<div className={'order-3 xl:order-none min-w-0' + (fill ? ' xl:shrink-0' : '')}>
{licksStrip}
</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' : '')}>
{relatedSlot}
</div>
)}
</div>
{/* 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 ' +
(fill ? 'xl:h-full' : 'xl:max-h-[calc(100vh_-_1.5rem)]')
}
>
{/* Micro-header — a line, not a button (D-40 §1: zero chrome) */}
<h3 className="mb-1.5 px-1 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
Suggested voicings {headerLabel}
{match.matched && keyInfo?.root ? ` · in ${keyInfo.root} ${keyInfo.mode}` : ''}
</h3>
{railContent}
</div>
</section>
)
}
+32 -12
View File
@@ -37,7 +37,10 @@ function keyColor(isChordTone, isPenta, isScale, isBlack, mono = false) {
: { fill: '#f5f5f5', text: '#6b7280' }
}
export default function Piano({ keyInfo, currentChord, monoColor = false }) {
// `compact` (task L-50, one-screen.md §2): trimmed card chrome (p-3, legend
// merged onto the heading line) + a natural-width cap on the SVG (max-width =
// its viewBox width, so it never renders above scale 1.0).
export default function Piano({ keyInfo, currentChord, monoColor = false, compact = false }) {
const { root, mode } = keyInfo ?? {}
if (!root) return null
@@ -51,15 +54,36 @@ export default function Piano({ keyInfo, currentChord, monoColor = false }) {
const svgW = totalWhite * KEY_W + 2
const svgH = KEY_H + 20 // +20 for octave labels
const heading = (
<p className={`text-sm text-gray-500 uppercase tracking-widest ${compact ? '' : 'mb-4'}`}>
Piano {root} {mode}
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
</p>
)
const legend = (
// Critic mechanical fix (L-50 gate): non-compact keeps HEAD's exact class
// string so the non-compact render stays byte-identical to the committed one.
<div className={compact ? 'flex items-center text-xs text-gray-500 flex-wrap gap-3' : 'mt-3 flex gap-5 text-xs text-gray-500'}>
<span><span className="text-accent"></span> Chord tone</span>
<span><span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}></span> Pentatonic</span>
<span><span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}></span> Scale</span>
</div>
)
return (
<div className="bg-panel border border-border rounded-2xl p-6">
<p className="text-sm text-gray-500 uppercase tracking-widest mb-4">
Piano {root} {mode}
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
</p>
<div className={`bg-panel border border-border rounded-2xl ${compact ? 'p-3' : 'p-6'}`}>
{compact ? (
<div className="mb-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-1">
{heading}
{legend}
</div>
) : (
heading
)}
<div>
<svg viewBox={`0 0 ${svgW} ${svgH}`} width="100%" height="auto" style={{ display: 'block' }}>
<svg viewBox={`0 0 ${svgW} ${svgH}`} width="100%" height="auto" style={{ display: 'block', ...(compact ? { maxWidth: svgW } : null) }}>
{/* White keys */}
{Array.from({ length: OCTAVES }, (_, oct) =>
@@ -132,11 +156,7 @@ export default function Piano({ keyInfo, currentChord, monoColor = false }) {
</svg>
</div>
<div className="mt-3 flex gap-5 text-xs text-gray-500">
<span><span className="text-accent"></span> Chord tone</span>
<span><span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}></span> Pentatonic</span>
<span><span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}></span> Scale</span>
</div>
{!compact && legend}
</div>
)
}