From bb99f5964a0fc6b0914457a5eb2881013abcce3e Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 9 Apr 2026 00:26:16 +0200 Subject: [PATCH] docs: chord diagram implementation plan --- .../plans/2026-04-09-chord-diagram.md | 1107 +++++++++++++++++ 1 file changed, 1107 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-09-chord-diagram.md diff --git a/docs/superpowers/plans/2026-04-09-chord-diagram.md b/docs/superpowers/plans/2026-04-09-chord-diagram.md new file mode 100644 index 0000000..630fe90 --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-chord-diagram.md @@ -0,0 +1,1107 @@ +# Chord Diagram Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add piano and guitar chord diagrams to the song detail page — shown in a side column on desktop and a scrollable bottom grid + inline tap popup on mobile. + +**Architecture:** Three layers: `tonal` parses chord names into note sets → `chord-voicing.ts` maps notes/quality to renderable positions → dumb React components render the positions. Guitar uses moveable barre-chord templates per quality type, transposed by shifting the baseFret. Piano highlights the matching keys in a fixed C-to-B octave. + +**Tech Stack:** React, TypeScript, Tailwind v4, `tonal` (music theory), `vitest` (unit tests) + +--- + +## File Map + +| File | Action | Purpose | +|------|--------|---------| +| `app/package.json` | Modify | Add `tonal` dep, `vitest` devDep, `test` script | +| `app/vitest.config.ts` | Create | Vitest config with `~` alias | +| `app/app/lib/guitar-voicings.ts` | Create | ~12 barre-chord quality templates (pure data) | +| `app/app/lib/chord-voicing.ts` | Create | `getPianoNotes` + `getGuitarVoicing` using tonal | +| `app/app/lib/song-utils.ts` | Modify | Add `extractUniqueChords` | +| `app/app/components/chord-diagram/piano-keys.tsx` | Create | Dumb piano keyboard renderer | +| `app/app/components/chord-diagram/guitar-fretboard.tsx` | Create | Dumb guitar fretboard renderer | +| `app/app/components/chord-diagram/chord-diagram.tsx` | Create | Entry point: chord+instrument → voicing → renderer | +| `app/app/components/chord-diagram/chord-grid.tsx` | Create | Wrapped grid of ChordDiagram cards | +| `app/app/components/chord-chart.tsx` | Modify | Make chord names tappable via `onChordClick` | +| `app/app/routes/songs.$id.tsx` | Modify | Add uniqueChords, instrument state, inline popup, two-column layout | + +--- + +## Task 1: Install tonal + set up vitest + +**Files:** +- Modify: `app/package.json` +- Create: `app/vitest.config.ts` + +- [ ] **Step 1: Install dependencies** + +```bash +cd app && npm install tonal && npm install -D vitest +``` + +Expected: `tonal` added to dependencies, `vitest` to devDependencies in `package.json`. + +- [ ] **Step 2: Add test script to package.json** + +In `app/package.json`, add to `"scripts"`: +```json +"test": "vitest run" +``` + +- [ ] **Step 3: Create vitest config** + +Create `app/vitest.config.ts`: +```ts +import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; + +export default defineConfig({ + test: { + environment: 'node', + }, + resolve: { + alias: { + '~': resolve(__dirname, './app'), + }, + }, +}); +``` + +- [ ] **Step 4: Verify vitest runs** + +```bash +cd app && npm test +``` + +Expected: `No test files found, exiting with code 0` (no tests yet — that's fine). + +- [ ] **Step 5: Commit** + +```bash +git add app/package.json app/package-lock.json app/vitest.config.ts +git commit -m "chore: add tonal + vitest" +``` + +--- + +## Task 2: Guitar voicing data + +**Files:** +- Create: `app/app/lib/guitar-voicings.ts` + +Templates use **0-based relative fret positions** where `0` = the root fret (open position for E/A root). `null` = muted string. `rootString` determines which open string carries the root for transposition. + +- [ ] **Step 1: Create guitar-voicings.ts** + +Create `app/app/lib/guitar-voicings.ts`: + +```ts +export interface GuitarVoicingTemplate { + /** 6 strings low→high; 0 = root position, 1 = one fret above root, null = muted */ + frets: (number | null)[]; + /** Fret (0-based relative) where a full barre is drawn, or null */ + barre: number | null; + /** Which open string carries the root — determines transposition offset */ + rootString: 'E' | 'A'; +} + +/** + * Moveable barre-chord templates keyed by tonal chord type name. + * Verified fingerings at root = E (E-shape) or root = A (A-shape). + * To add a new quality: look up `Chord.get('').type` in tonal, + * then define the fingering at root E or A and add it here. + */ +export const GUITAR_VOICINGS: Record = { + // ── E-shape (root on 6th string) ────────────────────────────────────── + // E major open: [0,2,2,1,0,0] E B E G# B E + 'major': { + frets: [0, 2, 2, 1, 0, 0], + barre: null, + rootString: 'E', + }, + // E7: [0,2,0,1,0,0] E B D G# B E + 'dominant seventh': { + frets: [0, 2, 0, 1, 0, 0], + barre: null, + rootString: 'E', + }, + // Emaj7: [0,2,1,1,0,0] E B D# G# B E + 'major seventh': { + frets: [0, 2, 1, 1, 0, 0], + barre: null, + rootString: 'E', + }, + // Eaug: [0,3,2,1,1,0] E C(=B#) E G# C E + 'augmented': { + frets: [0, 3, 2, 1, 1, 0], + barre: null, + rootString: 'E', + }, + // Esus4: [0,2,2,2,0,0] E B E A B E + 'suspended fourth': { + frets: [0, 2, 2, 2, 0, 0], + barre: null, + rootString: 'E', + }, + + // ── A-shape (root on 5th string) ────────────────────────────────────── + // Am open: [x,0,2,2,1,0] A E A C E + // barre: 0 so that transposed versions (Bm, Cm, etc.) draw the barre bar; + // the renderer suppresses the barre when baseFret===0 (open position = no barre needed) + 'minor': { + frets: [null, 0, 2, 2, 1, 0], + barre: 0, + rootString: 'A', + }, + // Am7: [x,0,2,0,1,0] A E G C E + 'minor seventh': { + frets: [null, 0, 2, 0, 1, 0], + barre: null, + rootString: 'A', + }, + // AmMaj7: [x,0,2,1,1,0] A E G# C E + 'minor major seventh': { + frets: [null, 0, 2, 1, 1, 0], + barre: null, + rootString: 'A', + }, + // Adim: [x,0,1,2,1,x] A Eb A C (string 1 muted) + 'diminished': { + frets: [null, 0, 1, 2, 1, null], + barre: null, + rootString: 'A', + }, + // Am7b5 (half-dim): [x,0,1,0,1,x] A Eb G C + 'half-diminished': { + frets: [null, 0, 1, 0, 1, null], + barre: null, + rootString: 'A', + }, + // Asus4: [x,0,2,2,3,0] A E A D E + 'suspended second': { + frets: [null, 0, 2, 2, 0, 0], + barre: null, + rootString: 'A', + }, +}; +``` + +- [ ] **Step 2: Commit** + +```bash +git add app/app/lib/guitar-voicings.ts +git commit -m "feat: guitar voicing templates" +``` + +--- + +## Task 3: Theory layer + tests + +**Files:** +- Create: `app/app/lib/chord-voicing.ts` +- Create: `app/app/lib/chord-voicing.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `app/app/lib/chord-voicing.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { getPianoNotes, getGuitarVoicing } from './chord-voicing'; + +describe('getPianoNotes', () => { + it('returns note names for a major chord', () => { + expect(getPianoNotes('C')).toEqual(['C', 'E', 'G']); + }); + + it('returns note names for Cmaj7', () => { + expect(getPianoNotes('Cmaj7')).toEqual(['C', 'E', 'G', 'B']); + }); + + it('returns note names for Am', () => { + expect(getPianoNotes('Am')).toEqual(['A', 'C', 'E']); + }); + + it('returns [] for unparseable chord', () => { + expect(getPianoNotes('???')).toEqual([]); + }); + + it('returns [] for empty string', () => { + expect(getPianoNotes('')).toEqual([]); + }); +}); + +describe('getGuitarVoicing', () => { + it('returns voicing for E major (open position, baseFret=0)', () => { + const v = getGuitarVoicing('E'); + expect(v).not.toBeNull(); + expect(v!.baseFret).toBe(0); + expect(v!.frets).toEqual([0, 2, 2, 1, 0, 0]); + }); + + it('returns voicing for Am (open position, baseFret=0)', () => { + const v = getGuitarVoicing('Am'); + expect(v).not.toBeNull(); + expect(v!.baseFret).toBe(0); + expect(v!.frets).toEqual([null, 0, 2, 2, 1, 0]); + }); + + it('transposes Bm correctly (A-shape, shift=2)', () => { + const v = getGuitarVoicing('Bm'); + expect(v).not.toBeNull(); + // Am shifted up 2: [null,2,4,4,3,2], baseFret=2 + expect(v!.baseFret).toBe(2); + expect(v!.frets).toEqual([null, 2, 4, 4, 3, 2]); + }); + + it('transposes G major correctly (E-shape, shift=3)', () => { + const v = getGuitarVoicing('G'); + expect(v).not.toBeNull(); + // E major shifted up 3: [3,5,5,4,3,3], baseFret=3 + expect(v!.baseFret).toBe(3); + expect(v!.frets).toEqual([3, 5, 5, 4, 3, 3]); + }); + + it('returns null for unknown quality', () => { + // 'add9' is not in the voicing map + expect(getGuitarVoicing('Cadd9')).toBeNull(); + }); + + it('returns null for unparseable chord', () => { + expect(getGuitarVoicing('???')).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +cd app && npm test +``` + +Expected: errors like `Cannot find module './chord-voicing'`. + +- [ ] **Step 3: Implement chord-voicing.ts** + +Create `app/app/lib/chord-voicing.ts`: + +```ts +import { Chord, Note } from 'tonal'; +import { GUITAR_VOICINGS } from './guitar-voicings'; + +export interface GuitarVoicing { + /** Absolute fret numbers per string (low→high); null = muted, 0 = open */ + frets: (number | null)[]; + /** Lowest fret displayed on the diagram (0 = show nut) */ + baseFret: number; + /** Absolute fret to draw a barre bar across, or null */ + barre: number | null; +} + +const ROOT_STRING_CHROMA: Record<'E' | 'A', number> = { + E: Note.chroma('E')!, // 4 + A: Note.chroma('A')!, // 9 +}; + +/** + * Returns the note names (e.g. ["C","E","G"]) for a chord string. + * Returns [] if the chord cannot be parsed. + */ +export function getPianoNotes(chord: string): string[] { + if (!chord) return []; + const parsed = Chord.get(chord); + if (!parsed.tonic || parsed.empty) return []; + return parsed.notes; +} + +/** + * Returns a transposed GuitarVoicing for a chord string, or null if the + * chord quality has no template or the chord cannot be parsed. + */ +export function getGuitarVoicing(chord: string): GuitarVoicing | null { + if (!chord) return null; + const parsed = Chord.get(chord); + if (!parsed.tonic || parsed.empty) return null; + + const template = GUITAR_VOICINGS[parsed.type]; + if (!template) return null; + + const rootChroma = ROOT_STRING_CHROMA[template.rootString]; + const tonicChroma = Note.chroma(parsed.tonic); + if (tonicChroma === undefined) return null; + + const shift = (tonicChroma - rootChroma + 12) % 12; + + return { + frets: template.frets.map((f) => (f === null ? null : f + shift)), + baseFret: shift, + barre: template.barre === null ? null : template.barre + shift, + }; +} +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +```bash +cd app && npm test +``` + +Expected: all 11 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add app/app/lib/chord-voicing.ts app/app/lib/chord-voicing.test.ts +git commit -m "feat: chord voicing theory layer" +``` + +--- + +## Task 4: PianoKeys component + +**Files:** +- Create: `app/app/components/chord-diagram/piano-keys.tsx` + +- [ ] **Step 1: Create the component** + +Create `app/app/components/chord-diagram/piano-keys.tsx`: + +```tsx +/** Chroma value (0=C … 11=B) for every note name tonal might return */ +const NOTE_CHROMA: Record = { + 'C': 0, 'C#': 1, 'Db': 1, + 'D': 2, 'D#': 3, 'Eb': 3, + 'E': 4, 'Fb': 4, + 'F': 5, 'E#': 5, 'F#': 6, 'Gb': 6, + 'G': 7, 'G#': 8, 'Ab': 8, + 'A': 9, 'A#': 10, 'Bb': 10, + 'B': 11, 'Cb': 11, 'B#': 0, +}; + +const WHITE_KEYS = ['C', 'D', 'E', 'F', 'G', 'A', 'B']; +const WHITE_KEY_W = 14; // px +const WHITE_KEY_H = 56; // px +const BLACK_KEY_W = 9; // px +const BLACK_KEY_H = 34; // px + +/** Left offset (px) of each black key from the left edge of the keyboard */ +const BLACK_KEY_LEFT: Record = { + 'C#': 1 * WHITE_KEY_W - BLACK_KEY_W / 2, + 'D#': 2 * WHITE_KEY_W - BLACK_KEY_W / 2, + 'F#': 4 * WHITE_KEY_W - BLACK_KEY_W / 2, + 'G#': 5 * WHITE_KEY_W - BLACK_KEY_W / 2, + 'A#': 6 * WHITE_KEY_W - BLACK_KEY_W / 2, +}; + +const BLACK_KEY_NAMES = Object.keys(BLACK_KEY_LEFT); + +interface Props { + /** Note names from tonal, e.g. ["C","E","G"] or ["Ab","C","Eb"] */ + notes: string[]; +} + +export function PianoKeys({ notes }: Props) { + const activeChroma = new Set( + notes.map((n) => NOTE_CHROMA[n]).filter((c) => c !== undefined) + ); + const totalWidth = WHITE_KEY_W * 7; + + if (notes.length === 0) { + return ( +
+ ? +
+ ); + } + + return ( +
+ {/* White keys */} + {WHITE_KEYS.map((note, i) => { + const active = activeChroma.has(NOTE_CHROMA[note]); + return ( +
+ {active && ( +
+ )} +
+ ); + })} + + {/* Black keys (rendered on top) */} + {BLACK_KEY_NAMES.map((note) => { + const chroma = NOTE_CHROMA[note]; + const active = activeChroma.has(chroma); + return ( +
+ {active && ( +
+ )} +
+ ); + })} +
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add app/app/components/chord-diagram/piano-keys.tsx +git commit -m "feat: PianoKeys component" +``` + +--- + +## Task 5: GuitarFretboard component + +**Files:** +- Create: `app/app/components/chord-diagram/guitar-fretboard.tsx` + +- [ ] **Step 1: Create the component** + +Create `app/app/components/chord-diagram/guitar-fretboard.tsx`: + +```tsx +import type { GuitarVoicing } from '~/lib/chord-voicing'; + +const FRETS_SHOWN = 4; +const STRING_COUNT = 6; + +interface Props { + voicing: GuitarVoicing | null; +} + +export function GuitarFretboard({ voicing }: Props) { + if (!voicing) { + return ( +
+ no voicing +
+ ); + } + + const { frets, baseFret, barre } = voicing; + + // Show fret number label when not at open position + const showFretLabel = baseFret > 0; + + return ( +
+ {/* Open/muted string indicators above nut */} +
+ {frets.map((f, i) => ( +
+ {f === null ? ( + + ) : f === 0 || (baseFret === 0 && f === 0) ? ( + + ) : null} +
+ ))} +
+ + {/* Fretboard grid */} +
+ {/* Fret number label */} + {showFretLabel && ( +
+ {baseFret} +
+ )} + + {/* Strings (columns) */} + {frets.map((fret, stringIdx) => ( +
+ {/* Nut or top border */} +
+ {/* Fret cells */} + {Array.from({ length: FRETS_SHOWN }, (_, fretIdx) => { + // Open position (baseFret=0): rows represent frets 1-4 (nut is shown above) + // Barre position (baseFret>0): rows represent frets baseFret, baseFret+1, … + const absoluteFret = baseFret === 0 ? fretIdx + 1 : baseFret + fretIdx; + const hasDot = fret !== null && fret > 0 && fret === absoluteFret; + return ( +
+ {hasDot && ( +
+ )} +
+ ); + })} +
+ ))} + + {/* Barre indicator — only for actual barre chords (not open position) */} + {barre !== null && barre > 0 && ( +
+ )} +
+
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add app/app/components/chord-diagram/guitar-fretboard.tsx +git commit -m "feat: GuitarFretboard component" +``` + +--- + +## Task 6: ChordDiagram entry-point component + +**Files:** +- Create: `app/app/components/chord-diagram/chord-diagram.tsx` + +- [ ] **Step 1: Create the component** + +Create `app/app/components/chord-diagram/chord-diagram.tsx`: + +```tsx +import { getPianoNotes, getGuitarVoicing } from '~/lib/chord-voicing'; +import { PianoKeys } from './piano-keys'; +import { GuitarFretboard } from './guitar-fretboard'; + +export type Instrument = 'piano' | 'guitar'; + +interface Props { + chord: string; + instrument: Instrument; +} + +export function ChordDiagram({ chord, instrument }: Props) { + return ( +
+ {chord} + {instrument === 'piano' ? ( + + ) : ( + + )} +
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add app/app/components/chord-diagram/chord-diagram.tsx +git commit -m "feat: ChordDiagram component" +``` + +--- + +## Task 7: ChordGrid component + +**Files:** +- Create: `app/app/components/chord-diagram/chord-grid.tsx` + +`ChordGrid` receives `instrument` and `onInstrumentChange` from the parent (songs.$id.tsx) so the inline popup and the grid share the same instrument selection. + +- [ ] **Step 1: Create the component** + +Create `app/app/components/chord-diagram/chord-grid.tsx`: + +```tsx +import { ChordDiagram } from './chord-diagram'; +import type { Instrument } from './chord-diagram'; + +interface Props { + chords: string[]; + instrument: Instrument; + onInstrumentChange: (i: Instrument) => void; +} + +export function ChordGrid({ chords, instrument, onInstrumentChange }: Props) { + if (chords.length === 0) return null; + + return ( +
+ {/* Instrument toggle */} +
+ Chords +
+ + +
+
+ + {/* Chord cards */} +
+ {chords.map((chord) => ( +
+ +
+ ))} +
+
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add app/app/components/chord-diagram/chord-grid.tsx +git commit -m "feat: ChordGrid component" +``` + +--- + +## Task 8: Refactor ChordChart for tappable chords + +**Files:** +- Modify: `app/app/components/chord-chart.tsx` +- Modify: `app/app/lib/song-utils.ts` + +Replace the string-built chord row with positioned `` elements using `ch` units. Add `onChordClick` prop threaded through `ChordChart → SectionBlock → LineBlock`. + +Also add `extractUniqueChords` to song-utils. + +- [ ] **Step 1: Add extractUniqueChords to song-utils.ts** + +Replace the full contents of `app/app/lib/song-utils.ts` with: + +```ts +import type { Song, Section } from "./types"; + +export function previewChords(song: Song): string[] { + const seen = new Set(); + const result: string[] = []; + for (const section of song.sections) { + for (const line of section.lines) { + for (const cp of line.chords) { + if (!seen.has(cp.chord)) { + seen.add(cp.chord); + result.push(cp.chord); + } + } + } + if (result.length >= 5) break; + } + return result.slice(0, 5); +} + +/** All unique chord names in order of first appearance across all sections. */ +export function extractUniqueChords(sections: Section[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const section of sections) { + for (const line of section.lines) { + for (const cp of line.chords) { + if (!seen.has(cp.chord)) { + seen.add(cp.chord); + result.push(cp.chord); + } + } + } + } + return result; +} +``` + +- [ ] **Step 2: Refactor chord-chart.tsx** + +Replace the full content of `app/app/components/chord-chart.tsx`: + +```tsx +import type { LyricLine, Section } from "~/lib/types"; + +const MAX_WIDTH = 38; + +interface Props { + sections: Section[]; + fontSize?: 'sm' | 'base' | 'lg'; + onChordClick?: (chord: string) => void; +} + +/** Split one LyricLine into segments that each fit within maxWidth characters. */ +function segmentLine(line: LyricLine, maxWidth: number): LyricLine[] { + const { text, chords } = line; + if (text.length <= maxWidth) return [line]; + + const segments: LyricLine[] = []; + let start = 0; + + while (start < text.length) { + let end = start + maxWidth; + if (end < text.length) { + const breakAt = text.lastIndexOf(" ", end); + if (breakAt > start) end = breakAt + 1; + } else { + end = text.length; + } + const segText = text.slice(start, end).trimEnd(); + const segChords = chords + .filter((cp) => cp.offset >= start && cp.offset < end) + .map((cp) => ({ ...cp, offset: cp.offset - start })); + segments.push({ text: segText, chords: segChords }); + start = end; + while (start < text.length && text[start] === " ") start++; + } + return segments; +} + +function ChordRow({ + chords, + sizeClass, + onChordClick, +}: { + chords: { offset: number; chord: string }[]; + sizeClass: string; + onChordClick?: (chord: string) => void; +}) { + return ( +
+ {chords.map(({ offset, chord }, i) => ( + onChordClick?.(chord)} + > + {chord} + + ))} +
+ ); +} + +function LineBlock({ + line, + sizeClass, + onChordClick, +}: { + line: LyricLine; + sizeClass: string; + onChordClick?: (chord: string) => void; +}) { + return ( +
+ {line.chords.length > 0 && ( + + )} + {line.text && ( +
+          {line.text}
+        
+ )} +
+ ); +} + +function SectionBlock({ + section, + sizeClass, + onChordClick, +}: { + section: Section; + sizeClass: string; + onChordClick?: (chord: string) => void; +}) { + return ( +
+ {section.label && ( +

[{section.label}]

+ )} + {section.lines.flatMap((line, i) => + segmentLine(line, MAX_WIDTH).map((seg, j) => ( + + )) + )} +
+ ); +} + +export function ChordChart({ sections, fontSize, onChordClick }: Props) { + const sizeClass = { sm: 'text-sm', base: 'text-base', lg: 'text-lg' }[fontSize ?? 'sm']; + return ( +
+ {sections.map((section, i) => ( + + ))} +
+ ); +} +``` + +- [ ] **Step 3: Verify typecheck passes** + +```bash +cd app && npm run typecheck +``` + +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add app/app/components/chord-chart.tsx app/app/lib/song-utils.ts +git commit -m "feat: tappable chord names in ChordChart, extractUniqueChords" +``` + +--- + +## Task 9: Wire up songs.$id.tsx + +**Files:** +- Modify: `app/app/routes/songs.$id.tsx` + +Add `uniqueChords`, `instrument` state, inline popup (mobile), two-column layout (desktop). + +- [ ] **Step 1: Add imports to songs.$id.tsx** + +At the top of `app/app/routes/songs.$id.tsx`, add these imports alongside the existing ones: + +```tsx +import { useRef, useCallback } from "react"; +import { ChordGrid } from "~/components/chord-diagram/chord-grid"; +import { ChordDiagram } from "~/components/chord-diagram/chord-diagram"; +import type { Instrument } from "~/components/chord-diagram/chord-diagram"; +import { extractUniqueChords } from "~/lib/song-utils"; +``` + +- [ ] **Step 2: Add instrument state + activeChord state** + +At **module level** in `songs.$id.tsx`, after the existing `initFontSize` function, add: + +```tsx +function initInstrument(): Instrument { + try { + const v = localStorage.getItem('chordDiagramInstrument'); + if (v === 'piano' || v === 'guitar') return v; + } catch { /* noop */ } + return 'piano'; +} +``` + +Then inside the `SongDetail` component, alongside the existing `const [offset, setOffset]` etc., add: + +```tsx +const [activeChord, setActiveChord] = useState(null); +const [instrument, setInstrument] = useState(initInstrument); +const scrollRef = useRef(null); +``` + +- [ ] **Step 3: Add instrument persist handler + scroll close effect** + +Still inside `SongDetail`, after `handleFontSizeChange`: + +```tsx +function handleInstrumentChange(i: Instrument) { + setInstrument(i); + try { localStorage.setItem('chordDiagramInstrument', i); } catch { /* noop */ } +} + +const handleScroll = useCallback(() => setActiveChord(null), []); +``` + +- [ ] **Step 4: Compute uniqueChords + add handleChordClick** + +After `const displayed = transposeSong(displayedSong, offset);`, add: + +```tsx +const uniqueChords = extractUniqueChords(displayed.sections); +const handleChordClick = (chord: string) => setActiveChord(chord); +``` + +- [ ] **Step 5: Replace the return JSX** + +Replace the entire `return (...)` block in `SongDetail` (the part starting with `
`) with: + +```tsx +return ( +
+ setEditOpen(true)} + onDelete={() => setDeleteOpen(true)} + fontSize={fontSize} + onFontSizeChange={handleFontSizeChange} + capo={baseSong.meta.capo ?? undefined} + applyCapo={applyCapo} + onToggleCapo={() => setApplyCapo((v) => !v)} + /> + + {/* Body: single column on mobile, two columns on desktop */} +
+ {/* Left / main column */} +
+
+ + + {/* Mobile bottom chord grid (hidden on desktop) */} +
+ +
+
+
+ + {/* Desktop side column (hidden on mobile) */} +
+ +
+
+ + {/* Mobile inline popup — fixed bottom, dismissed on scroll */} + {activeChord && ( +
+ + +
+ )} + + + +
+); +``` + +- [ ] **Step 6: Typecheck** + +```bash +cd app && npm run typecheck +``` + +Expected: no errors. + +- [ ] **Step 7: Smoke test in browser** + +```bash +cd app && npm run dev +``` + +Open a song. Verify: +- Chord names are underlined on hover. +- Tapping a chord name opens the fixed-bottom popup with the piano diagram. +- Scrolling the lyrics dismisses the popup. +- Piano/Guitar toggle switches all diagrams. +- On a wide window (≥ 1024px), the side column appears with all chord cards. +- Mobile bottom grid appears below all lyrics. + +- [ ] **Step 8: Final commit** + +```bash +git add app/app/routes/songs.$id.tsx +git commit -m "feat: chord diagram — piano/guitar diagrams in song detail" +```