Files
pocket-chords/docs/superpowers/plans/2026-04-09-chord-diagram.md

1108 lines
31 KiB
Markdown

# 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('<example>').type` in tonal,
* then define the fingering at root E or A and add it here.
*/
export const GUITAR_VOICINGS: Record<string, GuitarVoicingTemplate> = {
// ── 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<string, number> = {
'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<string, number> = {
'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 (
<div
className="flex items-center justify-center text-muted-foreground text-xs"
style={{ width: totalWidth, height: WHITE_KEY_H }}
>
?
</div>
);
}
return (
<div className="relative inline-block" style={{ width: totalWidth, height: WHITE_KEY_H }}>
{/* White keys */}
{WHITE_KEYS.map((note, i) => {
const active = activeChroma.has(NOTE_CHROMA[note]);
return (
<div
key={note}
className="absolute border border-border rounded-b-sm"
style={{
left: i * WHITE_KEY_W,
top: 0,
width: WHITE_KEY_W - 1,
height: WHITE_KEY_H,
background: active ? 'hsl(var(--primary) / 0.15)' : 'white',
}}
>
{active && (
<div
className="absolute rounded-full bg-primary"
style={{
width: 8,
height: 8,
bottom: 6,
left: '50%',
transform: 'translateX(-50%)',
}}
/>
)}
</div>
);
})}
{/* Black keys (rendered on top) */}
{BLACK_KEY_NAMES.map((note) => {
const chroma = NOTE_CHROMA[note];
const active = activeChroma.has(chroma);
return (
<div
key={note}
className="absolute z-10 rounded-b-sm"
style={{
left: BLACK_KEY_LEFT[note],
top: 0,
width: BLACK_KEY_W,
height: BLACK_KEY_H,
background: active ? 'hsl(var(--primary))' : '#1a1a1a',
}}
>
{active && (
<div
className="absolute rounded-full bg-primary-foreground"
style={{
width: 6,
height: 6,
bottom: 4,
left: '50%',
transform: 'translateX(-50%)',
}}
/>
)}
</div>
);
})}
</div>
);
}
```
- [ ] **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 (
<div className="flex items-center justify-center text-muted-foreground text-xs h-20 w-24">
no voicing
</div>
);
}
const { frets, baseFret, barre } = voicing;
// Show fret number label when not at open position
const showFretLabel = baseFret > 0;
return (
<div className="flex flex-col items-center gap-1">
{/* Open/muted string indicators above nut */}
<div className="flex" style={{ gap: 2 }}>
{frets.map((f, i) => (
<div
key={i}
className="flex items-center justify-center text-xs font-mono"
style={{ width: 14 }}
>
{f === null ? (
<span className="text-destructive"></span>
) : f === 0 || (baseFret === 0 && f === 0) ? (
<span className="text-muted-foreground"></span>
) : null}
</div>
))}
</div>
{/* Fretboard grid */}
<div className="relative flex" style={{ gap: 0 }}>
{/* Fret number label */}
{showFretLabel && (
<div
className="absolute text-xs text-muted-foreground font-mono"
style={{ left: -18, top: 0, lineHeight: '18px' }}
>
{baseFret}
</div>
)}
{/* Strings (columns) */}
{frets.map((fret, stringIdx) => (
<div key={stringIdx} className="relative" style={{ width: 14 }}>
{/* Nut or top border */}
<div
style={{
height: showFretLabel ? 2 : 4,
background: showFretLabel ? 'hsl(var(--border))' : 'hsl(var(--foreground))',
margin: '0 2px',
}}
/>
{/* 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 (
<div
key={fretIdx}
className="flex items-center justify-center border-b border-border"
style={{ height: 18, borderLeft: stringIdx === 0 ? '1px solid hsl(var(--border))' : undefined, borderRight: '1px solid hsl(var(--border))' }}
>
{hasDot && (
<div className="w-3 h-3 rounded-full bg-primary z-10" />
)}
</div>
);
})}
</div>
))}
{/* Barre indicator — only for actual barre chords (not open position) */}
{barre !== null && barre > 0 && (
<div
className="absolute rounded-full bg-primary"
style={{
left: 2,
right: 2,
height: 8,
top: (showFretLabel ? 2 : 4) + (barre - baseFret) * 18 + 5,
zIndex: 5,
}}
/>
)}
</div>
</div>
);
}
```
- [ ] **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 (
<div className="flex flex-col items-center gap-2 p-2">
<span className="text-xs font-mono font-semibold text-primary">{chord}</span>
{instrument === 'piano' ? (
<PianoKeys notes={getPianoNotes(chord)} />
) : (
<GuitarFretboard voicing={getGuitarVoicing(chord)} />
)}
</div>
);
}
```
- [ ] **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 (
<div className="p-3">
{/* Instrument toggle */}
<div className="flex items-center gap-2 mb-3">
<span className="text-xs text-muted-foreground">Chords</span>
<div className="flex rounded-md border border-border overflow-hidden ml-auto">
<button
className={`px-2 py-1 text-xs transition-colors ${instrument === 'piano' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent'}`}
onClick={() => onInstrumentChange('piano')}
>
Piano
</button>
<button
className={`px-2 py-1 text-xs transition-colors ${instrument === 'guitar' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent'}`}
onClick={() => onInstrumentChange('guitar')}
>
Guitar
</button>
</div>
</div>
{/* Chord cards */}
<div className="flex flex-wrap gap-2">
{chords.map((chord) => (
<div
key={chord}
className="rounded-md border border-border bg-card"
>
<ChordDiagram chord={chord} instrument={instrument} />
</div>
))}
</div>
</div>
);
}
```
- [ ] **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 `<span>` 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<string>();
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<string>();
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 (
<div className={`relative font-mono ${sizeClass} text-primary`} style={{ height: '1.5em' }}>
{chords.map(({ offset, chord }, i) => (
<span
key={i}
className="absolute cursor-pointer hover:underline"
style={{ left: `${offset}ch` }}
onClick={() => onChordClick?.(chord)}
>
{chord}
</span>
))}
</div>
);
}
function LineBlock({
line,
sizeClass,
onChordClick,
}: {
line: LyricLine;
sizeClass: string;
onChordClick?: (chord: string) => void;
}) {
return (
<div className="leading-tight">
{line.chords.length > 0 && (
<ChordRow chords={line.chords} sizeClass={sizeClass} onChordClick={onChordClick} />
)}
{line.text && (
<pre className={`text-foreground ${sizeClass} font-mono whitespace-pre`}>
{line.text}
</pre>
)}
</div>
);
}
function SectionBlock({
section,
sizeClass,
onChordClick,
}: {
section: Section;
sizeClass: string;
onChordClick?: (chord: string) => void;
}) {
return (
<div className="mb-6">
{section.label && (
<p className="text-xs text-muted-foreground mb-1">[{section.label}]</p>
)}
{section.lines.flatMap((line, i) =>
segmentLine(line, MAX_WIDTH).map((seg, j) => (
<LineBlock key={`${i}-${j}`} line={seg} sizeClass={sizeClass} onChordClick={onChordClick} />
))
)}
</div>
);
}
export function ChordChart({ sections, fontSize, onChordClick }: Props) {
const sizeClass = { sm: 'text-sm', base: 'text-base', lg: 'text-lg' }[fontSize ?? 'sm'];
return (
<div className="px-4 py-3">
{sections.map((section, i) => (
<SectionBlock key={i} section={section} sizeClass={sizeClass} onChordClick={onChordClick} />
))}
</div>
);
}
```
- [ ] **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<string | null>(null);
const [instrument, setInstrument] = useState<Instrument>(initInstrument);
const scrollRef = useRef<HTMLDivElement>(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 `<div className="flex flex-col h-full max-w-lg mx-auto">`) with:
```tsx
return (
<div className="flex flex-col h-full">
<TransposeBar
meta={baseSong.meta}
offset={offset}
onOffsetChange={handleOffsetChange}
onEdit={() => 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 */}
<div className="flex-1 overflow-hidden flex flex-col lg:flex-row">
{/* Left / main column */}
<div
className="flex-1 overflow-y-auto"
ref={scrollRef}
onScroll={handleScroll}
>
<div className="max-w-lg mx-auto lg:max-w-none">
<ChordChart
sections={displayed.sections}
fontSize={fontSize}
onChordClick={handleChordClick}
/>
{/* Mobile bottom chord grid (hidden on desktop) */}
<div className="lg:hidden border-t border-border">
<ChordGrid
chords={uniqueChords}
instrument={instrument}
onInstrumentChange={handleInstrumentChange}
/>
</div>
</div>
</div>
{/* Desktop side column (hidden on mobile) */}
<div className="hidden lg:block w-72 overflow-y-auto border-l border-border shrink-0">
<ChordGrid
chords={uniqueChords}
instrument={instrument}
onInstrumentChange={handleInstrumentChange}
/>
</div>
</div>
{/* Mobile inline popup — fixed bottom, dismissed on scroll */}
{activeChord && (
<div className="lg:hidden fixed bottom-0 left-0 right-0 z-50 border-t border-border bg-background shadow-lg p-3 flex items-center gap-3">
<ChordDiagram chord={activeChord} instrument={instrument} />
<button
className="ml-auto text-muted-foreground text-xs underline-offset-4 hover:underline"
onClick={() => setActiveChord(null)}
>
close
</button>
</div>
)}
<EditSongSheet
id={id}
meta={baseSong.meta}
open={editOpen}
onOpenChange={setEditOpen}
onUpdated={handleUpdated}
/>
<DeleteSongDialog
id={id}
title={baseSong.meta.title}
open={deleteOpen}
onOpenChange={setDeleteOpen}
/>
</div>
);
```
- [ ] **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"
```