diff --git a/src/components/RoadmapTrack.jsx b/src/components/RoadmapTrack.jsx
new file mode 100644
index 0000000..243ab30
--- /dev/null
+++ b/src/components/RoadmapTrack.jsx
@@ -0,0 +1,339 @@
+import { NOTES, CHORD_TYPES, guideTones, voiceLeadingPairs, soloScale } from '../lib/theory'
+
+// ─── RoadmapTrack (D-01) ──────────────────────────────────────────────────────
+//
+// The heart of the "Roadmap" Jam Guide concept (docs/design/jam-guide-concept-c.md):
+// the live loop rendered as a horizontal improv highway. Each KB progression
+// station carries a chord name + Roman numeral + solo-scale label, a guide-tone
+// lane (3rd/7th dots), and voice-leading rails drawn *between* adjacent stations
+// (the 7→3 falls-a-half-step thread). A playhead + beat grid sit underneath; the
+// station at `position` is "now", the next gets a subtle lookahead glow.
+//
+// Pure / presentational: no audio, no data fetching. Everything derives from
+// props + theory.js. Default-exported. Luthier (D-02) wires it into JamGuide.jsx.
+//
+// Prop contract (honoured exactly — other agents build against it):
+// progression KB progression object { id, name, rn, degrees, qualities, bars, mode, ... }
+// keyRoot tonic pitch class 0–11
+// keyMode 'major' | 'minor'
+// position index of the current station (playhead); -1 if none
+// bpm optional, for the beat grid; tolerate undefined
+
+// Pitch class → note name. Sharps via NOTES (the app's canonical spelling, and
+// what Fretboard.jsx uses); kept to one source so the roadmap matches the neck.
+const pcName = pc => NOTES[((pc % 12) + 12) % 12]
+
+// Build the full display chord name from a pitch class + a CHORD_TYPES quality
+// key (e.g. 7 + 'dom7' → "G7", 2 + 'min7' → "Dm7"). Falls back to a bare major
+// triad spelling if the quality is unknown, so the panel never renders blank.
+const chordName = (rootPc, quality) =>
+ pcName(rootPc) + (CHORD_TYPES[quality]?.suffix ?? '')
+
+// A readable mode word for the SCALE lane: theory.js returns snake_case names
+// ('phrygian_dominant'); the design wants "G mixolydian".
+const prettyScale = (rootPc, quality, keyMode) => {
+ const { name } = soloScale(quality, keyMode)
+ return `${pcName(rootPc)} ${name.replace(/_/g, ' ')}`
+}
+
+// ─── Layout constants (px in the SVG-free flex layout) ────────────────────────
+const STATION_MIN_W = 168 // each station's min width; loops longer than the
+ // viewport scroll horizontally (12-bar blues etc.)
+const RAIL_W = 34 // width of the gap a voice-leading rail bridges
+const RAIL_H = 40 // rail SVG height
+
+// Render the small arrow rail between two stations. `pair` is one entry from
+// voiceLeadingPairs: { from, to, semitones }. We emphasise the half-step motion
+// — a 0-semitone move is a held common tone ("holds"), ±1 a half-step, ±2 a
+// whole step. Drawn in accent purple to match the guide-tone dots it connects.
+function Rail({ pair }) {
+ if (!pair) return null
+ const { from, to, semitones } = pair
+ const held = semitones === 0
+ const dir = semitones < 0 ? 'down' : semitones > 0 ? 'up' : 'hold'
+ const label = held
+ ? `${pcName(from)} holds`
+ : `${pcName(from)}→${pcName(to)}` // C→B
+ const motion = held
+ ? 'common tone'
+ : `${Math.abs(semitones) === 1 ? '½' : Math.abs(semitones)} step ${dir === 'down' ? 'down' : 'up'}`
+
+ return (
+
+
+ {label}
+ {motion}
+
+ )
+}
+
+// A single guide-tone dot with its honest label. `kind` is '3rd' / '7th' / '5th'.
+function GuideDot({ pc, kind, filled }) {
+ return (
+
+
+ {pcName(pc)}
+
+ {kind}
+
+ )
+}
+
+// A single station on the highway.
+function Station({
+ index, rootPc, quality, rn, scaleLabel, isNow, isNext, width,
+}) {
+ const g = guideTones(rootPc, quality)
+ // hasSeventh:false → label the fallback honestly ("5th"), never call a 5th a 7th.
+ const seventhKind = g.hasSeventh ? '7th' : '5th'
+
+ // Tier the dimming exactly like ProgressionBanner: "now" is full accent, the
+ // lookahead "next" is a softer glow, everything else recedes — but never below
+ // a legibility floor (AA contrast on bg-panel).
+ const stateClass = isNow
+ ? 'border-accent bg-accent/10 ring-2 ring-accent'
+ : isNext
+ ? 'border-accent/50 bg-accent/5'
+ : 'border-border bg-surface'
+ const opacity = isNow ? 1 : isNext ? 0.92 : 0.7
+
+ return (
+
+ {/* Header: chord name + Roman numeral, with the lookahead flag */}
+
+ )
+}
+
+// The playhead + beat grid under the whole track. Total beats = Σ bars × 4.
+// The current beat is the start of the active station (coarse, chord-accurate —
+// matches ProgressionBanner; fine beat interpolation is a later D-02 polish).
+function BeatGrid({ bars, position, bpm }) {
+ const beatsPerStation = bars.map(b => (b || 1) * 4)
+ const totalBeats = beatsPerStation.reduce((s, n) => s + n, 0)
+ // first beat index of each station
+ const stationStart = []
+ let acc = 0
+ for (const n of beatsPerStation) { stationStart.push(acc); acc += n }
+ const nowBeat = position >= 0 && position < stationStart.length ? stationStart[position] : -1
+ const pct = nowBeat >= 0 && totalBeats > 0 ? (nowBeat + 0.5) / totalBeats : 0
+
+ return (
+
+ {Array.from({ length: totalBeats }, (_, i) => {
+ const isNow = i === nowBeat
+ // downbeat (beat 1 of a bar) gets a brighter tick
+ const isDownbeat = i % 4 === 0
+ return (
+
+ )
+ })}
+
+
+ {bpm ? (
+
~{Math.round(bpm)} BPM
+ ) : null}
+
+ )
+}
+
+export default function RoadmapTrack({
+ progression,
+ keyRoot = 0,
+ keyMode = 'major',
+ position = -1,
+ bpm,
+}) {
+ // Tolerate a missing / malformed progression — the panel is never empty-crashed.
+ if (!progression || !Array.isArray(progression.degrees) || progression.degrees.length === 0) {
+ return (
+
+ No loop to map yet — play a progression.
+
+ )
+ }
+
+ const { degrees, qualities = [], rn = [], bars = [], name, id } = progression
+ const n = degrees.length
+
+ // Resolve each station to an absolute chord in the current key.
+ const stations = degrees.map((deg, i) => {
+ const rootPc = (((keyRoot + deg) % 12) + 12) % 12
+ const quality = qualities[i] ?? 'maj'
+ return {
+ rootPc,
+ quality,
+ rn: rn[i] ?? '',
+ scaleLabel: prettyScale(rootPc, quality, keyMode),
+ bars: bars[i] ?? 1,
+ }
+ })
+
+ // Voice-leading rails between adjacent stations, plus a wrap-around rail from
+ // the last station back to the first (the loop is a wheel — a nice touch the
+ // design calls for: "B holds → next loop"). Index i = rail leaving station i.
+ const rails = stations.map((s, i) => {
+ const next = stations[(i + 1) % n]
+ return voiceLeadingPairs(
+ { root: s.rootPc, quality: s.quality },
+ { root: next.rootPc, quality: next.quality },
+ )[0] ?? null // the headline rail is the 7→3 (voiceLeadingPairs lists 7th first)
+ })
+
+ const nextPos = position >= 0 ? (position + 1) % n : -1
+
+ return (
+
+ {/* Header strip: loop name + station chord summary */}
+
+
+ {name ?? 'Loop'}
+
+
+ {stations.map(s => chordName(s.rootPc, s.quality)).join(' → ')}
+
+
+
+ {/* The highway: stations interleaved with voice-leading rails. Scrolls
+ horizontally when the loop is longer than the viewport. */}
+
+
+ {stations.map((s, i) => (
+
+
+ {/* rail to the next station (inter-station rails only; the
+ wrap-around rail is drawn separately after the last station) */}
+ {i < n - 1 && }
+
+ ))}
+ {/* wrap-around rail back to station 1, rendered after the last station */}
+ {n > 1 && (
+