diff --git a/src/components/BassPatternCard.jsx b/src/components/BassPatternCard.jsx
new file mode 100644
index 0000000..2576c0a
--- /dev/null
+++ b/src/components/BassPatternCard.jsx
@@ -0,0 +1,415 @@
+// BassPatternCard — per-station authored bass pattern as compact 4-string tab
+// (task L-42, per docs/design/integrated-glance.md §3's gallery-slot contract
+// and SCHEMA.md "Bass play").
+//
+// Given a station's { rootPc, quality }, one play's per-station `pattern`, and
+// the NEXT station's root (approach pitches derive from it — the loop wraps),
+// this renders the REALIZED line: the degree/approach data resolved to actual
+// pitches and placed on the bass. Visually it rhymes with LickCard's tab SVG
+// (same string-line + surface-pill-number language, same technique-glyph
+// vocabulary) but with the bass rendering convention from SCHEMA.md:
+//
+// standard 4-string tuning E–A–D–G, string 1 = G (HIGHEST) rendered on top,
+// string 4 = low E at the bottom — the same "1 = highest string" counting as
+// lick tab and `rootStr`, so no third scheme exists in this codebase.
+//
+// ── Realization rules (SCHEMA.md "Bass play") ─────────────────────────────────
+// Pitch space: semitones above open low E (E1). Open strings sit at 0 (E),
+// 5 (A), 10 (D), 15 (G); the playable ceiling is 30 (G string, fret 15).
+//
+// · The station ROOT lands on its lowest playable position:
+// rootAbs = (rootPc − 4) mod 12 (open E's pitch class is 4).
+// · A `deg` note sits at rootAbs + resolveDegree(deg, quality) + 12·octave.
+// The schema caps the resolved offset at 19 semitones, so every deg note
+// fits ≤ 30 by construction (11 + 19); anything unresolvable nulls the
+// whole pattern and the card renders its placeholder instead of lying.
+// · An `approach` note's PITCH CLASS derives from the next station's root:
+// chrom-below = next−1 · chrom-above = next+1 · fifth-of-next = next+7
+// (mod 12). Its OCTAVE is the placement nearest the previous realized note
+// (approaches are terminal, so a previous note always exists), which keeps
+// the walkup/walkdown contour the author described — e.g. the 12-bar bar-4
+// walkup C–D–E♭ lands its E right above the E♭, not two octaves away.
+// · 🚨 C-41 gate rule — the open-E floor: a chrom-below into a next root at
+// open E (abs 0) has NO pitch below the instrument (−1 does not exist).
+// When the nearest placement falls below abs 0 it is OCTAVE-DISPLACED up
+// (+12): the approach plays a half-step under the OCTAVE root instead —
+// e.g. D♯ at abs 11 leading into E. The symmetric ceiling guard (> 30 →
+// −12) exists for completeness. This is the displacement rule the ledger
+// row mandates; keep it.
+// · String/fret: each realized pitch takes the HIGHEST-tuned open string at
+// or below it — the minimum-fret assignment. Because the root starts at
+// its lowest position (fret ≤ 11 worst case, usually ≤ 8) and strings are
+// tuned in fourths, the whole pattern lands in one hand position within
+// frets 0–15 (the schema's ≤ 19-semitone span guarantee).
+//
+// ── Rendering ─────────────────────────────────────────────────────────────────
+// Beat spacing when every note carries a `beat` (columns proportional to beat,
+// faint beat numbers under the bottom string); plain even columns otherwise.
+// Approach notes draw AMBER (the established secondary-tone colour) with a
+// small arrow after the final one — they belong to the NEXT chord. Technique
+// marks reuse the lick glyph vocabulary in its light form (amber letter/symbol
+// above the note; ghost notes parenthesise and dim, as in LickCard).
+//
+// ── Playback (▶) ──────────────────────────────────────────────────────────────
+// Sequential single-note scheduling through chordAudio's playVoicing (one call
+// per note — playVoicing itself sorts/dedupes, which is wrong for a melodic
+// line, so the ORDER lives here in setTimeout scheduling). Bass register:
+// realized abs − 20 puts open E at chordAudio note −20 = E1 ≈ 41.2 Hz
+// (chordAudio's space is 0 = C3; negatives are documented-legal input).
+// Timing: beats at a fixed preview tempo when present, else even eighths.
+// One pattern at a time module-wide: each play stops the previous sequence
+// AND calls stopAll() so it never layers over a VoicingBrowser preview.
+
+import { useEffect } from 'react'
+import { NOTES } from '../lib/theory'
+import { resolveDegree } from './JamGuide'
+import { playVoicing, stopAll } from '../lib/chordAudio'
+
+// SVG palette — mirrors LickCard's token constants (SVG fills can't read
+// Tailwind classes): amber = technique/approach, surface = backing pills.
+const AMBER = '#f59e0b'
+const FRET_TEXT = '#e5e7eb' // gray-200
+const GHOST_TEXT = '#9ca3af' // gray-400
+const STRING_LINE = '#3a3a3a'
+const STRING_LABEL = '#6b7280' // gray-500
+const BEAT_LABEL = '#6b7280'
+const CARD_BG = '#0f0f0f' // token `surface`
+
+// Strings top → bottom: 1 = G (highest), 4 = low E. `open` = semitones above E1.
+const STRINGS = [
+ { n: 1, name: 'G', open: 15 },
+ { n: 2, name: 'D', open: 10 },
+ { n: 3, name: 'A', open: 5 },
+ { n: 4, name: 'E', open: 0 },
+]
+const OPEN_E_PC = 4 // pitch class of the low E string
+const ABS_MAX = 30 // G string fret 15 — the playable ceiling
+
+// Approach-type → semitone delta from the NEXT station's root (SCHEMA.md).
+const APPROACH_DELTA = { 'chrom-below': -1, 'chrom-above': 1, 'fifth-of-next': 7 }
+
+// Technique mark drawn above the note (light form of LickCard's glyphs; the
+// full vocabulary is legal per SCHEMA.md — ghost-note is handled by the
+// parenthesised dim label, double-stop by equal beats, chromatic-approach and
+// unknown words get no mark, exactly like LickCard's graceful default).
+const TECH_MARK = {
+ 'hammer-on': 'h',
+ 'pull-off': 'p',
+ slide: '⟋',
+ bend: '↑',
+ vibrato: '~',
+}
+
+const mod12 = (n) => ((n % 12) + 12) % 12
+
+// abs (semitones above open E1 = MIDI 28) → "E1"/"C2"… scientific spelling.
+function absName(abs) {
+ return `${NOTES[mod12(abs + OPEN_E_PC)]}${Math.floor((abs + 28) / 12) - 1}`
+}
+
+// Min-fret string assignment: highest-tuned open string at or below the pitch.
+function placeOnString(abs) {
+ for (const s of STRINGS) {
+ if (abs >= s.open && abs - s.open <= 15) return { string: s.n, fret: abs - s.open }
+ }
+ return null // abs outside [0, 30] — caller nulls the pattern
+}
+
+/**
+ * realizeBassPattern(pattern, rootPc, quality, nextRootPc)
+ * → [{ abs, string, fret, beat?, technique?, approach }] | null
+ *
+ * Pure realization per the header rules. Returns null on ANY malformed or
+ * unplaceable note so the caller can render a placeholder — authored data is
+ * validator-guaranteed, but a renderer must never crash on bad input.
+ */
+export function realizeBassPattern(pattern, rootPc, quality, nextRootPc) {
+ if (!Array.isArray(pattern) || pattern.length === 0) return null
+ if (!Number.isFinite(rootPc)) return null
+ const rootAbs = mod12(rootPc - OPEN_E_PC) // lowest playable root position
+ const out = []
+ let prev = rootAbs // approach placement anchor (root until a note lands)
+ for (const note of pattern) {
+ if (!note || typeof note !== 'object') return null
+ let abs
+ if (note.deg !== undefined && note.approach === undefined) {
+ const off = resolveDegree(String(note.deg), quality)
+ if (off === null || off === undefined) return null
+ abs = rootAbs + off + (note.octave === 1 ? 12 : 0)
+ } else if (note.approach !== undefined && note.deg === undefined) {
+ const delta = APPROACH_DELTA[note.approach]
+ if (delta === undefined || !Number.isFinite(nextRootPc)) return null
+ const pc = mod12(nextRootPc + delta)
+ const base = mod12(pc - OPEN_E_PC)
+ // Nearest octave placement to the previous note (ties resolve upward).
+ abs = base + 12 * Math.round((prev - base) / 12)
+ // C-41 open-E floor: chrom-below into a next root at open E resolves to
+ // abs −1, which does not exist on the instrument — octave-displace the
+ // approach up (+12: a half-step under the OCTAVE root). Ceiling mirror.
+ if (abs < 0) abs += 12
+ if (abs > ABS_MAX) abs -= 12
+ } else {
+ return null // exactly one of deg | approach per note (schema)
+ }
+ const placed = placeOnString(abs)
+ if (!placed) return null
+ out.push({
+ abs,
+ string: placed.string,
+ fret: placed.fret,
+ beat: Number.isFinite(note.beat) ? note.beat : undefined,
+ technique: typeof note.technique === 'string' ? note.technique : undefined,
+ approach: note.approach !== undefined,
+ })
+ prev = abs
+ }
+ return out
+}
+
+// ─── Sequential playback (module-level: one pattern at a time, app-wide) ─────
+
+const PREVIEW_BPM = 96 // relaxed shuffle-ish preview tempo (display is tempo-free)
+
+let currentSeq = null // { timeouts: number[], handles: {stop}[] }
+
+function stopPattern() {
+ if (!currentSeq) return
+ for (const t of currentSeq.timeouts) clearTimeout(t)
+ for (const h of currentSeq.handles) h.stop()
+ currentSeq = null
+}
+
+function playPattern(realized) {
+ stopPattern()
+ stopAll() // never layer over a VoicingBrowser (or any other) preview
+ const beatMs = 60000 / PREVIEW_BPM
+ const hasBeats = realized.every((n) => Number.isFinite(n.beat))
+ // Beats drive onsets when authored; otherwise even eighths.
+ 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 ghost-short gaps 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 − 20: open E1 in chordAudio's 0 = C3 note space (negatives legal).
+ seq.handles.push(playVoicing([n.abs - 20], { strumMs: 0, durMs, gain: 0.5 }))
+ }, times[i]),
+ )
+ })
+ currentSeq = seq
+}
+
+// ─── SVG geometry (LickCard's idiom, 4 strings) ───────────────────────────────
+
+const STR_GAP = 13
+const PAD_T = 14
+const PAD_B = 6
+const PAD_L = 20
+const PAD_R = 12
+const COL_W = 26
+const BEAT_ROW = 10 // extra bottom room for beat numbers when beats render
+
+function layoutPattern(realized) {
+ const hasBeats =
+ realized.every((n) => Number.isFinite(n.beat)) &&
+ realized.every((n, i) => i === 0 || n.beat >= realized[i - 1].beat)
+ let xs, width, maxBeat, unit = null
+ if (hasBeats) {
+ // Columns proportional to beat; scale so the tightest gap ≥ one column.
+ const gaps = realized
+ .map((n, i) => (i > 0 ? n.beat - realized[i - 1].beat : 0))
+ .filter((g) => g > 0)
+ const minGap = gaps.length ? Math.min(...gaps) : 1
+ unit = Math.min(COL_W / minGap, COL_W * 4)
+ xs = realized.map((n) => PAD_L + (n.beat - 1) * unit + 10)
+ maxBeat = realized[realized.length - 1].beat
+ width = PAD_L + (maxBeat - 1) * unit + 20 + PAD_R
+ } else {
+ xs = realized.map((_, i) => PAD_L + i * COL_W + COL_W / 2)
+ width = PAD_L + realized.length * COL_W + PAD_R
+ maxBeat = null
+ }
+ return {
+ hasBeats,
+ maxBeat,
+ unit,
+ width,
+ height: PAD_T + 3 * STR_GAP + PAD_B + (hasBeats ? BEAT_ROW : 0),
+ notes: realized.map((n, i) => ({
+ ...n,
+ x: xs[i],
+ y: PAD_T + (n.string - 1) * STR_GAP,
+ ghost: n.technique === 'ghost-note',
+ label: n.technique === 'ghost-note' ? `(${n.fret})` : String(n.fret),
+ })),
+ }
+}
+
+function PatternSvg({ layout, ariaLabel }) {
+ const { notes, width, height, hasBeats, maxBeat, unit } = layout
+ const bottomY = PAD_T + 3 * STR_GAP
+ return (
+
+ )
+}
+
+// ─── The card ─────────────────────────────────────────────────────────────────
+
+// Same ▶ pill as VoicingBrowser's gallery cells (its PlayButton is file-local;
+// classes mirrored so the two galleries read identically).
+function PlayButton({ ariaLabel, onClick }) {
+ return (
+
+ )
+}
+
+/**
+ *
{note}
} +- root {NOTES[st.rootPc]} - · - fifth {NOTES[fifthPc]} - {next && ( - <> - · - approach{' '} - - {NOTES[approachPc]} → {NOTES[next.rootPc]} - - > - )} -
+ {hasPack ? ( + /* The gallery slot (L-42): one realized pattern card per play, + side by side. The wrap mirrors the GlanceRail cell reflow — + rows wrap, never scroll horizontally (D-40 §4). */ ++ root {NOTES[st.rootPc]} + · + fifth {NOTES[fifthPc]} + {next && ( + <> + · + approach{' '} + + {NOTES[approachPc]} → {NOTES[next.rootPc]} + + > + )} +
+ )}- Authored bass patterns are on the way (blues first) — meanwhile: roots, - fifths, and the approach into the next chord. -
+ {hasPack ? ( + <> + {/* Per-play groove legend — feel is required schema data; position + hint appended when authored. Rendered once, not per row. */} + {plays.map((play, p) => ( ++ {play?.label} + {play?.feel ? <> — {play.feel}> : null} + {play?.position ? <> · {play.position}> : null} +
+ ))} + {/* Amber + mic microcopy — once for the whole rail (D-31 §2.5). */} ++ Amber note = the approach into the next chord. ▶ previews play through + your speakers — while the mic is live, detection may hear them. Nothing + plays automatically. +
+ > + ) : ( + /* ONE notice for the whole rail, not per row (D-40 §3). */ ++ Authored bass patterns are on the way (blues first) — meanwhile: roots, + fifths, and the approach into the next chord. +
+ )} ) }