# 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" ```