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

31 KiB

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

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":

"test": "vitest run"
  • Step 3: Create vitest config

Create app/vitest.config.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
cd app && npm test

Expected: No test files found, exiting with code 0 (no tests yet — that's fine).

  • Step 5: Commit
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:

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
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:

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
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:

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
cd app && npm test

Expected: all 11 tests pass.

  • Step 5: Commit
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:

/** 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
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:

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
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:

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
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:

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
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:

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:

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
cd app && npm run typecheck

Expected: no errors.

  • Step 4: Commit
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:

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:

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:

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:

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:

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:

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
cd app && npm run typecheck

Expected: no errors.

  • Step 7: Smoke test in browser
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

git add app/app/routes/songs.$id.tsx
git commit -m "feat: chord diagram — piano/guitar diagrams in song detail"