1108 lines
31 KiB
Markdown
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"
|
|
```
|