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:
+70
-21
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
return (
|
||||
<div className="bg-panel border border-border rounded-2xl p-6">
|
||||
<p className="text-sm text-gray-500 uppercase tracking-widest mb-4">
|
||||
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 ${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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
return (
|
||||
<div className="bg-panel border border-border rounded-2xl p-6">
|
||||
<p className="text-sm text-gray-500 uppercase tracking-widest mb-4">
|
||||
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 ${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>
|
||||
)
|
||||
}
|
||||
|
||||
+98
-43
@@ -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,16 +354,10 @@ 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 ? (
|
||||
// ── 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
|
||||
@@ -365,9 +370,8 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
|
||||
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). */}
|
||||
/* The voicing rail — ALL stations expanded as vertical rows; the
|
||||
playhead only highlights (D-41, D-40 §4). */
|
||||
<GlanceRail
|
||||
stations={stationVoicings}
|
||||
activeIndex={canonicalPos}
|
||||
@@ -377,13 +381,6 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
|
||||
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
|
||||
@@ -399,7 +396,6 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
|
||||
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}`}
|
||||
@@ -414,25 +410,84 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
|
||||
</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). */}
|
||||
)
|
||||
) : (
|
||||
/* 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>
|
||||
)
|
||||
) : (
|
||||
/* 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">
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
+30
-10
@@ -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
|
||||
|
||||
return (
|
||||
<div className="bg-panel border border-border rounded-2xl p-6">
|
||||
<p className="text-sm text-gray-500 uppercase tracking-widest mb-4">
|
||||
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 ${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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user