---
format: typebulb/v1
name: The Coltrainer
---

**code.tsx**

```tsx
import React, { useEffect, useMemo, useRef, useState } from "react"
import { createRoot } from "react-dom/client"

// A chord has two shapes, and this bulb draws both.
//
//   The trace — the chord's waveform. Drawn over exactly one period of the
//   chord, transposing it changes nothing at all: every Cmaj7 and every F♯maj7
//   makes the same picture. (The idea, and the faint-parts-under-a-bold-sum
//   treatment, are lifted from the 12-TET bulb's wave display.)
//
//   The wheel — the twelve pitch classes on a clock, the chord's tones joined
//   into a polygon. Transposing rotates the polygon and leaves the shape alone.
//   dim7 is a square, which is exactly why it answers to four different names.
//
// Both say the same true thing in different languages: what a chord *is* lives
// in its intervals, not its root. Which is what this game asks you to hear.
// ---------- the chords ----------
type PoolId = "triads" | "sevenths" | "colours" | "jazz" | "extended"
// `ivs` are the tones actually voiced, ascending from the root, in root position.
// `degs` are their scale degrees, so the reveal can spell a chord the way it is
// written (E♭ G B♭, never E♭ G A♯): the interval fixes the letter, the root the
// accidental. The jazz pool is voiced the way a pianist plays it — root, the shell
// (3rd and 7th), extensions on top — with the 5th dropped from 9ths, 11ths and
// 13ths unless it is altered, where it is the point.
type Quality = { id: string; name: string; sym: string; ivs: number[]; degs: number[]; pool: PoolId }

const QUALITIES: Quality[] = [
  { id: "maj",  name: "Major",               sym: "",     ivs: [0, 4, 7],     degs: [1, 3, 5],    pool: "triads" },
  { id: "min",  name: "Minor",               sym: "m",    ivs: [0, 3, 7],     degs: [1, 3, 5],    pool: "triads" },
  { id: "dim",  name: "Diminished",          sym: "dim",  ivs: [0, 3, 6],     degs: [1, 3, 5],    pool: "triads" },
  { id: "aug",  name: "Augmented",           sym: "aug",  ivs: [0, 4, 8],     degs: [1, 3, 5],    pool: "triads" },
  { id: "maj7", name: "Major 7th",           sym: "maj7", ivs: [0, 4, 7, 11], degs: [1, 3, 5, 7], pool: "sevenths" },
  { id: "dom7", name: "Dominant 7th",        sym: "7",    ivs: [0, 4, 7, 10], degs: [1, 3, 5, 7], pool: "sevenths" },
  { id: "min7", name: "Minor 7th",           sym: "m7",   ivs: [0, 3, 7, 10], degs: [1, 3, 5, 7], pool: "sevenths" },
  { id: "m7b5", name: "Half-diminished 7th", sym: "m7♭5", ivs: [0, 3, 6, 10], degs: [1, 3, 5, 7], pool: "sevenths" },
  { id: "dim7", name: "Diminished 7th",      sym: "dim7", ivs: [0, 3, 6, 9],  degs: [1, 3, 5, 7], pool: "sevenths" },
  { id: "sus2", name: "Sus2",                sym: "sus2", ivs: [0, 2, 7],     degs: [1, 2, 5],    pool: "colours" },
  { id: "sus4", name: "Sus4",                sym: "sus4", ivs: [0, 5, 7],     degs: [1, 4, 5],    pool: "colours" },
  { id: "maj6", name: "Major 6th",           sym: "6",    ivs: [0, 4, 7, 9],  degs: [1, 3, 5, 6], pool: "colours" },
  { id: "min6", name: "Minor 6th",           sym: "m6",   ivs: [0, 3, 7, 9],  degs: [1, 3, 5, 6], pool: "colours" },
  { id: "add9", name: "Add 9",               sym: "add9", ivs: [0, 4, 7, 14], degs: [1, 3, 5, 9], pool: "colours" },
  { id: "add4", name: "Add 4",               sym: "add4", ivs: [0, 4, 5, 7],  degs: [1, 3, 4, 5], pool: "colours" },
  { id: "minadd9", name: "Minor add 9",      sym: "m(add9)", ivs: [0, 3, 7, 14], degs: [1, 3, 5, 9], pool: "colours" },
  { id: "minadd11", name: "Minor add 11",    sym: "m(add11)", ivs: [0, 3, 7, 17], degs: [1, 3, 5, 11], pool: "colours" },
  { id: "adds11", name: "Add ♯11",           sym: "add♯11", ivs: [0, 4, 7, 18], degs: [1, 3, 5, 11], pool: "colours" },
  { id: "dom7sus2", name: "7sus2",           sym: "7sus2", ivs: [0, 2, 7, 10], degs: [1, 2, 5, 7], pool: "colours" },
  { id: "maj9",   name: "Major 9th",          sym: "maj9",    ivs: [0, 4, 11, 14],     degs: [1, 3, 7, 9],     pool: "jazz" },
  { id: "dom9",   name: "Dominant 9th",       sym: "9",       ivs: [0, 4, 10, 14],     degs: [1, 3, 7, 9],     pool: "jazz" },
  { id: "min9",   name: "Minor 9th",          sym: "m9",      ivs: [0, 3, 10, 14],     degs: [1, 3, 7, 9],     pool: "jazz" },
  { id: "dom7b9", name: "Dominant 7th ♭9",    sym: "7♭9",     ivs: [0, 4, 10, 13],     degs: [1, 3, 7, 9],     pool: "jazz" },
  { id: "dom7s9", name: "Dominant 7th ♯9",    sym: "7♯9",     ivs: [0, 4, 10, 15],     degs: [1, 3, 7, 9],     pool: "jazz" },
  { id: "dom13",  name: "Dominant 13th",      sym: "13",      ivs: [0, 4, 10, 14, 21], degs: [1, 3, 7, 9, 13], pool: "extended" },
  // The ♯11 chords keep their natural fifth. Dropped, the ♯11 simply *is* the
  // flat five, and 7♯11 and 7♭5 become one chord under two names.
  { id: "maj7s11", name: "Major 7th ♯11",     sym: "maj7♯11", ivs: [0, 4, 7, 11, 18],  degs: [1, 3, 5, 7, 11], pool: "extended" },
  { id: "dom7s11", name: "Dominant 7th ♯11",  sym: "7♯11",    ivs: [0, 4, 7, 10, 18],  degs: [1, 3, 5, 7, 11], pool: "extended" },
  { id: "dom7b5",  name: "Dominant 7th ♭5",   sym: "7♭5",     ivs: [0, 4, 6, 10],      degs: [1, 3, 5, 7],     pool: "jazz" },
  { id: "dom7s5", name: "Dominant 7th ♯5",    sym: "7♯5",     ivs: [0, 4, 8, 10],      degs: [1, 3, 5, 7],     pool: "jazz" },
  { id: "alt",    name: "Altered dominant",   sym: "7alt",    ivs: [0, 4, 10, 15, 20], degs: [1, 3, 7, 9, 13], pool: "extended" },
  { id: "mmaj7",  name: "Minor-major 7th",    sym: "m(maj7)", ivs: [0, 3, 7, 11],      degs: [1, 3, 5, 7],     pool: "jazz" },
  { id: "sus9",   name: "9sus4",              sym: "9sus4",   ivs: [0, 5, 10, 14],     degs: [1, 4, 7, 9],     pool: "jazz" },
  { id: "maj7s5", name: "Major 7th ♯5",       sym: "maj7♯5",  ivs: [0, 4, 8, 11],      degs: [1, 3, 5, 7],     pool: "jazz" },
  { id: "sus7",   name: "7sus4",              sym: "7sus4",   ivs: [0, 5, 7, 10],      degs: [1, 4, 5, 7],     pool: "jazz" },
  { id: "sus7b9", name: "7sus4 ♭9",           sym: "7sus♭9",  ivs: [0, 5, 10, 13],     degs: [1, 4, 7, 9],     pool: "jazz" },
  { id: "dimmaj7", name: "Diminished major 7th", sym: "dim(maj7)", ivs: [0, 3, 6, 11], degs: [1, 3, 5, 7],     pool: "jazz" },
  { id: "maj7b5", name: "Major 7th ♭5",       sym: "maj7♭5",  ivs: [0, 4, 6, 11],      degs: [1, 3, 5, 7],     pool: "jazz" },
  { id: "maj7sus4", name: "maj7sus4",         sym: "maj7sus4", ivs: [0, 5, 7, 11],     degs: [1, 4, 5, 7],     pool: "jazz" },
  { id: "m7b9",   name: "Minor 7th ♭9",       sym: "m7♭9",    ivs: [0, 3, 10, 13],     degs: [1, 3, 7, 9],     pool: "jazz" },

  // Five notes and up — which is the whole line between the hard pool and this
  // one. A round's four options are near relatives, so the more tones a chord
  // has the finer the distinction: these are chords a single altered tone apart.
  { id: "min11",     name: "Minor 11th",         sym: "m11",     ivs: [0, 3, 10, 14, 17], degs: [1, 3, 7, 9, 11], pool: "extended" },
  { id: "six9",      name: "6/9",                sym: "6/9",     ivs: [0, 4, 7, 9, 14],   degs: [1, 3, 5, 6, 9],  pool: "extended" },
  { id: "mmaj9",     name: "Minor-major 9th",    sym: "m(maj9)", ivs: [0, 3, 7, 11, 14],  degs: [1, 3, 5, 7, 9],  pool: "extended" },
  { id: "dom11",     name: "Dominant 11th",      sym: "11",      ivs: [0, 7, 10, 14, 17], degs: [1, 5, 7, 9, 11], pool: "extended" },
  { id: "maj9s5",    name: "Major 9th ♯5",       sym: "maj9♯5",  ivs: [0, 4, 8, 11, 14],  degs: [1, 3, 5, 7, 9],  pool: "extended" },
  { id: "m11b5",     name: "Half-diminished 11th", sym: "m11♭5", ivs: [0, 3, 6, 10, 17],  degs: [1, 3, 5, 7, 11], pool: "extended" },
  { id: "mmaj11",    name: "Minor-major 11th",   sym: "m(maj11)", ivs: [0, 3, 11, 14, 17], degs: [1, 3, 7, 9, 11], pool: "extended" },
  { id: "m7s11",     name: "Minor 7th ♯11",      sym: "m7♯11",   ivs: [0, 3, 7, 10, 18],  degs: [1, 3, 5, 7, 11], pool: "extended" },
  { id: "maj13",     name: "Major 13th",         sym: "maj13",   ivs: [0, 4, 11, 14, 21], degs: [1, 3, 7, 9, 13], pool: "extended" },
  { id: "min13",     name: "Minor 13th",         sym: "m13",     ivs: [0, 3, 10, 14, 21], degs: [1, 3, 7, 9, 13], pool: "extended" },
  { id: "dom13b9",   name: "Dominant 13th ♭9",   sym: "13♭9",    ivs: [0, 4, 10, 13, 21], degs: [1, 3, 7, 9, 13], pool: "extended" },
  { id: "dom7b13",   name: "Dominant 7th ♭13",   sym: "7♭13",    ivs: [0, 4, 7, 10, 20],  degs: [1, 3, 5, 7, 13], pool: "extended" },
  { id: "dom7b9b13", name: "Dominant 7th ♭9♭13", sym: "7♭9♭13",  ivs: [0, 4, 10, 13, 20], degs: [1, 3, 7, 9, 13], pool: "extended" },
  { id: "min69",     name: "Minor 6/9",          sym: "m6/9",    ivs: [0, 3, 7, 9, 14],   degs: [1, 3, 5, 6, 9],  pool: "extended" },
  { id: "m9b5",      name: "Half-diminished 9th", sym: "m9♭5",   ivs: [0, 3, 6, 10, 14],  degs: [1, 3, 5, 7, 9],  pool: "extended" },
  { id: "dom13s9",   name: "Dominant 13th ♯9",   sym: "13♯9",    ivs: [0, 4, 10, 15, 21], degs: [1, 3, 7, 9, 13], pool: "extended" },
  { id: "dom13s11",  name: "Dominant 13th ♯11",  sym: "13♯11",   ivs: [0, 4, 10, 14, 18, 21], degs: [1, 3, 7, 9, 11, 13], pool: "extended" },
  { id: "maj9s11",   name: "Major 9th ♯11",      sym: "maj9♯11", ivs: [0, 4, 7, 11, 14, 18],  degs: [1, 3, 5, 7, 9, 11],  pool: "extended" },
  { id: "dom9s5",    name: "Dominant 9th ♯5",    sym: "9♯5",     ivs: [0, 4, 8, 10, 14],  degs: [1, 3, 5, 7, 9],  pool: "extended" },
  { id: "dom9b5",    name: "Dominant 9th ♭5",    sym: "9♭5",     ivs: [0, 4, 6, 10, 14],  degs: [1, 3, 5, 7, 9],  pool: "extended" },
  { id: "sus13",     name: "13sus4",             sym: "13sus4",  ivs: [0, 5, 10, 14, 21], degs: [1, 4, 7, 9, 13], pool: "extended" },
  { id: "maj7b9",    name: "Major 7th ♭9",       sym: "maj7♭9",  ivs: [0, 4, 7, 11, 13],  degs: [1, 3, 5, 7, 9],  pool: "extended" },
  // Six at a time. The ♯11 rule from above still holds, except where the chord
  // already names its thirteenth: 13♯11 and maj13♯11 drop the fifth like the
  // other thirteenths, because nothing is left for the ♯11 to be mistaken for.
  { id: "dom9s11",   name: "Dominant 9th ♯11",   sym: "9♯11",    ivs: [0, 4, 7, 10, 14, 18], degs: [1, 3, 5, 7, 9, 11],  pool: "extended" },
  { id: "dom7b9s11", name: "Dominant 7th ♭9♯11", sym: "7♭9♯11",  ivs: [0, 4, 7, 10, 13, 18], degs: [1, 3, 5, 7, 9, 11],  pool: "extended" },
  { id: "dom7s9s11", name: "Dominant 7th ♯9♯11", sym: "7♯9♯11",  ivs: [0, 4, 7, 10, 15, 18], degs: [1, 3, 5, 7, 9, 11],  pool: "extended" },
  { id: "maj13s11",  name: "Major 13th ♯11",     sym: "maj13♯11", ivs: [0, 4, 11, 14, 18, 21], degs: [1, 3, 7, 9, 11, 13], pool: "extended" },
  { id: "six9s11",   name: "6/9 ♯11",            sym: "6/9♯11",  ivs: [0, 4, 7, 9, 14, 18],  degs: [1, 3, 5, 6, 9, 11],  pool: "extended" },
  { id: "min69s11",  name: "Minor 6/9 ♯11",      sym: "m6/9♯11", ivs: [0, 3, 7, 9, 14, 18],  degs: [1, 3, 5, 6, 9, 11],  pool: "extended" },
]

// A quality's `pool` is the family it belongs to; a PoolSetting is how hard a
// game you asked for. Keeping the two apart lets the ladder be regrouped without
// anyone touching the chord table.
type PoolSetting = "basic" | "medium" | "hard" | "coltrane" | "all"
const POOLS: { id: PoolSetting; label: string; has: (q: Quality) => boolean }[] = [
  { id: "basic",    label: "Basic",      has: q => q.pool === "triads" },
  { id: "medium",   label: "Med",        has: q => q.pool === "sevenths" || q.pool === "colours" },
  { id: "hard",     label: "Hard",       has: q => q.pool === "jazz" },
  { id: "coltrane", label: "Coltrane",   has: q => q.pool === "extended" },
  { id: "all",      label: "All",        has: () => true },
]
const poolOf = (id: PoolSetting) => QUALITIES.filter(POOLS.find(p => p.id === id)!.has)
const symOf = (q: Quality) => q.sym || "maj"

type Settings = { pool: PoolSetting; inversions: boolean; clock: number }
/* Opening on Basic. A first chord you can name is what makes someone play a
   second one, and being too easy is the cheap mistake here: the opening line
   asks how good your ear is and points at the control, so anyone who finds
   triads beneath them is one tap from saying so. Arriving on a pool you cannot
   touch has no equivalent way out, because you learn nothing from it. */
const DEFAULTS: Settings = { pool: "basic", inversions: false, clock: 30 }
// Seconds, not fast/medium/slow: the difficulty ladder is already a
// basic/medium/hard scale, and a second one beside it reads as another
// difficulty rather than as a clock.
const SPEEDS = [{ v: 5, label: "5s" }, { v: 10, label: "10s" }, { v: 30, label: "30s" }]
const VOICINGS = [{ v: false, label: "Root position" }, { v: true, label: "With inversions" }]
const ROUNDS = 10
/* The run, as the 0..1 the stylesheet wants. Capped at a full set, so one set
   spends the whole range and no more. Set once, on the board's root, and read by
   inheritance from there, so there is one place it comes from and nothing below
   may redeclare it.

   What it buys is the SIZE OF THE LEAP, and this took three tries to get right.
   Twice it was spent on a standing level instead: a wider glow, then a taller
   flame, both of which measured correctly and neither of which could be seen.
   A standing level fails here for a reason particular to this candle. It moves.
   The wax burns down thirty or forty pixels across a round while the run adds
   one, so the signal sits under a confound twenty times its size on the same
   object, and there is nothing beside it holding still to measure against. By
   the time the flame is visibly taller you have forgotten what it looked like
   ten chords ago.

   A leap has neither problem. It is compared against the last leap, seconds old,
   in the same place, and an eye is good at that. So the run multiplies the
   flare. And it has to be multiplied HARD. Three passes were spent on changes
   that measured correctly and could not be seen, because a difference of 20 or
   30 per cent on a 20px shape that appears for 150ms is not a difference anyone
   notices. So: a cold right answer lifts the flame by about a third, a long run
   nearly triples it, and past a couple in a row the leap throws embers, which no
   amount of squinting is needed to tell apart from one that does not. */
const runOf = (streak: number) => clamp(streak, 0, ROUNDS) / ROUNDS

// ---------- names ----------
// Chord-chart spellings for the twelve roots: D♭ rather than C♯, F♯ rather than G♭
// — unless the root's other name spells this chord with fewer accidentals (D♭m9 is
// D♭ F♭ C♭ E♭; C♯m9 is C♯ E B D♯), or the chart name forces a double accidental
// (D♭dim7 wants C♭♭). Ties keep the chart name.
const ROOT_NAMES = ["C", "D♭", "D", "E♭", "E", "F", "F♯", "G", "A♭", "A", "B♭", "B"]
const ALT_NAMES: Record<number, string> = { 1: "C♯", 3: "D♯", 6: "G♭", 8: "G♯", 10: "A♯" }
const SHARP_NAMES = ["C", "C♯", "D", "D♯", "E", "F", "F♯", "G", "G♯", "A", "A♯", "B"]
const FLAT_NAMES = ["C", "D♭", "D", "E♭", "E", "F", "G♭", "G", "A♭", "A", "B♭", "B"]
const LETTERS = "CDEFGAB"
const NAT = [0, 2, 4, 5, 7, 9, 11]

const rand = (n: number) => Math.floor(Math.random() * n)
const pick = <T,>(xs: T[]) => xs[rand(xs.length)]
const shuffle = <T,>(xs: T[]) => {
  const a = xs.slice()
  for (let i = a.length - 1; i > 0; i--) { const j = rand(i + 1); [a[i], a[j]] = [a[j], a[i]] }
  return a
}
const lower = (s: string) => s[0].toLowerCase() + s.slice(1)
const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi)
const clamp01 = (v: number) => clamp(v, 0, 1)
// A note, an interval or a degree, reduced to the twelve
const pcOf = (n: number) => ((n % 12) + 12) % 12
const pcsOf = (ns: number[]) => [...new Set(ns.map(pcOf))]
const PCS = Array.from({ length: 12 }, (_, i) => i)

type Chord = { rootPc: number; rootName: string; rootMidi: number; q: Quality; inv: number; notes: number[] }

// The keyboard runs C3–B5: roots sit in C3–B3, and no chord in the table, in any
// inversion, climbs past A♭5.
const KB_LO = 48, KB_HI = 83
const LOW_ROOT = 48

// Inversion k: the lowest k tones of the chord's own octave go up one. Extensions
// (the 9th, 11th, 13th — anything already above the octave) stay on top, where
// they belong, so the bass is always the root, 3rd, 5th, 6th or 7th, and only the
// tones under the octave count towards how many inversions a quality has.
const invBase = (q: Quality) => q.ivs.filter(iv => iv < 12).length
const chordOf = (q: Quality, rootMidi: number, inv: number): Chord => ({
  rootPc: pcOf(rootMidi), rootName: spellRoot(pcOf(rootMidi), q), rootMidi, q, inv,
  notes: q.ivs.map((iv, i) => rootMidi + iv + (i < inv ? 12 : 0)).sort((a, b) => a - b),
})

function makeChord(s: Settings, prev: Chord | null): Chord {
  const pool = poolOf(s.pool)
  let q = pick(pool)
  if (prev && pool.length > 1 && q.id === prev.q.id) q = pick(pool.filter(x => x.id !== prev.q.id))
  return chordOf(q, LOW_ROOT + rand(12), s.inversions ? rand(invBase(q)) : 0)
}

const maskOf = (ns: number[]) => ns.reduce((m, n) => m | (1 << pcOf(n)), 0)
const pcMask = (root: number, ivs: number[]) => maskOf(ivs.map(iv => root + iv))
// What an answer would have sounded like: the option's kind on the heard chord's
// root, in the same register and the same inversion, so the comparison isolates
// the one thing that differs. The right answer is the chord itself, voicing and
// all.
// The whole chord, not just its notes: auditioning an answer has to be able to
// name and spell what it is showing, and the spelling depends on the kind (the
// root of a m(maj7) is spelled under different rules than the root of a 13♭9).
const optionChord = (q: Quality, c: Chord): Chord =>
  q.id === c.q.id ? c : chordOf(q, c.rootMidi, Math.min(c.inv, invBase(q) - 1))
const optionNotes = (q: Quality, c: Chord) => optionChord(q, c).notes
const popcount = (m: number) => { let c = 0; while (m) { m &= m - 1; c++ } return c }
// how much of one chord is the other — shared pitch classes over the union, both on
// the same root. A 9 and a 13 score 4/5; a major and an altered dominant 2/6.
const similarity = (a: Quality, b: Quality) => {
  const x = pcMask(0, a.ivs), y = pcMask(0, b.ivs)
  return popcount(x & y) / popcount(x | y)
}
// The other qualities in the pool, nearest first, with enough jitter that the same
// three neighbours don't turn up every time. This is what makes four choices hard:
// a 7♭9 is asked against 7♯9, 7alt and 9 — not against major, minor and sus2.
const neighbours = (c: Chord, pool: Quality[]) =>
  pool.filter(q => q.id !== c.q.id)
    .map(q => ({ q, s: similarity(q, c.q) + Math.random() * 0.3 }))
    .sort((a, b) => b.s - a.s).map(x => x.q)

// Is this option the same pitch classes as the chord under another name? C6 is
// Am7 over C; Csus2 is Gsus4 over C; and the symmetric kinds are one chord with
// several names — Cdim7 = E♭dim7 = G♭dim7 = Adim7, Caug = Eaug = A♭aug,
// C7♯11 = G♭7♯11.
//
// Checked in every voicing, not only under inversions. The rule used to let these
// through in root position on the grounds that the bass settles it, but an option
// reading "E♭ diminished 7th" does not say "in root position", so on the board it
// reads as a second right answer — and by ear the root is not even asked, which
// makes a transposed twin the same answer under a different name.
// In root position this cannot happen and the guard is off. An option names a
// kind and nothing else, and the bass you hear is the root, so a kind is right
// exactly when its intervals above that bass are the ones you heard — and no two
// kinds in a pool share those. Cm7 and E♭6 are the same five pitch classes, but
// the one with C underneath is Cm7 and only Cm7.
//
// Turn inversions on and the bass stops being the root, so the chord really can
// be read from another root under another name, and then the twin has to go.
// (This used to run in every voicing, from back when an option could name a root
// as well as a kind — "C dim7" against "E♭ dim7" is genuinely two names for one
// set of notes. Without that mode there is nothing left for it to catch.)
function clashes(q: Quality, c: Chord, s: Settings): boolean {
  if (!s.inversions) return false
  const m = maskOf(c.notes)
  return PCS.some(r => pcMask(r, q.ivs) === m)
}

// An option names a kind, never a root: the root is never asked, so it is never
// offered. Which also means two options can only ever differ by interval.
function makeOptions(c: Chord, s: Settings): { options: Quality[]; answer: number } {
  const out = [c.q]
  // Options are compared as pitch-class sets, not as names: two kinds spelling
  // one set of notes would be one answer under two labels. The answer's own set
  // seeds this, so nothing can shadow it.
  const sets = new Set([pcMask(0, c.q.ivs)])
  // nearest kinds first, so the four choices are close relatives
  for (const q of neighbours(c, poolOf(s.pool))) {
    if (out.length >= 4) break
    const set = pcMask(0, q.ivs)
    if (sets.has(set) || clashes(q, c, s)) continue
    sets.add(set); out.push(q)
  }
  const options = shuffle(out)
  return { options, answer: options.indexOf(c.q) }
}

// Spell the tones by degree from a given root name, and price the result:
// accidentals cost 1, the ones that land on a white key (F♭, C♭, E♯, B♯) cost 2,
// being the ones a reader stumbles on. A tone that would need a double accidental
// gets a plain pitch-class name instead (E7♯9's F𝄪 is written G by everyone) at a
// cost of 3, so a spelling that manages without always wins.
function spellWith(rn: string, q: Quality): { names: string[]; cost: number } {
  const li = LETTERS.indexOf(rn[0])
  const racc = rn.length > 1 ? (rn[1] === "♯" ? 1 : -1) : 0
  const names: string[] = []
  let cost = 0
  for (let i = 0; i < q.ivs.length; i++) {
    const L = (li + q.degs[i] - 1) % 7
    const want = (NAT[li] + racc + q.ivs[i]) % 12
    const acc = ((((want - NAT[L]) % 12) + 18) % 12) - 6
    if (Math.abs(acc) > 1) { names.push((acc > 0 ? SHARP_NAMES : FLAT_NAMES)[want]); cost += 3 }
    else { names.push(LETTERS[L] + (acc === 1 ? "♯" : acc === -1 ? "♭" : "")); cost += Math.abs(acc) * (NAT.includes(want) ? 2 : 1) }
  }
  return { names, cost }
}
// The root name this quality spells best under: the cheaper of the chart name and
// its enharmonic twin, the chart name on a tie.
function spellRoot(pc: number, q: Quality): string {
  const alt = ALT_NAMES[pc]
  if (!alt) return ROOT_NAMES[pc]
  return spellWith(alt, q).cost < spellWith(ROOT_NAMES[pc], q).cost ? alt : ROOT_NAMES[pc]
}
const spell = (c: Chord): string[] => spellWith(c.rootName, c.q).names
function nameToPc(n: string) {
  let pc = NAT[LETTERS.indexOf(n[0])]
  for (const ch of n.slice(1)) pc += ch === "♯" ? 1 : ch === "♭" ? -1 : 0
  return pcOf(pc)
}
// "A♭m7/C♭" — slash bass when an inversion puts another tone underneath
function symbolOf(c: Chord): string {
  const names = spell(c)
  const bassPc = c.notes[0] % 12
  const bi = c.q.ivs.findIndex(iv => (c.rootPc + iv) % 12 === bassPc)
  const slash = bassPc !== c.rootPc && bi >= 0 ? "/" + names[bi] : ""
  return c.rootName + c.q.sym + slash
}
// ---------- sound ----------
let ac: AudioContext | null = null
let bus: GainNode | null = null
function audio(): AudioContext | null {
  try {
    if (!ac) {
      const AC = (window as any).AudioContext || (window as any).webkitAudioContext
      if (!AC) return null
      ac = new AC() as AudioContext
      bus = ac.createGain(); bus.gain.value = 0.8
      const comp = ac.createDynamicsCompressor()
      comp.threshold.value = -18; comp.ratio.value = 4
      bus.connect(comp); comp.connect(ac.destination)
      keepAlive(ac)
      // A device that has gone back to sleep has to be woken again, so a context
      // that leaves `running` forfeits its warm badge.
      ac.addEventListener("statechange", () => { if (ac && ac.state !== "running") warm = false })
    }
    if (ac.state === "suspended") ac.resume().catch(() => { /* needs a gesture yet */ })
    return ac
  } catch { return null }
}
const hz = (m: number) => 440 * Math.pow(2, (m - 69) / 12)
// An electric-piano-ish tone: a few sine partials, the upper ones dying first, so
// the attack has some bite and the tail is nearly pure.
const PARTIALS: [number, number][] = [[1, 1], [2, 0.45], [3, 0.2], [4, 0.1], [5, 0.05]]
function voice(a: AudioContext, m: number, t0: number, vel: number, tail0 = 2.4) {
  for (const [k, amp] of PARTIALS) {
    const o = a.createOscillator(); o.type = "sine"; o.frequency.value = hz(m) * k
    const g = a.createGain()
    const peak = amp * vel
    const tail = tail0 / Math.sqrt(k)
    g.gain.setValueAtTime(0, t0)
    g.gain.linearRampToValueAtTime(peak, t0 + 0.008)
    g.gain.exponentialRampToValueAtTime(peak * 0.5, t0 + 0.35)
    g.gain.exponentialRampToValueAtTime(0.0005, t0 + tail)
    o.connect(g); g.connect(bus!); o.start(t0); o.stop(t0 + tail + 0.05)
  }
}
/* Getting the first sound of the session out intact.

   Two clocks matter and `currentTime` is the wrong one. `resume()` settling, and
   the context clock starting to move, both say the graph is being *rendered*;
   neither says the output device is *playing*. Opening that device costs anywhere
   from a few milliseconds to most of a second — Bluetooth, a sleeping DAC — and
   everything rendered into that gap is computed correctly and thrown away. That
   is a chord arriving with its attack already gone, which is what this used to do.

   `getOutputTimestamp().contextTime` is the one number that tells the truth: the
   position of the stream the hardware is actually consuming. It stands still while
   the device opens, so wait for it to move and only then schedule. Waiting for it
   to *move* rather than to be non-zero is what makes it work a second time: after
   a suspend it resumes from its old value, not from zero.

   That got the first chord out. It did not get the fourth. A device sleeps again
   the moment nothing is feeding it, and it does that silently: the context stays
   `running`, `statechange` never fires, and nothing in the graph says the DAC has
   powered down. So a badge earned at the first gesture was still being trusted a
   minute later, and the wait that had been so carefully measured was skipped at
   precisely the two clicks that follow a silence — Start, after reading the setup;
   Again, after reading the results. Mid-set the chords come close enough together
   to hold the device open, which is why it only ever went missing at those two.

   Two answers, in order. A keep-alive, so the stream never empties and there is no
   idle to fall into. And an expiry on the badge, so that if it sleeps anyway the
   next sound measures again instead of assuming: that costs a poll tick on a
   device that is up, and saves the attack on one that is not.

   The wait is paid once, and mostly before the click that wants the sound — see
   `unlock`. Everything after goes out at the usual few tens of milliseconds. */
let warm = false
const LEAD = 0.03           // scheduling headroom, once the device is up
/* And the headroom for a sound that follows a silence, where it may not be. This
   is the part that stopped being clever. Whether the DAC is open is not a thing
   the platform will tell you — `getOutputTimestamp` reports frames handed to the
   sink, which start moving well before the hardware plays them, so a probe built
   on it reports a 13ms warm-up on a device that takes half a second to wake. So
   don't ask: wait. A third of a second of pre-roll puts the device's opening
   window over silence instead of over the attack, and the keep-alive above is
   feeding it the whole time. It is paid only on a cold sound, and paid where a
   third of a second reads as the app answering rather than as lag — the click
   that starts a set, not the one that plays a key. */
const COLD = 0.35
const WARMUP = 1200         // ms to wait on a device that never reports itself
const IDLE = 1500           // ms of silence after which a warm badge is only a memory
// when a sound was last asked for — the gap between asks is what puts a device down
let lastAsk = -1e9
// what the warm-up cost, read back by the `audio` probe
const warmup = { ms: -1, live: false, rechecks: 0 }

/* Holding the device open. The cheapest way to stop a stream from being closed
   under you is to never stop feeding it, so a loop runs from the moment the graph
   exists and never ends. It carries noise rather than digital silence because a
   silent stream is the thing the sleepier stacks — Bluetooth above all — watch for.
   At -90 dBFS it sits under the floor of a 16-bit path, which is to say under
   anything anyone can hear, and it goes straight to the output: nothing this quiet
   has any business passing through the compressor the chords share. */
let alive: AudioBufferSourceNode | null = null
function keepAlive(a: AudioContext) {
  if (alive) return
  try {
    const n = Math.floor(a.sampleRate * 0.5)
    const b = a.createBuffer(1, n, a.sampleRate)
    const d = b.getChannelData(0)
    for (let i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * 3e-5
    const s = a.createBufferSource()
    s.buffer = b; s.loop = true
    s.connect(a.destination); s.start()
    alive = s
  } catch { /* no keep-alive; the expiry below still covers it */ }
}

/** Where the output stream has got to, as the hardware sees it — falling back to
    the render clock, which is all some browsers will tell us. */
function outPos(a: AudioContext): number {
  try {
    if (typeof a.getOutputTimestamp === "function") return a.getOutputTimestamp().contextTime ?? 0
  } catch { /* refused while suspended, on some builds */ }
  return a.currentTime
}
function whenReady(a: AudioContext, then: () => void) {
  if (warm) { then(); return }
  const t0 = performance.now(), mark = outPos(a)
  const poll = () => {
    const running = a.state === "running"
    const live = running && outPos(a) > mark
    if (!live && performance.now() - t0 < WARMUP) { setTimeout(poll, 12); return }
    // Warm even on a timeout, so long as the context was actually up: a browser
    // that never reports its stream position is one there is no point
    // interrogating before every note. A context still waiting on a gesture is
    // the other case — it has not had its chance yet, so leave it cold and let
    // the next thing that wants a sound ask again.
    if (running) {
      warm = true
      warmup.live = live
      warmup.ms = Math.round(performance.now() - t0)
    }
    then()
  }
  poll()
}
function schedule(play: (a: AudioContext, t0: number) => void, preroll = false) {
  const a = audio(); if (!a || !bus) return
  // A badge earned before a long silence is not evidence about now.
  const now = performance.now()
  const cold = now - lastAsk > IDLE
  if (cold && warm) { warm = false; warmup.rechecks++ }
  lastAsk = now
  const lead = cold && preroll ? COLD : LEAD
  const go = () => whenReady(a, () => play(a, a.currentTime + lead))
  if (a.state === "running") go()
  else a.resume().then(go, go)
}
/* The device can only be opened by a gesture, so open it on the *earliest* one:
   the pointer going down, which leads the click that asks for a chord by the
   length of the press — and usually by a whole screenful of reading the setup.
   By the time anything wants to be heard the warm-up is normally over. */
function unlock() {
  const a = audio(); if (!a) return
  // Old iOS wants a source actually started inside the gesture, not just a resume
  try {
    const s = a.createBufferSource()
    s.buffer = a.createBuffer(1, 1, a.sampleRate); s.connect(a.destination); s.start()
  } catch { /* nothing to salvage */ }
  lastAsk = performance.now()
  whenReady(a, () => { })
}

/* A clock probe used to sit here, sampling the render clock against
   `getOutputTimestamp` for 900ms after every chord and logging a verdict, to
   settle whether a swallowed attack was the device opening under the note or
   the renderer running late. It answered: the device, which is what the pre-roll
   and the keep-alive above are for. It then reported `clean` on every chord for
   the rest of its life, so it has been taken out. Its cost was a 16ms interval
   and a log line per chord, and its findings are written up where the fixes are.
   If the attack ever goes missing again, that is the shape of the probe to
   rebuild: the two failures are indistinguishable from the speaker and want
   opposite fixes, so guessing does not converge. */
function playChord(notes: number[], then?: () => void) {
  schedule((a, t0) => {
    for (const m of notes) voice(a, m, t0, 0.2 / Math.sqrt(notes.length))
    atStrike(a, t0, then)          // the trace strikes when the chord does
  }, true)
}
/** Fire `then` when the notes actually sound, which on a cold device is a
    pre-roll after the click that asked for them. */
function atStrike(a: AudioContext, t0: number, then?: () => void) {
  if (then) window.setTimeout(then, Math.max(0, (t0 - a.currentTime) * 1000))
}
// a single key, clicked — the same voice, a touch shorter
function playNote(m: number) {
  schedule((a, t0) => voice(a, m, t0, 0.2, 1.8))
}
/** The same notes one at a time, each left ringing, so the chord assembles
    itself out of its own spelling and you hear the intervals stack up. */
const ARP_GAP = 0.32
function playArpeggio(notes: number[], then?: () => void) {
  schedule((a, t0) => {
    notes.forEach((m, i) => voice(a, m, t0 + i * ARP_GAP, 0.24, 2.3))
    atStrike(a, t0, then)          // …and the keys light with the notes, for the same reason
  }, true)
}
const noteName = (m: number) => ROOT_NAMES[m % 12] + (Math.floor(m / 12) - 1)
// One empty array, not a fresh one per render: `Trace` and `Keyboard` key their
// work off the identity of what they are given.
const NO_NOTES: number[] = []
// tiny right/wrong cues, well under the chord's level
// A tap, never a tune. Anything with a pitch in it is a second chord landing on
// top of the one you are still holding in your head, so both cues are filtered
// noise: a dry tick when you land it, a soft thud when you do not.
let noiseBuf: AudioBuffer | null = null
function noise(a: AudioContext) {
  if (!noiseBuf || noiseBuf.sampleRate !== a.sampleRate) {
    const n = Math.floor(a.sampleRate * 0.4)
    noiseBuf = a.createBuffer(1, n, a.sampleRate)
    const d = noiseBuf.getChannelData(0)
    for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1
  }
  return noiseBuf
}
function cue(good: boolean) {
  // Through `schedule` like everything else. A cue follows a stretch of silence as
  // often as a chord does — you can sit on a question for a minute — and reaching
  // for `currentTime` itself is how it kept being swallowed.
  schedule((a, t) => {
    const s = a.createBufferSource(); s.buffer = noise(a)
    const f = a.createBiquadFilter()
    const g = a.createGain()
    const dur = good ? 0.05 : 0.19
    if (good) { f.type = "bandpass"; f.frequency.value = 3400; f.Q.value = 0.8; g.gain.setValueAtTime(0.07, t) }
    else {
      // The thud used to sit under 240Hz, which is below what a laptop speaker
      // reproduces, so on most machines a wrong answer made no sound at all. Same
      // character, moved up into the range a small speaker actually has, and swept
      // downward across its length so it still lands like a door closing rather
      // than sitting there as a hiss. Still noise: nothing here has a pitch.
      f.type = "lowpass"; f.Q.value = 0.7
      f.frequency.setValueAtTime(900, t)
      f.frequency.exponentialRampToValueAtTime(260, t + dur)
      g.gain.setValueAtTime(0.34, t)
    }
    g.gain.exponentialRampToValueAtTime(0.0002, t + dur)
    s.connect(f).connect(g).connect(bus!)
    s.start(t, Math.random() * 0.3); s.stop(t + dur + 0.05)
  })
}


/* ---------- the trace ----------
   The chord's own waveform, summed from the same partials the synth uses, so
   the picture is of the sound and not a decoration next to it.

   Two windows, and the difference between them is the whole point:

     "heard"  — a fixed 42 ms slice. How many cycles fit depends on the root, so
                this is the sound you just heard, in the register you heard it.
     "shape"  — exactly one period of the chord. The root cancels out entirely:
                every voicing of a maj7 draws the identical curve. That is the
                shape of the quality, which is what you were asked to name.

   Drawn on canvas, which has no cascade, so the colours are read back off the
   page rather than restated here and left to drift. */

// The synth's own partials, summed. The claim above, that the picture is of the
// sound, holds because there is no second list here to drift from.
const PARTIAL_SUM = PARTIALS.reduce((s, [, a]) => s + a, 0)

const cssVar = (name: string, fallback: string) =>
  getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback

/** One period of the chord: the slowest beat between its notes, which for a
    12-TET chord over root f is f / 2^(1/12)-ish — so take the greatest common
    period numerically instead of pretending the ratios are just. */
function chordPeriod(freqs: number[]): number {
  // The tones are equal-tempered, so no exact common period exists. Use the
  // lowest note's period times the smallest n that brings every other note
  // close to a whole number of cycles — 16 periods is plenty for every chord
  // in the book, and it is what makes the curve sit still.
  const f0 = freqs[0]
  let best = 1, bestErr = Infinity
  for (let n = 1; n <= 16; n++) {
    let err = 0
    for (const f of freqs) { const c = (f / f0) * n; err += Math.abs(c - Math.round(c)) }
    if (err < bestErr - 1e-9) { bestErr = err; best = n }
  }
  return best / f0
}

function drawTrace(
  cv: HTMLCanvasElement, notes: number[],
  opts: { window: "heard" | "shape"; amp: number; parts: boolean },
) {
  const ctx = cv.getContext("2d")
  if (!ctx) return
  const dpr = Math.min(window.devicePixelRatio || 1, 2)
  const w = cv.clientWidth, h = cv.clientHeight
  if (!w || !h) return
  if (cv.width !== w * dpr || cv.height !== h * dpr) { cv.width = w * dpr; cv.height = h * dpr }
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
  ctx.clearRect(0, 0, w, h)

  const mid = h / 2
  ctx.strokeStyle = cssVar("--hair", "rgba(255,255,255,0.12)")
  ctx.lineWidth = 1
  ctx.beginPath(); ctx.moveTo(0, mid + 0.5); ctx.lineTo(w, mid + 0.5); ctx.stroke()
  if (!notes.length || opts.amp <= 0.001) return

  const freqs = notes.map(hz)
  const span = opts.window === "shape" ? chordPeriod(freqs) : 0.042
  // Scale for how notes actually sum, not for the worst case: dividing by the
  // note count assumes every partial peaks in phase, which never happens and
  // leaves the curve a flat ribbon. sqrt(n) is the honest figure, and the draw
  // is clamped so the rare loud frame cannot climb out of the box.
  const scale = (mid * 0.9 * opts.amp) / (Math.sqrt(notes.length) * PARTIAL_SUM)
  const lim = mid * 0.95

  const COLS = 480
  const trace = (fs: number[]) => {
    ctx.beginPath()
    for (let x = 0; x <= COLS; x++) {
      const u = (x / COLS) * span
      let s = 0
      for (const f of fs) for (const [k, a] of PARTIALS) s += a * Math.sin(2 * Math.PI * k * f * u)
      const px = (x / COLS) * w
      const y = mid - clamp(s * scale, -lim, lim)
      x === 0 ? ctx.moveTo(px, y) : ctx.lineTo(px, y)
    }
    ctx.stroke()
  }

  // faint, the notes that make it up; bold, their sum
  if (opts.parts) {
    ctx.globalAlpha = 0.3; ctx.lineWidth = 1
    ctx.strokeStyle = cssVar("--accent", "#ffab52")
    for (const f of freqs) trace([f])
    ctx.globalAlpha = 1
  }
  ctx.strokeStyle = cssVar("--ink", "#f3e9da")
  ctx.lineWidth = 1.8
  ctx.lineJoin = "round"
  trace(freqs)
}

/* ---------- the wheel ----------
   Twelve pitch classes on a clock, the chord's tones joined up. Two chords are
   the same quality exactly when their polygons are congruent, so a shape learnt
   once is learnt in all twelve keys. It also shows, at a glance, why some
   chords answer to several names: a shape with rotational symmetry lands on
   itself, which is dim7 (a square), aug (a triangle) and 7♯11 (a rectangle). */

function Wheel({ pcs, root, hits = [], size = 132, labels = false, dim = false, tone }: {
  pcs: number[]; root: number | null; hits?: number[]; size?: number
  labels?: boolean; dim?: boolean; tone?: "good" | "bad"
}) {
  // The box grows to hold the ring of note names — at radius 49 in a 100-unit
  // box the C at twelve o'clock had its top sheared off.
  const R = 40, C = R + (labels ? 24 : 6), VB = C * 2, LR = R + 13
  // 12 o'clock is C and the circle runs clockwise, the way a chord chart reads
  const at = (p: number, r: number) => {
    const a = (p / 12) * Math.PI * 2 - Math.PI / 2
    return [C + Math.cos(a) * r, C + Math.sin(a) * r] as const
  }
  const ring = pcsOf(pcs).sort((a, b) => a - b)
  const on = new Set(ring)
  const dot = (p: number, r: number, cls: string, key: string) => {
    const [x, y] = at(p, R)
    return <circle key={key + p} cx={x} cy={y} r={r} className={cls} />
  }
  return (
    <svg className={"wheel" + (dim ? " dim" : "") + (tone ? " " + tone : "")} viewBox={`0 0 ${VB} ${VB}`} width={size} height={size}
      role="img" aria-label={`chord shape, ${ring.length} tones`}>
      <circle cx={C} cy={C} r={R} className="rim" />
      {PCS.map(p => dot(p, 1.8, "tick", "k"))}
      {/* Two tones would draw as a closed pair, which is the line: no chord in
          the table has fewer than three, so it never comes up. */}
      {ring.length > 1 && <polygon points={ring.map(p => at(p, R).join(",")).join(" ")} className="poly" />}
      {ring.map(p => dot(p, p === root ? 4.8 : 3.6, p === root ? "tone root" : "tone", "t"))}
      {/* a key you pressed, marked on the clock wherever it falls */}
      {pcsOf(hits).map(p => dot(p, 7.5, "hit", "h"))}
      {labels && PCS.map(p => {
        const [x, y] = at(p, LR)
        return <text key={"l" + p} x={x} y={y} className={on.has(p) ? "wl on" : "wl"}>{ROOT_NAMES[p]}</text>
      })}
    </svg>
  )
}

/** An option's shape, drawn on the root of the chord you actually heard — the
    same root it is voiced on when you click to hear it. So all four sit on one
    clock and the right one lands exactly on the big wheel's polygon. */
const optionPcs = (q: Quality, c: Chord) => pcsOf(optionNotes(q, c))

/** How wide one digit is, in ems, for the font the page actually got: `system-ui`
    is a different typeface on every platform and the countdown is placed a glyph
    at a time, so the advance has to be measured rather than assumed. Once, on
    first use, off a canvas. */
let advEm = 0
function digitAdvance() {
  if (advEm) return advEm
  advEm = 0.56                              // a fair guess for a bold grotesque
  try {
    const cx = document.createElement("canvas").getContext("2d")
    if (cx) {
      cx.font = `700 100px ${cssVar("--sans", "system-ui, sans-serif")}`
      const w = cx.measureText("8").width / 100
      if (w > 0.3 && w < 0.9) advEm = w     // anything outside that is a failed measure
    }
  } catch { /* the guess stands */ }
  return advEm
}

/* ---------- the clock, which is a candle ----------
   Once the call is made the candle stops where it burned to and goes out; the
   wax never springs back, because the time you took is part of the record.

   Getting it right is the opposite event, so it gets the opposite picture. A
   breath of air hits the wick: the flame leaps, its heart goes white, and it
   settles to a burn a little taller and a little steadier than the one before.
   The leap is sized by the wax still standing, which is the measurement the
   candle already is. Name it in a second and it jumps; scrape in on the last of
   the clock and it barely lifts. The other half of the leap is the run, which
   arrives as `--run` on the board's root and is folded in by `--lift` in the
   stylesheet: how well you are going scales how hard the flame answers. That is
   what a miss costs, and it is why the snuff needed something worth losing.

   And past five in a row the flame changes colour and stands taller: green and
   half again its height, then blue at eight, then purple at ten. Both of those
   move in steps, at the same three thresholds, which is the whole lesson of how
   this was built. Three earlier passes ramped a size smoothly with the streak
   and every one of them was invisible, because the candle is a moving target
   (the wax drops it thirty pixels a round) with nothing beside it holding still,
   and a few per cent per chord under a confound that size is a puzzle, not a
   signal. A step is seen. A drift is not. Colour was held out at first on the
   argument that the channel belonged to the clock; the argument was true and
   beside the point, since what it left behind could not be seen at all. See
   `.candle.run5` for what it costs the clock, which is less than it looks. */
function Candle({ frac, secs, low, out, won, streak = 0 }: {
  frac: number; secs: number; low: boolean; out?: boolean; won?: boolean; streak?: number
}) {
  // The box carries 20 units of headroom above a full candle, because the flame
  // stands 20 above the wick and was being sheared off the top of the viewBox.
  const H = 58, BASE = 96, y = BASE - H * frac
  const FOOT = BASE + 11                    // where the base's curve bottoms out, wherever the top is

  /* The leap keeps a floor under it: naming a chord with two seconds of wax left
     is still naming it, and still worth a lift you can see. */
  const flare = won ? 0.35 + 0.65 * clamp01(frac) : 0
  const run = runOf(streak)
  /* The tier the run has reached, which is the flame's colour. Held while the
     run is, not only on the chord that earned it, so a green flame is the state
     of the set and not a one-second congratulation. */
  const tier = streak >= 10 ? " run10" : streak >= 8 ? " run8" : streak >= 5 ? " run5" : ""
  /* Embers, and the one part of the leap that is a difference in kind rather
     than in degree. Nothing at all for the first right answer, one at two in a
     row, nine at ten: "was that bigger than the last one" is a question an eye
     is bad at, and "were there sparks" is one it cannot get wrong. */
  const sparks = won ? Math.floor(run * 9) : 0

  /* The number is painted on the wax, so it rides down with the wax. Two things
     drive it off, and whichever comes first wins: the wax, which under about
     twenty-six units of column has the digits cramped and under sixteen has
     nowhere to put them at all, and the clock, which is the point of the whole
     thing. Either way it climbs clear of the flame, straightens out (no cylinder
     under it any more to bend to) and goes red. The clock's trigger is in
     seconds rather than in wax, so the drift starts on three whichever speed the
     round was set to. At 30s the wax runs out before that and takes it first, at
     5s and 10s the clock does. */
  const column = FOOT - y                   // wax standing under the number
  const esc = clamp01(Math.max((22 - column) / 7, (3 - secs) / 1.4))
  const lift = esc * esc * (3 - 2 * esc)    // smoothstep, so it leaves and lands gently
  const onWax = (y + FOOT) / 2              // centred on whatever wax is left
  const numY = onWax + (48 - 7 * lift - onWax) * lift

  /* Painting a number on a cylinder is not the same as bending it along a curve,
     which is what running it down an arc does: that tips each glyph off vertical,
     and a wrapped glyph never tips. Its uprights run parallel to the candle's
     axis, and the axis projects straight up however the candle is turned. What
     the wrap actually does is three things, all of them driven by how far round
     the barrel a glyph has travelled: it slides towards the middle, it narrows,
     and it rides up by the same foreshortening the rim already shows.
     Two digits only reach about 20 degrees round a barrel this wide, so this is
     a whisper and not a bow. That is the honest size of it. */
  const n = Math.max(0, Math.ceil(secs))
  const FS = 22 + 4 * lift                  // it grows a little as it breaks free
  const R = 16, RIM = 4                     // the barrel, and the rim's own ry
  const wrapped = 1 - lift                  // off the wax there is no barrel left
  const adv = digitAdvance() * FS
  const digits = String(n).split("").map((ch, i, all) => {
    const s = (i - (all.length - 1) / 2) * adv        // arc from the front of the candle
    const th = s / R
    const c = Math.cos(th)
    return {
      ch,
      x: 32 + s + (R * Math.sin(th) - s) * wrapped,
      y: numY - RIM * (1 - c) * wrapped,
      w: 1 - (1 - c) * wrapped,
    }
  })
  // the last second dims out instead of blinking out
  const fade = clamp01(secs)

  /* Smoke rises continuously out of a source that does not move. The wiggle is
     diffusion, so its amplitude is a function of distance travelled from the
     wick — zero at the wick itself — and the pattern advects upward through a
     base that stays put. Translating the whole plume, as this used to, detaches
     it from the wick and reads as a puff being thrown rather than smoke. */
  const [t, setT] = useState(0)
  useEffect(() => {
    if (!out) { setT(0); return }
    if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return
    let raf = 0
    const t0 = performance.now()
    const tick = () => {
      const age = (performance.now() - t0) / 1000
      setT(age)
      if (age < 20) raf = requestAnimationFrame(tick)   // by then it has gone still
    }
    raf = requestAnimationFrame(tick)
    return () => cancelAnimationFrame(raf)
  }, [out])

  const PLUME = 54
  const wisp = (phase: number, freq: number, lean: number) => {
    const calm = Math.exp(-t / 7)                   // the air settles
    let d = `M32 ${(y - 3).toFixed(2)}`
    for (let i = 1; i <= 26; i++) {
      const f = i / 26                              // fraction of the way up
      const h = f * PLUME
      const spread = Math.pow(f, 1.6)               // diffusion widens with distance
      const x = 32 + lean * spread * 7
        + 6.5 * spread * calm * Math.sin(freq * h - t * 2.6 + phase)
      d += ` L${x.toFixed(2)} ${(y - 3 - h).toFixed(2)}`
    }
    return d
  }

  return (
    // wide enough for two digits of wax: at 20 units it could not hold "25"
    <svg className={"candle" + (low ? " low" : "") + (out ? " out" : "") + (won ? " won" : "") + tier}
      viewBox="0 0 64 118" role="img"
      style={{ "--flare": flare } as React.CSSProperties}
      aria-label={out ? "candle out" : `${n} seconds left`}>
      <defs>
        <linearGradient id="waxfill" x1="0" y1="0" x2="1" y2="0">
          <stop offset="0%" stopColor="#bfb197" />
          <stop offset="26%" stopColor="#f2e7cd" />
          <stop offset="58%" stopColor="#e6d9ba" />
          <stop offset="100%" stopColor="#a89a80" />
        </linearGradient>
        {/* thins out with height, so the plume dissipates instead of stopping */}
        <linearGradient id="smokefade" gradientUnits="userSpaceOnUse"
          x1="32" y1={y - 3} x2="32" y2={y - 3 - PLUME}>
          <stop offset="0%" stopColor="#d2c9bc" stopOpacity="0.92" />
          <stop offset="30%" stopColor="#c6bdb0" stopOpacity="0.62" />
          <stop offset="68%" stopColor="#b6ada1" stopOpacity="0.27" />
          <stop offset="100%" stopColor="#b6ada1" stopOpacity="0" />
        </linearGradient>
      </defs>
      {/* A cylinder seen side-on: straight flanks lit from the left, an ellipse
          capping the foot the way one caps the top. No holder — every version of
          one read as a plate stuck on behind it. */}
      <path className="wax" d={`M16 ${y} v${H * frac + 7} a16 4 0 0 0 32 0 v${-(H * frac + 7)} z`} />
      <ellipse cx="32" cy={y} rx="16" ry="4" className="waxtop" />
      <ellipse cx="32" cy={y} rx="16" ry="4" className="waxlip" />
      {out ? (
        <>
          {/* the wick stays out of the blurred group: it is the one thing here
              with an edge */}
          <path className="wick" d={`M32 ${y - 4} L32 ${y}`} />
          <g className="smoke">
            <path className="wisp" d={wisp(0, 0.34, -0.55)} />
            <path className="wisp two" d={wisp(2.1, 0.44, 0.75)} />
            <path className="wisp three" d={wisp(4.2, 0.26, 0.15)} />
          </g>
        </>
      ) : (
        /* Three nested groups, because three transforms have to compose and an
           element gets one: the leap, the flicker inside it, and the tier's
           standing height inside that. Fold any pair together and the leap's
           keyframes overwrite whatever shared its `transform`. All three turn
           about the wick, which is where a flame is anchored. */
        <g className="flareg" style={{ transformOrigin: `32px ${y}px` }}>
          <g className="flamewrap" style={{ transformOrigin: `32px ${y}px` }}>
            {/* Outside `.stand` on purpose. The halo leaps and flickers with the
                flame but does not take the tier's height: stretched by that as
                well it turns into a smudge that swallows the shape it is meant to
                sit behind, and it is also the piece that would spill furthest
                over the top of a box with 18px of room above it. */}
            <ellipse cx="32" cy={y - 11} rx="8.5" ry="14" className="halo" />
            <g className="stand" style={{ transformOrigin: `32px ${y}px` }}>
              <path d={`M32 ${y - 20} C 36.5 ${y - 12}, 36.5 ${y - 3}, 32 ${y - 2} C 27.5 ${y - 3}, 27.5 ${y - 12}, 32 ${y - 20} Z`} className="flame" />
              {/* the same cone at a bit over half height, dark until the leap
                  lifts it: a flame that surges brightens from the inside out */}
              <path d={`M32 ${y - 12.2} C 34.6 ${y - 7.6}, 34.6 ${y - 2.6}, 32 ${y - 2} C 29.4 ${y - 2.6}, 29.4 ${y - 7.6}, 32 ${y - 12.2} Z`} className="core" />
            </g>
          </g>
        </g>
      )}
      {/* Outside the flame's groups: an ember that has left the wick is no longer
          part of the flame and must not be scaled or flickered by it. The spread
          is a fixed hash of the index rather than a random, so a re-render during
          the reveal cannot re-scatter them mid-flight. */}
      {sparks > 0 && (
        <g className="sparks">
          {Array.from({ length: sparks }, (_, i) => (
            <circle key={i} className="spark" cx={32} cy={y - 7} r={0.75 + (i % 3) * 0.3}
              style={{
                "--dx": ((i * 37) % 11) - 5,
                "--dy": -24 - ((i * 53) % 19),
                animationDelay: `${0.05 + i * 0.05}s`,
              } as React.CSSProperties} />
          ))}
        </g>
      )}
      {n > 0 && !out && (
        <g style={{ opacity: fade }}>
          <g className={"secs" + (lift > 0.1 ? " free" : "")} style={{ fontSize: FS }}>
            {digits.map((d, i) => (
              // scaled about the glyph's own centre, which is what `x`/`y` at the
              // origin plus a translate buys us
              <text key={i} x="0" y="0"
                transform={`translate(${d.x.toFixed(2)} ${d.y.toFixed(2)}) scale(${d.w.toFixed(3)} 1)`}>
                {d.ch}
              </text>
            ))}
          </g>
        </g>
      )}
    </svg>
  )
}

const isBlack = (m: number) => [1, 3, 6, 8, 10].includes(m % 12)

/** Every button that stays where it is when you press it springs on the press.
    A CSS animation restarts when its computed name changes and at no other time,
    so pressing the same button twice would re-add a class it already had and
    play nothing: the class alternates between two identical sets of keyframes
    instead. That trick was written out by hand at each button that needed it,
    which is why it kept being left off the ones that did not have it yet. */
/** Drop the focus ring as the button fires: these are pressed, not tabbed
    through, and a ring left standing on the last one read as a selection. */
const tap = (fn: () => void) => (e: React.MouseEvent<HTMLElement>) => { e.currentTarget.blur(); fn() }

function Tap({ className = "", onClick, ...rest }: React.ComponentProps<"button">) {
  const [n, setN] = useState(0)
  return (
    <button {...rest}
      className={className + (n === 0 ? "" : n % 2 ? " tapA" : " tapB")}
      onClick={e => { setN(v => v + 1); onClick?.(e) }} />
  )
}

/* ---------- the marks ----------
   One mark per action, and the mark is the whole button. Replay went through a
   circular arrow, which reads as "reset the whole thing", and then the repeat
   sign, which is the correct notation for it and means nothing to anyone who has
   not been taught it. A note says the one thing it needs to: sound. The mark for
   moving on is a head against a barline, which is the notation reading and the
   skip-forward reading at once, so it lands either way. The end of the set is
   the one place with nothing that lands: the final double bar is meaningless to
   anyone it has not been explained to, so the tenth call is answered with the
   word Results instead. */
const Play = () => (
  <svg className="mark" viewBox="0 0 24 24" aria-hidden="true">
    {/* a triangle's weight is a third of the way back from its point, so it is
        set right of centre: box-centred, it reads as leaning left */}
    <path d="M6.2 2.5 L22.7 12 L6.2 21.5 Z" />
  </svg>
)
const Note = () => (
  <svg className="mark" viewBox="0 0 24 24" aria-hidden="true">
    <ellipse cx="8.2" cy="18" rx="4.6" ry="3.4" transform="rotate(-22 8.2 18)" />
    <rect x="11.1" y="3.2" width="1.9" height="14.9" />
    <path d="M13 3.2 C 17.2 5.4 19.6 7.4 19.6 10.6 C 19.6 12.4 18.7 13.7 17.2 14.6
             C 17.9 12.6 17.4 10.6 15.4 9 C 14.6 8.4 13.8 7.9 13 7.4 Z" />
  </svg>
)
const Barline = () => (
  <svg className="mark" viewBox="0 0 24 24" aria-hidden="true">
    <path d="M3 2.5 L14 12 L3 21.5 Z" />
    <rect x="16.6" y="2.5" width="3.6" height="19" />
  </svg>
)
/* and three that are not notation, because nothing in notation means "settings",
   "take a picture of this" or "that worked" */
const Sliders = () => (
  <svg className="mark" viewBox="0 0 24 24" aria-hidden="true">
    <rect x="2.5" y="4.6" width="2.2" height="1.8" rx="0.9" />
    <rect x="11.2" y="4.6" width="10.3" height="1.8" rx="0.9" />
    <circle cx="8" cy="5.5" r="2.9" />
    <rect x="2.5" y="11.1" width="10.2" height="1.8" rx="0.9" />
    <rect x="19.2" y="11.1" width="2.3" height="1.8" rx="0.9" />
    <circle cx="16" cy="12" r="2.9" />
    <rect x="2.5" y="17.6" width="4.6" height="1.8" rx="0.9" />
    <rect x="13.7" y="17.6" width="7.8" height="1.8" rx="0.9" />
    <circle cx="10.5" cy="18.5" r="2.9" />
  </svg>
)
const Camera = () => (
  // body, viewfinder and lens in one path, so evenodd punches the lens out
  <svg className="mark" viewBox="0 0 24 24" aria-hidden="true">
    <path fillRule="evenodd" d={
      "M8 3.6 h6 a1.2 1.2 0 0 1 1.2 1.2 v1.7 h-8.4 v-1.7 a1.2 1.2 0 0 1 1.2 -1.2 Z"
      + " M5 6.5 h14 a2.5 2.5 0 0 1 2.5 2.5 v8.5 a2.5 2.5 0 0 1 -2.5 2.5"
      + " h-14 a2.5 2.5 0 0 1 -2.5 -2.5 v-8.5 a2.5 2.5 0 0 1 2.5 -2.5 Z"
      + " M7.6 13.2 A 4.4 4.4 0 1 1 16.4 13.2 A 4.4 4.4 0 1 1 7.6 13.2 Z"
    } />
  </svg>
)
const Tick = () => (
  <svg className="mark" viewBox="0 0 24 24" aria-hidden="true">
    <path d="M4 12.6 L9.6 18.2 L20 6.4" fill="none" stroke="currentColor"
      strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
  </svg>
)
const Cross = () => (
  <svg className="mark" viewBox="0 0 24 24" aria-hidden="true">
    <path d="M5.8 5.8 L18.2 18.2 M18.2 5.8 L5.8 18.2" fill="none" stroke="currentColor"
      strokeWidth="3" strokeLinecap="round" />
  </svg>
)

/* Dimmed and dead are the same state, so there is one flag for them: no
   `onPlay` is what makes it dim. A dimmed keyboard you can still press would be
   a keyboard that says it is out of the question and then answers anyway. */
function Keyboard({ lit, root, pressed = [], onPlay }: {
  lit: number[]; root: number | null; pressed?: number[]; onPlay?: (m: number) => void
}) {
  const W = 24, H = 78, BW = 14, BH = 48
  const whites: number[] = [], blacks: number[] = []
  for (let m = KB_LO; m <= KB_HI; m++) (isBlack(m) ? blacks : whites).push(m)
  const wx = (m: number) => whites.indexOf(m) * W
  const litSet = new Set(lit), pressedSet = new Set(pressed)
  const cls = (base: string, m: number) =>
    base + (litSet.has(m) ? " on" : "") + (pressedSet.has(m) ? " press" : "") + (onPlay ? " live" : "")
  const key = (m: number, geom: { x: number; y: number; width: number; height: number }, base: string) => (
    <rect key={m} {...geom} rx={2} className={cls(base, m)} role={onPlay ? "button" : undefined} aria-label={noteName(m)}
      onPointerDown={onPlay ? e => { e.preventDefault(); onPlay(m) } : undefined}>
      <title>{noteName(m)}</title>
    </rect>
  )
  return (
    <svg className={"kb" + (onPlay ? "" : " dim")} viewBox={`0 0 ${whites.length * W} ${H}`}
      role="group" aria-label="keyboard">
      {whites.map(m => key(m, { x: wx(m), y: 0, width: W, height: H }, "wk"))}
      {blacks.map(m => key(m, { x: wx(m - 1) + W - BW / 2, y: 0, width: BW, height: BH }, "bk"))}
      {whites.filter(m => m % 12 === 0).map(m => (
        <text key={"l" + m} x={wx(m) + W / 2} y={H - 6} className="kl" pointerEvents="none">C{m / 12 - 1}</text>
      ))}
      {root !== null && lit.filter(m => m % 12 === root).map(m => (
        <circle key={"r" + m} r={3.4} className="rootdot" pointerEvents="none"
          cx={isBlack(m) ? wx(m - 1) + W : wx(m) + W / 2} cy={isBlack(m) ? BH - 10 : H - 21} />
      ))}
    </svg>
  )
}

function Seg<T extends string | number | boolean>({ value, onChange, items, label }: {
  value: T; onChange: (v: T) => void; items: { v: T; label: string }[]; label: string
}) {
  return (
    <div className="seg" role="group" aria-label={label}>
      {items.map(it => (
        <button key={String(it.v)} className={it.v === value ? "on" : ""} aria-pressed={it.v === value}
          onClick={tap(() => onChange(it.v))}>{it.label}</button>
      ))}
    </div>
  )
}
const fmtSec = (ms: number) => (ms / 1000).toFixed(1) + " s"
// ---------- game state ----------
// The page opens on "ready": the board is dealt and the first chord is already
// chosen, just not yet sounded. Nothing to set up before you can play.
type Phase = "ready" | "round" | "reveal" | "results"
type RoundRec = { chord: Chord; options: Quality[]; answer: number; picked: number | null; correct: boolean; ms: number }
type Summary = { correct: number; avgMs: number | null }
type Game = {
  phase: Phase; settings: Settings
  round: number; right: number; streak: number
  chord: Chord; options: Quality[]; answer: number
  picked: number | null; correct: boolean | null
  history: RoundRec[]; last: Summary | null
  t0: number; dur: number
}

const STORE = "the-coltrainer"
const STORE_WAS = "chord-shapes"        // settings saved under the old name
function loadSettings(): Settings {
  try {
    const s = JSON.parse(localStorage.getItem(STORE) || localStorage.getItem(STORE_WAS) || "null")
    if (s) {
      const out: Settings = { ...DEFAULTS, ...s }
      // a pool saved before the ladder was regrouped names nothing now, and
      // poolOf would walk straight off the end of the list looking for it
      if (!POOLS.some(p => p.id === out.pool)) out.pool = DEFAULTS.pool
      if (!SPEEDS.some(x => x.v === out.clock)) out.clock = DEFAULTS.clock
      return out
    }
  } catch { /* nothing saved yet */ }
  return { ...DEFAULTS }
}
function saveSettings(s: Settings) { try { localStorage.setItem(STORE, JSON.stringify(s)) } catch {} }

/** A chord and its four names, chosen but not yet played. */
function deal(s: Settings, prev: Chord | null) {
  const chord = makeChord(s, prev)
  const { options, answer } = makeOptions(chord, s)
  return { chord, options, answer }
}

const newGame = (): Game => {
  const settings = loadSettings()
  return {
    phase: "ready", settings,
    round: 0, right: 0, streak: 0,
    ...deal(settings, null), picked: null, correct: null,
    history: [], last: null, t0: 0, dur: 1,
  }
}

function summarize(h: RoundRec[]): Summary {
  const right = h.filter(r => r.correct)
  return {
    correct: right.length,
    avgMs: right.length ? right.reduce((a, r) => a + r.ms, 0) / right.length : null,
  }
}

/** What the cell under a chord says: how long you took, or what you called it.
    Shared by the card on screen and the card in the image, so the two cannot
    drift apart. */
const saidOf = (r: RoundRec) =>
  r.correct ? `${(r.ms / 1000).toFixed(1)}s`
    : r.picked === null ? "timed out"
      : lower(r.options[r.picked].name)

/** The settings a set was played under, as one line. Root position is the
    default and says nothing, so it is left out: only a departure from it is
    worth a word on the card. The label is read off the segmented control
    rather than written again here, so the two cannot say it differently. */
const setupLine = (s: Settings) => [
  POOLS.find(p => p.id === s.pool)!.label,
  ...(s.inversions ? [VOICINGS.find(x => x.v)!.label] : []),
  `${s.clock}s`,
].join(" · ")

/* ---------- the scorecard as an image ----------
   The card on screen is already built to be screenshotted; this draws the same
   card again, at a fixed size, as a file you can hand over. Not a rasteriser
   pointed at the DOM: the wheels are SVG coloured entirely from the stylesheet,
   which none of those carries across, and the page has to fold the grid to two
   columns on a phone — where an image, answering to no viewport, keeps the five
   across that let ten chords read as one set.

   Every measurement lives in `shotLayout`, the height included: it falls out of
   the same running cursor the drawing then follows, so the two cannot disagree
   about where anything is. */

type ShotCell = { pcs: number[]; root: number; sym: string; said: string; correct: boolean }
type Shot = { correct: number; setup: string; foot: string; cells: ShotCell[] }

const SHOT_LINK = "typebulb.com/u/antypica/the-coltrainer"
const SHOT_FILE = "the-coltrainer.png"

/** `foot` says whether there is an average to print: with nothing right there
    is nothing to say, and the row is left out rather than left empty. */
function shotLayout(foot: boolean) {
  const w = 1120, m = 18, pad = 46, gap = 20, wheel = 130
  const x0 = m + pad
  const col = (w - 2 * x0 - 4 * gap) / 5
  // a shape, its name, and what you said — the last with room for two lines,
  // because "dominant 7th ♭9♯11" does not fit a fifth of the card on one
  const rowH = wheel + 14 + 30 + 4 + 44
  let y = m + 46
  const brand = y; y += 46
  const tally = y; y += 112
  const setup = y; y += 36 + 26
  const grid = y; y += rowH * 2 + 24
  // modest, because the row above it has already reserved a second line of
  // "what you said" that most sets never use
  y += 14
  const rule = y; y += 26
  const footAt = y; y += foot ? 34 : 0
  const link = y; y += 36
  return { w, h: y + m + 22, m, x0, gap, col, wheel, rowH, brand, tally, setup, grid, rule, foot: footAt, link }
}

/** The wheel again, in canvas: the same 92-unit box and radius-40 ring the SVG
    uses, so a shape on the image is the shape that was on the page. */
function shotWheel(
  ctx: CanvasRenderingContext2D, cx: number, cy: number, size: number,
  pcs: number[], root: number, good: boolean,
) {
  const u = size / 92, R = 40 * u
  const at = (pc: number, r: number) => {
    const a = (pc / 12) * Math.PI * 2 - Math.PI / 2
    return [cx + Math.cos(a) * r, cy + Math.sin(a) * r] as const
  }
  const ring = pcsOf(pcs).sort((a, b) => a - b)
  const line = good ? cssVar("--good", "#86d59a") : cssVar("--bad", "#ff8f79")
  const soft = good ? cssVar("--good-soft", "rgba(134, 213, 154, 0.13)")
    : cssVar("--bad-soft", "rgba(255, 143, 121, 0.13)")
  const hair = cssVar("--hair", "rgba(255, 224, 190, 0.16)")
  const dot = (x: number, y: number, r: number) => { ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2) }

  dot(cx, cy, R); ctx.strokeStyle = hair; ctx.lineWidth = u; ctx.stroke()
  ctx.fillStyle = hair
  for (const pc of PCS) { const [x, y] = at(pc, R); dot(x, y, 1.8 * u); ctx.fill() }

  if (ring.length > 1) {
    ctx.beginPath()
    ring.forEach((pc, i) => { const [x, y] = at(pc, R); i ? ctx.lineTo(x, y) : ctx.moveTo(x, y) })
    if (ring.length > 2) { ctx.closePath(); ctx.fillStyle = soft; ctx.fill() }
    ctx.strokeStyle = line; ctx.lineWidth = 1.6 * u; ctx.lineJoin = "round"; ctx.stroke()
  }
  for (const pc of ring) {
    const [x, y] = at(pc, R)
    dot(x, y, (pc === root ? 4.8 : 3.6) * u)
    if (pc === root) {
      ctx.fillStyle = cssVar("--ink", "#f2e9dc"); ctx.fill()
      ctx.strokeStyle = line; ctx.lineWidth = 1.4 * u; ctx.stroke()
    } else { ctx.fillStyle = line; ctx.fill() }
  }
}

/** Centred text that stays inside its column: it wraps as far as it is allowed
    to, and only a word too wide to wrap gives up a tail to an ellipsis. */
function shotText(
  ctx: CanvasRenderingContext2D, s: string, cx: number, top: number,
  w: number, lh: number, max: number,
) {
  const lines: string[] = []
  let cur = ""
  for (const word of s.split(" ")) {
    const next = cur ? cur + " " + word : word
    if (cur && ctx.measureText(next).width > w) { lines.push(cur); cur = word } else cur = next
    if (lines.length === max) { cur = ""; break }
  }
  if (cur) lines.push(cur)
  lines.forEach((l, i) => {
    while (l.length > 1 && ctx.measureText(l).width > w) l = l.replace(/…$/, "").slice(0, -1) + "…"
    ctx.fillText(l, cx, top + i * lh)
  })
}

function drawShot(cv: HTMLCanvasElement, shot: Shot) {
  const ctx = cv.getContext("2d")
  if (!ctx) return
  const L = shotLayout(!!shot.foot)
  // drawn at 2×, so it holds up full-screen on the phone it gets sent to
  const S = 2
  cv.width = L.w * S; cv.height = L.h * S
  ctx.setTransform(S, 0, 0, S, 0, 0)

  const muted = cssVar("--muted", "#8b8073"), accent = cssVar("--accent", "#e7a765")
  const hair = cssVar("--hair", "rgba(255, 224, 190, 0.16)"), bad = cssVar("--bad", "#ff8f79")
  const inkHi = cssVar("--ink-hi", "#fff3e0"), inkWarm = cssVar("--ink-warm", "#ffe8c4")
  const sans = cssVar("--sans", "system-ui, sans-serif")
  const serif = cssVar("--serif", "Georgia, serif")
  const mid = L.w / 2

  // the room, and the card sitting in it — the same two surfaces as the page
  ctx.fillStyle = cssVar("--bg", "#0e0d0c")
  ctx.fillRect(0, 0, L.w, L.h)
  ctx.beginPath()
  ctx.roundRect(L.m + 0.5, L.m + 0.5, L.w - 2 * L.m - 1, L.h - 2 * L.m - 1, 18)
  ctx.fillStyle = cssVar("--pane", "#151312"); ctx.fill()
  ctx.strokeStyle = hair; ctx.lineWidth = 1; ctx.stroke()

  ctx.textAlign = "center"; ctx.textBaseline = "top"
  ctx.font = `italic 700 39px ${serif}`; ctx.fillStyle = accent
  ctx.fillText("The Coltrainer", mid, L.brand)

  // the score, big, with "of 10" standing on its baseline the way the page has it
  const big = `700 100px ${sans}`, of = `500 30px ${sans}`
  ctx.textAlign = "left"; ctx.textBaseline = "alphabetic"
  ctx.font = big; const bw = ctx.measureText(String(shot.correct)).width
  ctx.font = of; const ow = ctx.measureText(`of ${ROUNDS}`).width
  // to scale with the page's 5px at 58px: the canvas has no tabular figures to
  // pad the number, so it wants a touch more than the ratio alone would give
  const gap = 10
  const x = mid - (bw + gap + ow) / 2
  ctx.font = big; ctx.fillStyle = inkHi; ctx.fillText(String(shot.correct), x, L.tally + 100)
  ctx.font = of; ctx.fillStyle = muted; ctx.fillText(`of ${ROUNDS}`, x + bw + gap, L.tally + 100)

  ctx.textAlign = "center"; ctx.textBaseline = "top"
  ctx.font = `400 24px ${sans}`; ctx.fillStyle = muted
  ctx.fillText(shot.setup, mid, L.setup)

  shot.cells.forEach((c, i) => {
    const cx = L.x0 + (i % 5) * (L.col + L.gap) + L.col / 2
    const top = L.grid + Math.floor(i / 5) * (L.rowH + 24)
    shotWheel(ctx, cx, top + L.wheel / 2, L.wheel, c.pcs, c.root, c.correct)
    ctx.font = `700 26px ${serif}`; ctx.fillStyle = inkWarm
    shotText(ctx, c.sym, cx, top + L.wheel + 14, L.col, 30, 1)
    ctx.font = `400 20px ${sans}`; ctx.fillStyle = c.correct ? muted : bad
    shotText(ctx, c.said, cx, top + L.wheel + 48, L.col, 24, 2)
  })

  ctx.beginPath()
  ctx.moveTo(L.x0, L.rule + 0.5); ctx.lineTo(L.w - L.x0, L.rule + 0.5)
  ctx.strokeStyle = hair; ctx.lineWidth = 1; ctx.stroke()

  if (shot.foot) {
    ctx.font = `400 24px ${sans}`; ctx.fillStyle = muted
    ctx.fillText(shot.foot, mid, L.foot)
  }
  // whoever sees this has no idea what it is, so the card says where it came from
  ctx.font = `600 24px ${sans}`; ctx.fillStyle = accent
  ctx.fillText(SHOT_LINK, mid, L.link)
}

const shotOf = (g: Game): Shot => ({
  correct: g.last!.correct,
  setup: setupLine(g.settings),
  foot: g.last!.avgMs !== null ? `${fmtSec(g.last!.avgMs)} average when right` : "",
  cells: g.history.map(r => ({
    pcs: pcsOf(r.chord.notes),
    root: r.chord.rootPc,
    sym: symbolOf(r.chord),
    said: saidOf(r),
    correct: r.correct,
  })),
})

// Every combination of settings, many rounds each: four distinct options, the
// answer among them exactly once, no second right answer hiding as a distractor,
// every note on the keyboard, every spelled name the pitch it claims.
function selftest() {
  const fails: string[] = []
  let n = 0
  // no two kinds in one pool may be the same notes over the same root — that would
  // be one chord under two names, unanswerable even in root position
  for (const p of POOLS) {
    const qs = poolOf(p.id)
    for (let i = 0; i < qs.length; i++) for (let j = i + 1; j < qs.length; j++)
      if (pcMask(0, qs[i].ivs) === pcMask(0, qs[j].ivs)) fails.push(`${p.id}: ${symOf(qs[i])} and ${symOf(qs[j])} are the same notes`)
  }
  if (new Set(QUALITIES.map(q => q.id)).size !== QUALITIES.length) fails.push("duplicate quality id")
  if (new Set(QUALITIES.map(symOf)).size !== QUALITIES.length) fails.push("duplicate chord symbol")
  for (const pool of POOLS) for (const inversions of [false, true]) {
    const s: Settings = { ...DEFAULTS, pool: pool.id, inversions }
    let prev: Chord | null = null
    for (let i = 0; i < 150; i++) {
      const { chord: c, options, answer } = deal(s, prev); prev = c; n++
      const tag = `${pool.id}/${inversions ? "inv" : "root"} ${symbolOf(c)}`
      if (options.length !== 4) fails.push(`${tag}: ${options.length} options`)
      if (new Set(options.map(o => o.id)).size !== options.length) fails.push(`${tag}: duplicate option`)
      // …and no two of them may be one set of notes under two names
      const sets = options.map(o => pcMask(0, o.ivs))
      if (new Set(sets).size !== sets.length) fails.push(`${tag}: two options are the same notes`)
      const a = options[answer]
      if (!a || a.id !== c.q.id) fails.push(`${tag}: answer mismatch`)
      options.forEach((o, i) => { if (i !== answer && clashes(o, c, s)) fails.push(`${tag}: second right answer ${o.name}`) })
      if (c.notes.some(x => x < KB_LO || x > KB_HI)) fails.push(`${tag}: note off keyboard`)
      spell(c).forEach((nm, i) => {
        const pc = (c.rootPc + c.q.ivs[i]) % 12
        if (nameToPc(nm) !== pc) fails.push(`${tag}: spelled ${nm} for pc ${pc}`)
      })
      if (nameToPc(c.rootName) !== c.rootPc) fails.push(`${tag}: root spelled ${c.rootName}`)
    }
  }
  return { rounds: n, failures: fails.slice(0, 20), verdict: fails.length ? "FAIL" : "ok" }
}


/* ---------- the game ---------- */

/** The trace, wired to the sound: it strikes with the chord, decays with it,
    and then holds — nothing on this page moves while you are thinking. */
function Trace({ notes, strike, mode, parts }: {
  notes: number[]; strike: number; mode: "heard" | "shape"; parts: boolean
}) {
  const ref = useRef<HTMLCanvasElement>(null)
  useEffect(() => {
    const cv = ref.current
    if (!cv) return
    let raf = 0
    const t0 = performance.now()
    // The shape curve used to replace the decaying one in a single frame: a wave
    // in motion, then a different wave standing still. It springs in instead —
    // the same damped oscillation the reveal's keyframes use, written as a
    // function: past full height, back under, to rest. `drawTrace` clamps the
    // draw, so the overshoot cannot climb out of the box.
    const SWELL = 0.55
    const spring = (x: number) => x >= 1 ? 1 : 1 - Math.exp(-7 * x) * Math.cos(8.5 * x)
    const tick = () => {
      const age = (performance.now() - t0) / 1000
      // The synth's own envelope, so the picture dies at the rate the sound does
      // — but it settles to a legible line rather than a flat one, because it
      // has to stay readable for as long as you are still deciding.
      const amp = mode === "shape"
        ? spring(age / SWELL)
        : Math.max(0.45, Math.exp(-age / 1.5))
      drawTrace(cv, notes, { window: mode, amp, parts })
      // Stop when the picture stops changing, not later. `amp` is the only thing
      // that varies between frames, and the decay reaches its 0.45 floor at
      // 1.5·ln(1/0.45) ≈ 1.2s: every frame after that redraws the same image.
      raf = age < (mode === "shape" ? SWELL : 1.2) ? requestAnimationFrame(tick) : 0
    }
    tick()
    // Redraw at the current age; do NOT re-enter tick, which would schedule a
    // second rAF chain alongside the first. `observe` always fires once on
    // attach, so that is exactly what used to happen: every frame drawn twice,
    // and cleanup cancelling only the later of the two handles.
    const ro = new ResizeObserver(() => { if (!raf) tick() })
    ro.observe(cv)
    return () => { cancelAnimationFrame(raf); ro.disconnect() }
  }, [notes, strike, mode, parts])
  return <canvas ref={ref} className="trace" aria-label="the chord as a waveform" role="img" />
}

function App() {
  // useRef evaluates its argument on every render and keeps only the first, so a
  // bare useRef(newGame()) re-reads localStorage and deals a whole chord, only to
  // throw it away, on every frame the clock renders. useState's initialiser is
  // the one that runs once; the box it holds is the ref.
  const [G] = useState(() => ({ current: newGame() }))
  const [, force] = useState(0)
  const bump = () => force(n => n + 1)
  const [left, setLeft] = useState(1)
  const [pressed, setPressed] = useState<number[]>([])
  const [strike, setStrike] = useState(0)
  // Bumped on every audition, and only its parity is used. A CSS animation
  // restarts when its computed name changes and at no other time, so hearing the
  // same option twice — or hearing the right answer, which already carries the
  // reveal's own animation — would otherwise re-add a class the element already
  // had and play nothing. Alternating between two identical keyframes forces the
  // name to change on every click.
  const [pulse, setPulse] = useState(0)
  /* The reveal's spring is a one-shot event, not a standing style. Declared on
     .good/.bad it re-fires whenever an audition's own animation is taken back
     off the element — the computed name flips from the pulse to the reveal, and
     the browser reads that as a fresh animation — so clicking away from the
     right answer sprang it a second time. This carries it for exactly as long as
     it runs, and an audition takes it off early so the two can never trade. */
  const [sprung, setSprung] = useState(false)
  const sprungT = useRef(0)
  const springOnce = () => {
    window.clearTimeout(sprungT.current)
    setSprung(true)
    sprungT.current = window.setTimeout(() => setSprung(false), 620)
  }
  const springOff = () => { window.clearTimeout(sprungT.current); setSprung(false) }
  const [audition, setAudition] = useState<{ i: number; notes: number[] } | null>(null)
  const [showSetup, setShowSetup] = useState(false)
  /* Until the first chord has sounded, Start breathes a ring. The page arrives
     silent and still, with a stopped candle beside four unlit names, so nothing
     in the picture says that pressing something is what starts it: 12-TET's play
     button wears the same nudge for the same reason. Cleared for good on the
     first press rather than per set, since coming back through the setup panel
     is not first contact. */
  const [everBegan, setEverBegan] = useState(false)
  const raf = useRef(0)
  const g = G.current
  const s = g.settings

  /** Light a key, then let it go. One occurrence, not all of them: an arpeggio
      can have the same pitch class lit twice over and the first release must not
      unlight the second. */
  function flash(m: number, ms: number) {
    setPressed(p => [...p, m])
    window.setTimeout(() => setPressed(p => {
      const i = p.indexOf(m)
      return i < 0 ? p : [...p.slice(0, i), ...p.slice(i + 1)]
    }), ms)
  }

  function press(m: number) {
    playNote(m)
    flash(m, 420)
  }
  // The trace strikes when the chord does, not when the click did: on the first
  // sound of the session those can be a warm-up apart.
  const sound = (notes: number[]) => playChord(notes, () => setStrike(n => n + 1))
  /** Spell it out loud: each note in turn, lighting its key and its point on the
      clock as it arrives. */
  function arpeggio(notes: number[]) {
    playArpeggio(notes, () =>
      notes.forEach((m, i) => window.setTimeout(() => flash(m, 460), i * ARP_GAP * 1000)))
  }

  const stopClock = () => cancelAnimationFrame(raf.current)
  function startClock(sec: number) {
    stopClock()
    g.t0 = performance.now(); g.dur = sec * 1000
    const tick = () => {
      const rem = Math.max(0, g.t0 + g.dur - performance.now())
      setLeft(rem / g.dur)
      if (rem <= 0) resolve(null)
      else raf.current = requestAnimationFrame(tick)
    }
    setLeft(1)
    raf.current = requestAnimationFrame(tick)
  }

  function setS(patch: Partial<Settings>) {
    g.settings = { ...g.settings, ...patch }
    saveSettings(g.settings)
    // Changing the rules ends the set. Half a score under one pool and half
    // under another is not a score of anything, and a chord already on the
    // stand was dealt under settings that no longer hold.
    // The panel stays as it is: these changes are made from inside it.
    toReady()
  }
  /** The silent board the game starts from: no score, a fresh chord dealt but
      not sounded, the clock stopped and full. Every route back to the start goes
      through here, so the two that used to assemble it by hand cannot drift
      apart again — one of them was leaving the candle wherever it stood. */
  function toReady() {
    stopClock()
    resetSet()
    Object.assign(g, deal(g.settings, null), { phase: "ready" })
    setAudition(null)
    setLeft(1)
    bump()
  }
  /** Play whatever chord is on the stand and start its clock. */
  function play(chord: Chord) {
    g.round += 1
    Object.assign(g, { phase: "round", picked: null, correct: null })
    setAudition(null)
    setShowSetup(false)
    bump()
    sound(chord.notes)
    startClock(g.settings.clock)
  }
  /** Deal the next chord and play it in one move — Next never waits. */
  function dealAndPlay() {
    const d = deal(g.settings, g.chord)
    Object.assign(g, d)
    play(d.chord)
  }
  /** The opening move: the chord is already dealt, so this only sounds it. */
  function begin() {
    if (g.phase !== "ready") return
    setEverBegan(true)
    play(g.chord)
  }
  /** Wipe the set. Every route back to the start has to go through here — the
      results screen once cleared the score but left `history` standing, so the
      next set appended to the last one and summarize reported "15 of 10". */
  function resetSet() {
    Object.assign(g, {
      round: 0, right: 0, streak: 0,
      history: [], last: null, picked: null, correct: null,
    })
  }
  function again() {
    resetSet()
    dealAndPlay()
  }
  /** Back to a dealt-but-silent board, with the setup panel open. */
  const reconfigure = () => { toReady(); setShowSetup(true) }
  function resolve(i: number | null) {
    if (g.phase !== "round") return
    stopClock()
    const ms = Math.min(g.dur, performance.now() - g.t0)
    const correct = i !== null && i === g.answer
    g.streak = correct ? g.streak + 1 : 0
    if (correct) g.right += 1
    Object.assign(g, { picked: i, correct, phase: "reveal" })
    g.history.push({ chord: g.chord, options: g.options, answer: g.answer, picked: i, correct, ms })
    setLeft(1 - ms / g.dur)
    bump()
    springOnce()
    cue(correct)
    if (!correct) setTimeout(() => sound(g.chord.notes), 450)
  }
  function auditionOpt(i: number) {
    const q = g.options[i]
    if (!q) return
    const notes = optionNotes(q, g.chord)
    sound(notes)
    // It stays put. After the reveal you are examining, not being quizzed, and
    // a picture that snaps back to the answer after two seconds is no use for
    // comparing one chord against another. Play the right answer, or hit again,
    // to come back to what was actually sounded.
    setAudition({ i, notes })
    setPulse(p => p + 1)
    springOff()
  }
  const answer = (i: number) => {
    if (i >= g.options.length) return
    if (g.phase === "round") resolve(i)
    else if (g.phase === "reveal") auditionOpt(i)
  }
  function next() {
    if (g.phase !== "reveal") return
    setAudition(null)
    if (g.round >= ROUNDS) { g.last = summarize(g.history); g.phase = "results"; bump() }
    else dealAndPlay()
  }
  const replay = () => {
    if (g.phase !== "round" && g.phase !== "reveal") return
    setAudition(null)                       // back to the chord that was played
    sound(g.chord.notes)
  }

  const api = useRef({ begin, answer, next, replay, again })
  api.current = { begin, answer, next, replay, again }
  // The probe handler is registered once, so anything it reports that lives in
  // React state has to reach it through a ref refreshed on every render.
  const live = useRef({ audition, showSetup, pulse })
  live.current = { audition, showSetup, pulse }

  // Build the audio graph up front, suspended, so the first gesture has only to
  // resume it rather than construct it as well — and take that gesture as early
  // as the pointer going down, so the device is already warming while the click
  // that wants a chord is still being made.
  useEffect(() => {
    audio()
    const go = () => {
      unlock()
      window.removeEventListener("pointerdown", go, true)
      window.removeEventListener("keydown", go, true)
    }
    window.addEventListener("pointerdown", go, true)
    window.addEventListener("keydown", go, true)
    return () => {
      window.removeEventListener("pointerdown", go, true)
      window.removeEventListener("keydown", go, true)
    }
  }, [])

  useEffect(() => {
    const h = (e: KeyboardEvent) => {
      if (e.ctrlKey || e.metaKey || e.altKey) return
      const a = api.current
      if (/^[1-4]$/.test(e.key)) { a.answer(+e.key - 1); e.preventDefault(); return }
      // space hears it again, return moves the game on. The guard is for a
      // button that somehow still holds focus: space is its own click.
      if (e.key === " ") {
        if ((e.target as HTMLElement | null)?.tagName === "BUTTON") return
        e.preventDefault()
        a.replay()
        return
      }
      if (e.key === "Enter") {
        if ((e.target as HTMLElement | null)?.tagName === "BUTTON") return
        e.preventDefault()
        const p = G.current.phase
        if (p === "ready") a.begin()
        else if (p === "reveal") a.next()
        else if (p === "results") a.again()
      }
    }
    window.addEventListener("keydown", h)
    return () => { window.removeEventListener("keydown", h); cancelAnimationFrame(raf.current) }
  }, [])

  // terminal probes: `selftest`, `state`, `audio`, `start`, `next`, `{"answer":2}`,
  // `{"set":{"pool":"jazz"}}`, `{"sample":{"pool":"jazz","n":8}}`
  useEffect(() => tb.onMessage((m: unknown) => {
    const gg = G.current
    if (m === "selftest") return selftest()
    // What the audio device cost to open, and whether we could see it open at all
    if (m === "audio") return {
      state: ac?.state ?? null, warm, live: warmup.live, warmupMs: warmup.ms,
      rechecks: warmup.rechecks, keptAlive: !!alive,
      baseLatency: ac?.baseLatency ?? null, outputLatency: ac?.outputLatency ?? null,
    }
    /* What the candle resolved to, read off the element rather than off the
       props that were meant to reach it. The flame is driven by two custom
       properties written inline and consumed by calc() in the stylesheet, and
       every way that can fail — the prop not arriving, the property not being
       written, the calc being dropped — looks identical from up here. */
    if (m === "candle") {
      const el = document.querySelector<SVGElement>(".candle")
      if (!el) return null
      const cs = getComputedStyle(el)
      const sub = (sel: string, prop: string) => {
        const n = el.querySelector<SVGElement>(sel)
        return n ? getComputedStyle(n).getPropertyValue(prop) : null
      }
      /* What is on the screen, as opposed to what the cascade resolved to. A
         transform can compute correctly and still paint nothing if it is hung on
         a group the shape does not sit inside, and from up here the two look the
         same. This is the only reading of the two that can tell them apart. */
      const rect = (sel: string) => {
        const n = el.querySelector<SVGGraphicsElement>(sel)
        if (!n) return null
        const r = n.getBoundingClientRect()
        return { top: Math.round(r.top * 10) / 10, h: Math.round(r.height * 10) / 10 }
      }
      return {
        streak: gg.streak, phase: gg.phase,
        cls: el.getAttribute("class"),
        run: cs.getPropertyValue("--run"), flare: cs.getPropertyValue("--flare"),
        lift: cs.getPropertyValue("--lift"),
        amp: cs.getPropertyValue("--amp"), filter: cs.filter,
        flick: sub(".flamewrap", "animation-duration"),
        flareg: sub(".flareg", "transform"),
        flameRect: rect(".flame"), waxRect: rect(".wax"),
      }
    }
    if (m === "state") return {
      phase: gg.phase, round: gg.round, right: gg.right, streak: gg.streak,
      chord: symbolOf(gg.chord), notes: gg.chord.notes,
      options: gg.options.map(o => ({ label: o.name, quality: o.id, pcs: optionPcs(o, gg.chord) })),
      answer: gg.answer, picked: gg.picked, settings: gg.settings,
      // must never exceed ROUNDS: if it does, a set is running on top of an
      // old one and the summary will report more right answers than chords
      played: gg.history.length,
      audition: live.current.audition, setup: live.current.showSetup,
      // parity is what drives the audition spring; it has to flip on every click
      pulse: live.current.pulse,
    }
    if (m === "start") { api.current.begin(); return G.current.phase }
    if (m === "next") { api.current.next(); return G.current.phase }
    if (m === "again") { api.current.again(); return G.current.phase }
    if (m && typeof m === "object") {
      const o = m as Record<string, unknown>
      if (o.sample) {
        const ss: Settings = { ...DEFAULTS, ...(o.sample as Partial<Settings>) }
        const n = (o.sample as { n?: number }).n ?? 6
        let prev: Chord | null = null
        return Array.from({ length: n }, () => {
          const { chord: c, options, answer } = deal(ss, prev); prev = c
          return {
            chord: symbolOf(c), notes: spell(c).join(" "), answer,
            options: options.map(x => ({ label: x.name, quality: x.id, pcs: optionPcs(x, c) })),
          }
        })
      }
      if (typeof o.answer === "number") {
        api.current.answer(o.answer)
        /* `{"answer":n,"peak":900}` answers and then watches the leap. The flare
           tops out about 150ms in and a round trip back out to the terminal is
           slower than that, so how tall it actually got can only be measured
           from in here. Twice now a run has been declared visible on a computed
           value that was climbing correctly and painting something nobody could
           see; this reads the pixels instead. */
        const ms = typeof o.peak === "number" ? o.peak : 0
        const fl = document.querySelector<SVGGraphicsElement>(".candle .flame")
        if (ms > 0 && fl) return new Promise(res => {
          let peak = 0
          const t0 = performance.now()
          const tick = () => {
            peak = Math.max(peak, fl.getBoundingClientRect().height)
            if (performance.now() - t0 < ms) requestAnimationFrame(tick)
            else res({ correct: G.current.correct, peak: Math.round(peak * 10) / 10 })
          }
          requestAnimationFrame(tick)
        })
        return { correct: gg.correct }
      }
      if (typeof o.play === "number") { press(o.play); return noteName(o.play) }
      if (o.set) { setS(o.set as Partial<Settings>); return G.current.settings }
    }
    return undefined
  }), [])

  /* The chord everything on show follows: the one that was played, or the answer
     you clicked to hear against it.

     It is memoised because `Trace` keys its effect on the identity of the notes
     array it is handed. Rebuilt per render that array was new every time, so any
     unrelated re-render restarted the swell — an arpeggio fires two state updates
     per note, and the picture re-sprang a dozen times while one chord played.

     And it sits above the results screen's early return because hooks cannot be
     conditional. Below it, the results screen renders one hook fewer than the
     board does and React refuses the update. */
  const heard = useMemo(
    () => audition ? optionChord(g.options[audition.i], g.chord) : g.chord,
    [audition, g.chord, g.options],
  )

  /* ---------- the scorecard as a file ----------
     A canvas kept off the page, drawn the moment a set ends and turned into a
     blob straight after, so the share button has an image already in hand when
     it is pressed: a browser only honours a clipboard write made inside the
     click that asked for it, and work done in the middle is work the click may
     not survive. Same reason as `heard` above for living up here: hooks cannot
     be conditional, and the results screen returns early. */
  const shotRef = useRef<HTMLCanvasElement | null>(null)
  const shotBlob = useRef<Blob | null>(null)
  const [shotSaid, setShotSaid] = useState<"" | "copied" | "saved" | "failed">("")
  useEffect(() => {
    const cv = shotRef.current
    if (g.phase !== "results" || !cv) return
    drawShot(cv, shotOf(G.current))
    shotBlob.current = null
    cv.toBlob(b => { shotBlob.current = b }, "image/png")
  }, [g.phase, g.last])
  // whatever the button last reported goes quiet again
  useEffect(() => {
    if (!shotSaid) return
    const t = window.setTimeout(() => setShotSaid(""), 2400)
    return () => window.clearTimeout(t)
  }, [shotSaid])

  /** No clipboard for pictures: leave it in the downloads folder instead. */
  const saveShot = (b: Blob) => {
    try {
      const url = URL.createObjectURL(b)
      const a = document.createElement("a")
      a.href = url; a.download = SHOT_FILE; a.click()
      // not immediately: the download reads the url after the click returns
      window.setTimeout(() => URL.revokeObjectURL(url), 10000)
      setShotSaid("saved")
    } catch { setShotSaid("failed") }
  }

  function shareShot() {
    const cv = shotRef.current
    if (!cv) return
    const ready = shotBlob.current
    const file = ready ? new File([ready], SHOT_FILE, { type: "image/png" }) : null
    /* A device you hold has somewhere to send a file, and its share sheet is the
       whole point of the button there. A desktop technically has one too — and
       Windows Chrome will happily say so — but it is a slow dialog next to the
       clipboard, which is what a scorecard is for: paste it into whatever you
       were already typing in. So the sheet is offered to a coarse pointer only,
       the clipboard everywhere else, and the downloads folder to a browser that
       will not put a picture on one. */
    const handheld = window.matchMedia?.("(pointer: coarse)").matches
    if (handheld && file && navigator.canShare?.({ files: [file] })) {
      navigator.share({ files: [file] }).catch(() => { /* you changed your mind */ })
      return
    }
    // The blob goes to ClipboardItem as a promise rather than being awaited
    // first, for the same reason the drawing happens ahead of time: awaiting it
    // puts the write outside the click, and Safari then refuses it.
    const blob = ready ? Promise.resolve(ready)
      : new Promise<Blob>((ok, no) => cv.toBlob(b => b ? ok(b) : no(new Error("no image")), "image/png"))
    const fail = () => setShotSaid("failed")
    if (typeof ClipboardItem === "undefined" || !navigator.clipboard?.write) {
      blob.then(saveShot, fail)
      return
    }
    navigator.clipboard.write([new ClipboardItem({ "image/png": blob })])
      .then(() => setShotSaid("copied"), () => blob.then(saveShot, fail))
  }

  /* ---------- the end of a set ---------- */
  if (g.phase === "results") {
    const last = g.last!
    // A scorecard meant to be screenshotted: it names itself, states the
    // settings it was played under, and shows all ten chords as the shapes the
    // rest of the bulb speaks in — green for the ones you called, red for the
    // ones you did not.
    return (
      <div className="wrap">
        <div className="card" role="group" aria-label="score">
          <div className="brand">The Coltrainer</div>
          <div className="tally"><b>{last.correct}</b><i>of {ROUNDS}</i></div>
          <div className="under">{setupLine(s)}</div>

          <ol className="grid" aria-label="every chord in the set">
            {g.history.map((r, i) => (
              <li key={i} className={"cell " + (r.correct ? "good" : "bad")}>
                <Wheel pcs={pcsOf(r.chord.notes)} root={r.chord.rootPc}
                  size={72} tone={r.correct ? "good" : "bad"} />
                <b>{symbolOf(r.chord)}</b>
                <span className="said">{saidOf(r)}</span>
              </li>
            ))}
          </ol>

          {/* nothing right means nothing to average, and an empty ruled strip
              reads as something that failed to load */}
          {last.avgMs !== null && (
            <div className="under foot">{fmtSec(last.avgMs)} average when right</div>
          )}
        </div>
        {/* the same card at a fixed size, for handing over as a picture — drawn,
            never shown. Named rather than hidden: it is a real picture of the
            results, and a name is what makes it readable back from the terminal
            (`typebulb send … tb:png`) when the drawing needs checking. */}
        <canvas className="shot" ref={shotRef} role="img" aria-label="scorecard picture" />
        <div className="go">
          <Tap className="act" onClick={tap(reconfigure)} title="settings" aria-label="settings">
            <Sliders />
          </Tap>
          {/* Keeps the focus it is given: the button is where the answer appears,
              and with no words in it the answer is the mark going green or red. */}
          <Tap className={"act" + (shotSaid === "failed" ? " miss" : shotSaid ? " done" : "")}
            onClick={shareShot}
            title={shotSaid === "copied" ? "copied" : shotSaid === "saved" ? "saved"
              : shotSaid === "failed" ? "no luck" : "a picture of this scorecard"}
            aria-label="a picture of this scorecard">
            {shotSaid === "failed" ? <Cross /> : shotSaid ? <Tick /> : <Camera />}
          </Tap>
          <Tap className="act lead" onClick={tap(again)} title="play again (↵)" aria-label="play again">
            <Play />
          </Tap>
        </div>
      </div>
    )
  }

  /* ---------- in play ---------- */
  const c = g.chord
  const reveal = g.phase === "reveal"
  const ready = g.phase === "ready"
  // seconds left, unrounded: the candle rounds it for the digits it shows and
  // keeps the fraction, which is what lets the last one dim out rather than blink
  const secs = Math.max(0, left * s.clock)
  const shown = heard.notes
  // computed once: the stylesheet gets it as `--run`, the candle as a prop
  const run = runOf(g.streak)

  return (
    /* `--run` lands on the root rather than inside the candle so there is one
       place it is computed; everything downstream reads it by inheritance. */
    <div className="wrap playing" style={{ "--run": run } as React.CSSProperties}>
      <div className="hud">
        {/* "Score 5 · 6 of 10". The score leads, because it is the reading you
            come here for; the round is where you are in the set, so it sits
            second and carries no label of its own. "Chord" named the one thing
            the whole page is already about, and beside "Right" it read as if
            the two counted the same things. */}
        <span>Score <b>{g.right}</b></span>
        <span className="muted" aria-hidden="true">·</span>
        <span><b>{Math.max(1, g.round)}</b><span className="muted"> of {ROUNDS}</span></span>
        {/* No streak readout here. The flame says it, in colour and in height,
            and a number saying the same thing beside it is the page reading its
            own picture aloud. It also came and went as a run started and broke,
            which was the one thing in this row that moved. */}
        {/* the status line is the way in to the settings — there is no screen
            in front of the game asking you to configure it first */}
        <Tap className={"setupbtn push" + (showSetup ? " on" : "")}
          title="settings" aria-label="settings"
          onClick={tap(() => setShowSetup(v => !v))}>
          {/* seconds rather than "medium" here: next to the pool name, a second
              word like "medium" reads as a second difficulty */}
          <span className="txt">
            {POOLS.find(p => p.id === s.pool)!.label.toLowerCase()}{s.inversions ? " · inversions" : ""} · {s.clock}s
          </span>
          {/* the settings the chip reports are the settings it opens, so the
              mark rides inside it rather than standing as a button of its own */}
          <Sliders />
        </Tap>
      </div>

      {/* setup takes the page rather than pushing the game down it */}
      {showSetup ? (
        <>
          <div className="settings" role="group" aria-label="setup">
            <label>Chords</label>
            <Seg label="chords" value={s.pool} onChange={v => setS({ pool: v })}
              items={POOLS.map(p => ({ v: p.id, label: p.label }))} />
            <span />
            <span className="poolnote">{poolOf(s.pool).map(symOf).join(" · ")}</span>
            <label>Voicing</label>
            <Seg label="voicing" value={s.inversions} onChange={v => setS({ inversions: v })} items={VOICINGS} />
          <label>Speed</label>
          <Seg label="speed" value={s.clock} onChange={v => setS({ clock: v })} items={SPEEDS} />
          </div>
          <div className="go">
            <Tap className="btn" onClick={tap(() => setShowSetup(false))}>Done</Tap>
          </div>
        </>
      ) : (
       <>

      {/* Every slot below is sized for its fullest state and rendered in both,
          so nothing on the page moves when the call is made. The wheel starts as
          an empty clock face; the keys start unlit; the option shapes start
          hidden rather than absent. */}
      <div className="stage">
        {/* the wheel follows whatever is sounding: the chord, or the answer you
            clicked to hear against it */}
        <Wheel pcs={reveal ? pcsOf(shown) : []} root={reveal ? heard.rootPc : null}
          hits={pressed} size={150} labels />
        <div className="pane">
          <Trace notes={ready ? NO_NOTES : shown} strike={strike} mode={reveal ? "shape" : "heard"} parts={reveal} />
        </div>
        {/* Name it and the wax stops where it is and keeps burning, so the flame
            left standing is the record of how fast you were, and it leaps once
            for the naming. Miss it, or let the clock run out, and it gets
            snuffed, taking the run's accumulated light with it. */}
        <Candle frac={ready ? 1 : left} secs={ready ? s.clock : secs}
          out={reveal && !g.correct} won={reveal && !!g.correct} streak={g.streak}
          low={!ready && left < 0.28} />
      </div>

      <div className="prompt">
        <div className="line1">
          {reveal ? (
            <>
              <b className="sym">{symbolOf(heard)}</b>
              <span className="spelled">{spell(heard).join(" · ")}</span>
              <Tap className="mini"
                title="play the notes one at a time"
                aria-label="play the notes one at a time"
                onClick={tap(() => arpeggio(shown))}>
                {/* The two long sides average out to the left of the apex, so a
                    triangle centred on its bounding box reads as sitting left of
                    centre. These vertices put its centroid on 6 instead. */}
                <svg viewBox="0 0 12 12" width="16" height="16" aria-hidden="true">
                  <path d="M3.4 1.2 L11.2 6 L3.4 10.8 Z" />
                </svg>
              </Tap>
            </>
          ) : ready ? (
            /* The opening line is a question rather than the rules, and it
               points at the one control that answers it: arriving on a pool
               that is too easy or too hard is the way this gets closed, and
               the settings chip up in the corner was not being found. */
            <span className="ask">
              How good is your ear? Change difficulty{" "}
              <button type="button" className="asklink"
                onClick={tap(() => setShowSetup(true))}>here</button>.
            </span>
          ) : (
            <span className="ask">What chord was that?</span>
          )}
        </div>
      </div>

      {/* Out of the question while the question stands: with a chord sounding and
          the call unmade, picking notes off the keyboard until one matches is not
          naming the chord, it is looking up the answer. Before the set starts it
          is yours to noodle on, and after the call it is where the answer is
          shown, so both of those leave it live. */}
      <Keyboard lit={reveal ? shown : []} root={reveal ? heard.rootPc : null}
        pressed={pressed} onPlay={ready || reveal ? press : undefined} />

      <div className={"options" + (reveal ? " revealed" : "") + (ready ? " waiting" : "")} role="group" aria-label="options">
        {g.options.map((o, i) => {
          let cls = "opt"
          if (reveal) cls += i === g.answer ? " good" : i === g.picked ? " bad" : " dim"
          if (reveal && sprung && (i === g.answer || i === g.picked)) cls += " sprung"
          if (audition?.i === i) cls += " playing" + (pulse % 2 ? " pulseA" : " pulseB")
          return (
            <button key={i} className={cls} onClick={tap(() => answer(i))}
              aria-label={reveal ? `hear ${o.name}` : o.name}>
              <kbd>{i + 1}</kbd>
              <span className="lbl">{o.name}</span>
              <Wheel pcs={optionPcs(o, c)} root={c.rootPc} size={34} dim={i !== g.answer} />
            </button>
          )
        })}
      </div>

      <div className="go">
        {reveal && g.round >= ROUNDS ? (
          /* The set is over. There is nothing left to hear again that the score
             card will not show you anyway, and nowhere to go but the score, so
             the pair of marks gives way to the one thing left to do. */
          <Tap className="btn" onClick={tap(next)} title="the score (↵)">Results</Tap>
        ) : (<>
        {ready ? (
          <Tap className={"act lead" + (everBegan ? "" : " nudge")}
            onClick={tap(begin)} title="start (↵)" aria-label="start">
            <Play />
          </Tap>
        ) : (
          <Tap className="act" onClick={tap(replay)} title="hear it again (space)" aria-label="hear it again">
            <Note />
          </Tap>
        )}
        {/* Always standing, so the row never changes shape under the pointer.
            There is nothing to move on to until the call is made, so until then
            it is dimmed and out of reach rather than absent. */}
        <Tap className="act lead" disabled={!reveal}
          onClick={tap(next)} title="next chord (↵)" aria-label="next chord">
          <Barline />
        </Tap>
        </>)}
      </div>
       </>
      )}
    </div>
  )
}

createRoot(document.getElementById("root")!).render(<App />)
```
**styles.css**

```css
/* One light, one dark room, one accent. The page is meant to be quiet: the only
   things that move are the candle flame and the trace while the chord rings. */
html { color-scheme: dark; }
/* Not redundant with the line above: the host writes its own
   html[data-theme="light"] { color-scheme: light } on top of this stylesheet,
   and a bare `html` selector loses to it. This matches its specificity so the
   room stays dark whichever theme the reader is in. */
html[data-theme="light"] { color-scheme: dark; }

:root {
  --bg: #0e0d0c;
  --pane: #151312;
  --ink: #f2e9dc;
  --ink2: #bfb3a2;
  --ink-hi: #fff3e0;      /* brighter than --ink: the score, the chord symbol */
  --ink-warm: #ffe8c4;    /* the scorecard's chord names */
  --muted: #8b8073;
  /* Four colours are spent at more than one strength, so each is written once
     as an rgb triple and never again. A hand-copied rgba() of a colour that
     already has a name is how a tint and its solid drift apart, and this sheet
     had five of them. The candle's `--glow` has always worked this way; the
     rest of the page is now catching up.

     The rule that keeps it that way: BELOW THIS BLOCK, NO RULE WRITES A RAW
     CHANNEL NUMBER. One that does is a strength the design does not have yet,
     and either it earns a name here or it collapses into one that exists.
     (The candle keeps its own palette and is the one exception.) */
  --accent-rgb: 231, 167, 101;
  --good-rgb: 134, 213, 154;
  --bad-rgb: 255, 143, 121;
  --tint-rgb: 255, 224, 190;   /* the warm film every surface and line is made of */
  --accent: rgb(var(--accent-rgb));
  --accent-soft: rgba(var(--accent-rgb), 0.13);
  --good: rgb(var(--good-rgb));
  --good-soft: rgba(var(--good-rgb), 0.13);
  --bad: rgb(var(--bad-rgb));
  --bad-soft: rgba(var(--bad-rgb), 0.13);
  /* The tint's five steps, which are all the states the design distinguishes:
     a control at rest and under the pointer, a line, a line that carries
     weight, and a line under the pointer. This was nine alphas, and the extra
     four were drift rather than intent. */
  --fill: rgba(var(--tint-rgb), 0.06);
  --fill-hi: rgba(var(--tint-rgb), 0.14);
  --hair: rgba(var(--tint-rgb), 0.16);
  --hair2: rgba(var(--tint-rgb), 0.3);
  --hair-hi: rgba(var(--tint-rgb), 0.45);
  --serif: Georgia, "Iowan Old Style", "Times New Roman", serif;
  --sans: system-ui, -apple-system, "Segoe UI", sans-serif;
  /* Naming the answer changes six things at once, and they only read as a single
     event if they move together. `--ease` carries the fades. But a fade has no
     momentum to feel no matter what curve you put on it — that is why the reveal
     read as dead however it was tuned. Weight comes from something moving, so
     the pieces that arrive on the reveal scale in on the `spring` keyframes
     below: past the mark, back under it, and to rest. */
  --ease: cubic-bezier(0.16, 1, 0.3, 1);
  --reveal: 0.42s;
  --spring: 0.55s;
}

* { box-sizing: border-box; }
/* Centred in the window rather than hung from the top: the board is one compact
   object, and a band of dead space under it read as a page that had not
   finished loading.

   `safe` is the load-bearing word. Plain centring pushes the overflow of
   anything taller than the window off BOTH edges, and there is no scrolling
   back up to a top that has gone above zero. `safe` falls back to top-aligned
   at exactly the point that would start happening, which on a short window is
   the only answer. And `min-height`, not `height`, so the content's own height
   is always the floor and this can only ever add space, never take it. */
body {
  background: var(--bg);
  min-height: 100dvh;
  display: grid;
  align-content: safe center;
}

.wrap {
  max-width: 680px;
  margin: 0 auto;
  padding: 30px 20px 36px;
  font: 17px/1.55 var(--sans);
  color: var(--ink);
  /* the run, 0..1, written over inline by the board; the results screen and the
     setup panel keep the default and stay unlit */
  --run: 0;
}
.muted { color: var(--muted); }
kbd {
  font: inherit; font-size: 13px; line-height: 1.4; padding: 1px 5px;
  border: 1px solid var(--hair); border-radius: 4px; color: var(--muted); margin-left: 4px;
}

/* the two shapes */
.stage { display: flex; align-items: center; gap: 14px; margin: 0 0 12px; }
.stage > .wheel { flex: none; }
.pane {
  flex: 1; min-width: 0;
}
.trace { display: block; width: 100%; height: 120px; }

.wheel .rim { fill: none; stroke: var(--hair); stroke-width: 1; }
.wheel .tick { fill: var(--hair); }
.wheel .poly { fill: var(--accent-soft); stroke: var(--accent); stroke-width: 1.6; stroke-linejoin: round; }
.wheel .tone { fill: var(--accent); }
.wheel .tone.root { fill: var(--ink); stroke: var(--accent); stroke-width: 1.4; }
.wheel .hit { fill: none; stroke: var(--ink); stroke-width: 2.2; }
.wheel .wl { font: 600 11px var(--sans); fill: var(--muted); text-anchor: middle; dominant-baseline: central; }
.wheel .wl.on { fill: var(--ink); }
.wheel.dim { opacity: 0.55; }
.wheel.dim .poly { fill: none; }
.wheel.good .poly { fill: var(--good-soft); stroke: var(--good); }
.wheel.good .tone { fill: var(--good); }
.wheel.good .tone.root { fill: var(--ink); stroke: var(--good); }
.wheel.bad .poly { fill: var(--bad-soft); stroke: var(--bad); }
.wheel.bad .tone { fill: var(--bad); }
.wheel.bad .tone.root { fill: var(--ink); stroke: var(--bad); }

/* the candle, which is the clock */
.candle {
  width: 62px; height: 114px; flex: none; display: block;
  /* A default for the one the component writes inline, so a keyframe that reads
     it is never left holding an invalid value and dropped whole. `--run` is NOT
     defaulted here on purpose: declaring it would shadow the board's. */
  --flare: 0;
  /* the flame's own colour as an rgb triple, so the glow it throws can be the
     same colour it is without being written out twice per tier */
  --glow: 255, 150, 50;
  /* the tier's standing height, stepped rather than ramped */
  --tall: 1;
  /* The leap, sized. `--flare` is the wax that was left, which is the Candle's
     own business; `--run` is how the set is going, which is the board's. They
     meet here and nowhere else, so neither has to know about the other. Range
     runs 0.21 (a slow call, cold) to 1.1 (a fast one, ten in a row). Held back
     from where it was, because it now compounds with a flame already up to
     1.9x tall and the two together have to clear the top of the box. */
  --lift: calc(var(--flare) * (0.6 + 0.5 * var(--run)));
  /* how hard it flickers, which is the inverse of the run */
  --amp: calc(1 - 0.6 * var(--run));
  /* The close glow on the wick, near enough left alone by the run: it opened up
     by 16px over a set at one point and all that bought was a bigger blur where
     a bigger flame was wanted. Snuffed, it drops to `none`, and a filter list
     interpolating to none fades rather than cutting, so it goes out over the
     first half second of the smoke. */
  filter: drop-shadow(0 0 calc(16px + 6px * var(--run))
    rgba(var(--glow), calc(0.28 + 0.1 * var(--run))));
  transition: filter 0.55s var(--ease);
  /* the leap stands taller than the box has room for at the top of the clock,
     the same way the plume does at the bottom of it */
  overflow: visible;
}
/* a lit column, so the flanks fall off to either side and the pool at the top
   catches the flame */
.candle .wax { fill: url(#waxfill); }
.candle .waxtop { fill: #fff8e8; }
.candle .waxlip { fill: none; stroke: #b8a887; stroke-width: 0.8; }
.candle .flame { fill: #ffd27a; }
/* Hand-tuned, and a shade lighter than the glow it sits inside; the tiers below
   take theirs from `--glow` instead, so a tier is one triple and not two. */
.candle .halo { fill: rgba(255,170,70,0.3); }
.candle .secs {
  font: 700 22px var(--sans); fill: #1c1309; text-anchor: middle; dominant-baseline: central;
  font-variant-numeric: tabular-nums; opacity: 0.6;
  /* once it is off the wax it hangs over the flame, so it carries the room's
     own colour behind it as a backing. On the wax that backing would be a black
     outline on cream, so it is held at zero and faded in with everything else. */
  paint-order: stroke; stroke: var(--bg); stroke-width: 3.4; stroke-opacity: 0;
  transition: fill 0.7s linear, opacity 0.7s linear, stroke-opacity 0.7s linear;
}
/* out of wax and out of time: the number climbs on the clock's own frames, and
   the colour catches up over the first of them */
.candle .secs.free { fill: var(--bad); opacity: 1; stroke-opacity: 1; }
/* A long run burns steadier as well as taller: the flicker loses better than
   half its amplitude across ten and slows to match. That reads as composure
   without spending another colour or another mark on the page. */
.candle .flamewrap { animation: flick calc(0.45s + 0.22s * var(--run)) infinite alternate ease-in-out; }
/* Longer as the run grows, so a big leap is a longer event and not only a taller
   one: the flame is up there for about a third of a second at the top of a set,
   against a blink at the start of one. */
.candle.won .flareg { animation: flare calc(0.9s + 0.55s * var(--run)) linear; }
/* The tier's height. Transitioned rather than snapped so the step arrives as the
   flame growing into it over a third of a second, which lands under the leap
   that fires at the same instant. */
.candle .stand {
  transform: scaleY(var(--tall));
  transition: transform 0.35s var(--ease);
}
.candle .sparks { pointer-events: none; }
.candle .spark { fill: #ffd8a2; animation: spark 1.1s ease-out both; }
/* dark by default, so it costs nothing on the page until the leap lifts it */
.candle .core { fill: #fff4dd; opacity: 0; }
.candle.won .core { animation: flarecore 1s linear; }
.candle.low .flame { fill: #ff8a52; }
.candle.low .halo { fill: rgba(255,90,40,0.36); }
/* Five in a row turns the flame green, eight blue, ten purple. These sit after
   `.low` and match its one-class depth, so they win it: on a long run the flame
   keeps its colour even as the clock runs down. That costs the clock less than
   it looks, because the loud half of the low-time cue is the seconds climbing
   off the wax and going red, which is untouched. */
.candle.run5 { --glow: 88, 226, 140; --tall: 1.4; }
.candle.run5 .flame { fill: #7cf0a2; }
.candle.run5 .spark { fill: #a6f7c2; }
.candle.run8 { --glow: 72, 168, 255; --tall: 1.65; }
.candle.run8 .flame { fill: #78c9ff; }
.candle.run8 .spark { fill: #a8dcff; }
.candle.run10 { --glow: 178, 120, 255; --tall: 1.9; }
.candle.run10 .flame { fill: #c99bff; }
.candle.run10 .spark { fill: #dcc0ff; }
/* each tier's halo is its own glow colour, so the tier names that colour once */
.candle.run5 .halo, .candle.run8 .halo, .candle.run10 .halo { fill: rgba(var(--glow), 0.33); }
/* snuffed, there is nothing left to light the room with */
.candle.out { filter: none; }
.candle .wick { stroke: #2a2118; stroke-width: 2; stroke-linecap: round; }
/* three strokes read as three strokes until they are blurred into one body */
.candle .smoke { filter: blur(1.1px); }
.candle .wisp {
  /* the shape is computed per frame in Candle; the gradient does the thinning */
  fill: none; stroke: url(#smokefade); stroke-width: 4.4;
  stroke-linecap: round; stroke-linejoin: round;
}
.candle .wisp.two { stroke-width: 2.9; }
.candle .wisp.three { stroke-width: 1.8; }
@keyframes flick {
  from { transform: scale(calc(1 - 0.06 * var(--amp))) rotate(calc(-2deg * var(--amp))); }
  to   { transform: scale(calc(1 + 0.06 * var(--amp))) rotate(calc(2deg * var(--amp))); }
}

/* The breath. It leaps inside a sixth of a second, ducks under its own height
   as the surge passes, comes back up short of the first peak, and lands.
   `--flare` scales the whole gesture, so one set of stops carries both a jump
   and the barest lift. Played `linear` for the same reason as the springs
   below: the stops carry the curve. */
@keyframes flare {
  0%   { transform: scale(1, 1); }
  11%  { transform: scale(calc(1 + 0.14 * var(--lift)), calc(1 + 0.50 * var(--lift))); }
  25%  { transform: scale(calc(1 + 0.12 * var(--lift)), calc(1 + 0.43 * var(--lift))); }
  46%  { transform: scale(calc(1 + 0.03 * var(--lift)), calc(1 + 0.08 * var(--lift))); }
  66%  { transform: scale(calc(1 + 0.02 * var(--lift)), calc(1 + 0.16 * var(--lift))); }
  86%  { transform: scale(1, calc(1 + 0.06 * var(--lift))); }
  100% { transform: scale(1, 1); }
}
@keyframes flarecore {
  0%   { opacity: 0; }
  10%  { opacity: calc(0.95 * var(--lift)); }
  30%  { opacity: calc(0.7 * var(--lift)); }
  60%  { opacity: calc(0.2 * var(--lift)); }
  100% { opacity: 0; }
}
/* An ember leaves the wick, rises, slows, and goes out. `--dx`/`--dy` are bare
   numbers so they can be read as user units here and as a count in the sparks'
   own arithmetic there. */
@keyframes spark {
  0%   { transform: translate(0, 0) scale(0.3); opacity: 0; }
  14%  { opacity: 1; }
  70%  { opacity: 0.85; }
  100% {
    transform: translate(calc(var(--dx) * 1px), calc(var(--dy) * 1px)) scale(0.25);
    opacity: 0;
  }
}

/* A damped oscillation, written out as keyframes and played `linear` so the
   stops carry the curve rather than an easing function flattening it. Three
   crossings is enough to read as sprung; more reads as wobble. */
@keyframes pop {
  0%   { transform: scale(0.55); }
  40%  { transform: scale(1.10); }
  62%  { transform: scale(0.955); }
  80%  { transform: scale(1.022); }
  92%  { transform: scale(0.992); }
  100% { transform: scale(1); }
}
@keyframes rise {
  0%   { transform: translateY(10px) scale(0.90); opacity: 0; }
  45%  { transform: translateY(-2.5px) scale(1.035); opacity: 1; }
  68%  { transform: translateY(0.8px) scale(0.988); }
  85%  { transform: translateY(-0.3px) scale(1.006); }
  100% { transform: none; }
}
@keyframes settle {
  0%   { transform: scale(0.94); }
  42%  { transform: scale(1.028); }
  66%  { transform: scale(0.99); }
  84%  { transform: scale(1.006); }
  100% { transform: scale(1); }
}
/* Two names, one shape. CSS gives no way to replay an animation whose name has
   not changed, so an audition alternates between these and the name flips on
   every click. They are deliberate duplicates, not a copy-paste slip. */
@keyframes pulseA {
  0%   { transform: scale(0.94); }
  42%  { transform: scale(1.028); }
  66%  { transform: scale(0.99); }
  84%  { transform: scale(1.006); }
  100% { transform: scale(1); }
}
@keyframes pulseB {
  0%   { transform: scale(0.94); }
  42%  { transform: scale(1.028); }
  66%  { transform: scale(0.99); }
  84%  { transform: scale(1.006); }
  100% { transform: scale(1); }
}
@keyframes tapA {
  0%   { transform: scale(0.80); }
  38%  { transform: scale(1.14); }
  60%  { transform: scale(0.96); }
  80%  { transform: scale(1.025); }
  100% { transform: scale(1); }
}
@keyframes tapB {
  0%   { transform: scale(0.80); }
  38%  { transform: scale(1.14); }
  60%  { transform: scale(0.96); }
  80%  { transform: scale(1.025); }
  100% { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
  /* The standing height and the room's light stay: those are states rather than
     motion, and they are what a run is paid in. What goes is the leap, the
     flicker, and the ring under Start. */
  .candle .flamewrap, .candle.won .flareg, .candle.won .core,
  .candle .spark, .act.nudge { animation: none; }
  .candle .stand { transition: none; }
  /* with no animation to carry them off the wick they would sit on it */
  .candle .sparks { display: none; }
  .opt .wheel, .line1 > *, .opt.sprung,
  .opt.pulseA, .opt.pulseB, .tapA, .tapB { animation: none; }
}

/* in play — slot heights are fixed so the reveal never shifts anything */
/* `align-items` is the load-bearing part. Left to its `stretch` default the
   plain spans grow to the height of the tallest thing on the row, which is the
   settings chip at 31px, and text in a stretched box sits at the top of it
   while the chip centres its own text inside the same 31px. So the chip read
   about 4px lower than the counts beside it, and the drop tracked whatever was
   making the row tall rather than anything about the chip. Centred, every
   item's text centre lands on the line's, and the row reads as one line. */
/* Never wraps. Wrapping is decided on what an item WANTS to be, so a chip that
   wants 250px takes a whole second row the moment the counts and the gaps and
   its own 26px of padding come to more than the column, and it does that while
   there is still plenty of room for what is actually printed on it. Dropping a
   line to save fifteen pixels is the "too enthusiastic" part. Held to one line,
   the chip is the only thing here that can give, and it gives width: its label
   truncates from the right and the mark stays, which on a narrow window costs a
   word off a readout that is also spelled out in the panel it opens. */
.hud {
  display: flex; flex-wrap: nowrap; align-items: center; gap: 6px 10px;
  font-size: 16px; color: var(--ink2); margin: 0 0 16px; min-height: 22px;
}
/* the counts hold their size; they are the row's fixed content */
.hud > span { white-space: nowrap; flex: none; }
/* A word space is too tight between a label and the count it introduces: the
   two read as one token. The gap after the number, before " of 10", stays a
   plain space, because those two belong together, and the round's own number
   leads its span with no label in front of it to be held off. */
.hud b { color: var(--ink); font-weight: 600; font-variant-numeric: tabular-nums; margin-left: 3px; }
.hud span > b:first-child { margin-left: 0; }
.hud .push { margin-left: auto; }
/* No box at all: a status readout that underlines when you point at it, the
   same gesture as the opening line's link. Dropping the border and the
   horizontal padding is also what squares the row, because the ink's right edge
   is now the box's right edge. It used to be inset 13px behind it and the chip
   was outdented by exactly that much to compensate, which worked but left the
   box overhanging into the page gutter whenever it lit up. */
.setupbtn {
  font: 500 16px/1 var(--sans); padding: 7px 0; cursor: pointer;
  border: 0; background: none;
  color: var(--muted); transition: color 0.15s;
  display: inline-flex; align-items: center; gap: 9px;
  /* the one item on the row allowed to shrink, and `min-width: 0` is what lets
     it shrink past its own content instead of forcing the overflow outward */
  min-width: 0;
}
.setupbtn:hover { color: var(--ink2); }
/* the panel is open, so the chip holds the hover it was clicked with */
.setupbtn.on { color: var(--ink); }
/* Centring a line of text centres its em box, and an em box has room under the
   baseline for descenders this string does not have — so the ink lands low while
   the mark beside it, which fills its own box, lands true. The px is the gap
   between those two centres, off the layout so it cannot change the row height. */
.setupbtn .txt {
  position: relative; top: -1px;
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  /* Room under the baseline for the underline, which is otherwise clipped away
     entirely: the button sets `line-height: 1`, so the box ends at the em edge
     with the descenders already hanging outside it, and `overflow: hidden` —
     which the ellipsis above requires — cuts everything past that. The padding
     extends the box that gets clipped; the negative margin takes the same
     amount back off the box that gets laid out, so the row's height and the
     text's alignment against the mark are exactly as they were. */
  padding-bottom: 6px; margin-bottom: -6px;
}
/* hud scale, not disc scale */
.setupbtn .mark { width: 17px; height: 17px; flex: none; }
/* before the first chord sounds the names are there to read, not to pick */
.options.waiting .opt { cursor: default; opacity: 0.62; }
/* held at the resting fill, which is the whole point: it must not light up */
.options.waiting .opt:hover { background: var(--fill); }

.prompt { margin: 6px 0 14px; }
.line1 {
  display: flex; align-items: center; justify-content: center;
  gap: 14px; flex-wrap: wrap; min-height: 42px; text-align: center;
}
/* The prompt swaps its whole contents on the call. The slot height is fixed, so
   the new pieces can travel on their way in rather than cutting. */
.line1 > * { animation: rise var(--spring) linear both; }
.ask { font: 600 21px/1.3 var(--sans); }
/* Two things on this page are a phrase you can click: the opening line's "here"
   and the settings chip. One underline between them, so a run of clickable text
   is a single gesture the reader learns once. On the chip it goes on `.txt`
   rather than on the button, because an inline-flex box does not pass a
   decoration down to its flex items, and the mark should not be underlined
   anyway. */
/* An action, so it is a button; it looks like a link because a phrase in the
   middle of a sentence should. Being a real button also means the page's
   keyboard handler already steps aside for it: Enter and space go to the
   control that has focus rather than to the game. */
.asklink {
  font: inherit; color: var(--accent); background: none; border: 0; padding: 0;
  cursor: pointer;
}
/* `text-decoration-line`, never the `text-decoration` shorthand: the shorthand
   resets the colour to `currentColor`, and this rule sits after the ones that
   set it, so it would quietly paint every underline on at rest. */
.asklink, .setupbtn .txt {
  text-decoration-line: underline; text-underline-offset: 3px;
  text-decoration-thickness: 1px; text-decoration-color: transparent;
  transition: text-decoration-color 0.15s;
}
.asklink:hover, .setupbtn:hover .txt, .setupbtn.on .txt {
  text-decoration-color: var(--accent);
}
.mini {
  display: inline-flex; align-items: center; justify-content: center;
  width: 32px; height: 32px; padding: 0; border-radius: 50%;
  color: var(--ink2);
}
/* Pressed, then sprung: the transform answers the finger going down, the
   keyframes answer the click. Two names for the same reason as `pulse` above. */
.mini:active { transform: scale(0.88); }
/* the spring itself, on whatever `Tap` wraps: the transform answers the finger
   going down, the keyframes answer the click */
.tapA { animation: tapA var(--spring) linear; }
.tapB { animation: tapB var(--spring) linear; }
.mini svg { fill: currentColor; display: block; }
.sym { font: 700 32px/1.1 var(--serif); color: var(--ink-hi); }
.spelled { font: 600 17px var(--sans); color: var(--accent); letter-spacing: 0.6px; }

/* keyboard */
.kb {
  width: 100%; height: auto; display: block; margin: 4px 0 14px;
  touch-action: manipulation; user-select: none;
  transition: opacity var(--reveal) var(--ease);
}
/* nothing lit on it until the call is made, so it stays back until then */
.kb.dim { opacity: 0.5; }
/* Shorter than the reveal: the keys also carry the press flash, which wants to
   stay crisp. */
.wk { fill: #ddd5c6; stroke: #2b2621; stroke-width: 1; transition: fill 0.18s var(--ease); }
.bk { fill: #18150f; stroke: #2b2621; stroke-width: 1; transition: fill 0.18s var(--ease); }
.wk.on { fill: var(--accent); }
.bk.on { fill: #d5792a; }
.wk.live, .bk.live { cursor: pointer; }
.wk.live:hover { fill: #f0e9da; }
.bk.live:hover { fill: #332c22; }
.wk.on.live:hover, .bk.on.live:hover { fill: #ffc478; }
.wk.press, .bk.press, .wk.on.press, .bk.on.press { fill: #ffe6ac; transition: fill 0.05s; }
.kl { font: 12px var(--sans); fill: #7a7064; text-anchor: middle; }
.rootdot { fill: #23180a; }

/* the four choices */
.options { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; }
.opt {
  font: 500 18px/1.25 var(--sans); padding: 14px 15px; text-align: left; cursor: pointer;
  border-radius: 9px; border: 1px solid var(--hair); background: var(--fill);
  color: var(--ink); display: flex; align-items: center; gap: 11px; min-height: 62px;
  transition: transform 0.14s var(--ease),
    background var(--reveal) var(--ease), border-color var(--reveal) var(--ease),
    opacity var(--reveal) var(--ease), box-shadow var(--reveal) var(--ease);
}
.opt .lbl { flex: 1; min-width: 0; }
.opt kbd { flex: none; margin: 0; }
/* the option shapes are present from the first frame and merely unseen, so the
   buttons never change size when the call is made. Opacity is a transition and
   scale is the spring, kept apart so the `dim` wheels can settle at their own
   opacity without the animation's fill overriding it. */
.opt .wheel { flex: none; visibility: hidden; opacity: 0; transition: opacity var(--reveal) var(--ease); }
.options.revealed .opt .wheel { visibility: visible; opacity: 1; animation: pop var(--spring) linear; }
.options.revealed .opt .wheel.dim { opacity: 0.55; }
.opt:hover { background: var(--fill-hi); }
.opt:active { transform: scale(0.985); }
.opt.good { border-color: rgba(var(--good-rgb), 0.65); background: var(--good-soft); }
.opt.bad { border-color: rgba(var(--bad-rgb), 0.65); background: var(--bad-soft); }
/* No fill mode: once it has sprung, the transform is released so `:active` can
   still take the button on a later click. */
.opt.sprung { animation: settle var(--spring) linear; }
.opt.dim { opacity: 0.44; }
.opt.dim:hover { opacity: 0.85; }
/* Sounding now. This used to be an outline, which after the reveal sat just
   outside the green or red border and read as a second ring around the same
   button. A glow has no edge to double up, and it is the same treatment the
   candle already uses for something lit. */
.opt.playing { box-shadow: 0 0 18px rgba(var(--accent-rgb), 0.5); }
/* After .good/.bad, so an audition of the right answer overrides the reveal's
   animation rather than tying with it and being ignored. */
.opt.pulseA { animation: pulseA var(--spring) linear; }
.opt.pulseB { animation: pulseB var(--spring) linear; }
/* Two by two at every width. One column spent four rows where two would do, and
   on a short phone that was the difference between the board fitting the screen
   and not, which is the one thing this layout has to get right: there is no
   scrolling to an option while the candle is burning.

   Two columns only fit down here if the label is given back the room the chrome
   was taking. The key hint goes first, being the least useful pixel on a device
   with no key to press; then the padding, the gaps and the shape come in. That
   turns roughly 47px of label into roughly 100px, which is two lines for even
   the longest name in the table rather than four. The button still measures
   about 156 by 56 on a 360px phone, well past the size a thumb starts missing. */
@media (max-width: 430px) {
  .wrap { padding: 20px 14px 24px; }
  .options { gap: 7px; }
  .opt { font-size: 15px; padding: 10px; gap: 7px; min-height: 56px; }
  .opt kbd { display: none; }
  .opt .wheel { width: 28px; height: 28px; }
}

/* buttons */
/* same gap as the four answers above, so the two rows read as one grid */
.go { margin-top: 16px; min-height: 58px; display: flex; align-items: center; justify-content: center; gap: 14px; flex-wrap: wrap; }
/* every button on the page is the same button */
.btn, .act, .mini {
  cursor: pointer;
  background: var(--fill); border: 1px solid var(--hair2);
  transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.1s var(--ease);
}
.btn:hover, .mini:hover, .act:not(:disabled):hover {
  color: var(--ink); background: var(--fill-hi); border-color: var(--hair-hi);
}
/* …and what follows adds only shape */
.btn {
  font: 600 16px/1 var(--sans); padding: 13px 20px;
  border-radius: 9px; color: var(--ink);
  display: inline-flex; align-items: center; gap: 8px;
}
.btn:active { transform: scale(0.95); }
.btn kbd { margin-left: 0; }
/* Rectangles are down to one: the settings panel's Done. Everywhere else a row
   of them read as a wall, four answers deep already, and on the score card three
   of them at a 172px floor came to more than a phone has and wrapped one onto a
   row of its own. What replaced them is a disc with a mark in it and nothing
   else, the key it answers to in the tooltip rather than on the page. */
.act {
  color: var(--muted);
  border-radius: 50%; padding: 0;
  width: 58px; height: 58px; display: grid; place-items: center;
}
.act:not(:disabled):active { transform: scale(0.9); }
.act.lead { color: var(--ink); }             /* the forward one carries the weight */
.act.lead:not(:disabled):hover { color: var(--accent); }
.act:disabled { opacity: 0.3; cursor: default; }
/* the share button says so in the mark, having no words to say it in */
.act.done { color: var(--good); }
.act.miss { color: var(--bad); }
/* Until the first chord has sounded, Start breathes a ring so the eye finds it.
   Nothing else on the opening board announces itself: the candle is stopped, the
   wheel is an empty clock face, the four names are dimmed out of reach. In the
   accent, which is what this page spends on anything lit. */
.act.nudge { animation: nudge 2.8s ease-out infinite; }
@keyframes nudge {
  0%        { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0.5); }
  50%, 100% { box-shadow: 0 0 0 13px rgba(var(--accent-rgb), 0); }
}
/* drawn out to the edges of its own box, so what is in the disc is all mark and
   no padding baked into the artwork */
.mark { width: 28px; height: 28px; fill: currentColor; display: block; }

/* The shareable copy of the scorecard. Off the page and one pixel of it, so
   its 1120-wide backing store cannot lengthen the document — the host measures
   an inline bulb by how tall its body scrolls. */
.shot { position: fixed; left: -9999px; top: 0; width: 1px; height: 1px; pointer-events: none; }

/* settings & results */
.settings {
  display: grid; grid-template-columns: auto 1fr; gap: 12px 16px; align-items: center;
  margin: 0 0 18px; padding: 16px 18px;
  border: 1px solid var(--hair); border-radius: 10px; background: var(--pane);
}
.settings label { font-size: 15px; color: var(--muted); }
.poolnote { font-size: 14px; color: var(--muted); line-height: 1.5; margin-top: -3px; }
.seg { display: inline-flex; border: 1px solid var(--hair); border-radius: 8px; overflow: hidden; }
/* a grid item stretches to its column by default, which left the segmented
   controls with a rail of empty box running out to the right edge */
.settings .seg { justify-self: start; }

/* Narrow: a wrapped segmented control stops reading as one control — the second
   line looks like a separate set of buttons sitting under it. So drop the
   label-beside-control grid, give each control its own full-width row, and let
   the options divide it evenly. */
@media (max-width: 560px) {
  .settings { grid-template-columns: 1fr; gap: 5px; padding: 14px; }
  .settings label { margin-top: 9px; }
  .settings .seg { justify-self: stretch; display: flex; }
  .settings .seg button { flex: 1; padding: 10px 4px; font-size: 13.5px; text-align: center; }
  .poolnote { margin-top: 0; }
}
.seg button { font: 500 15px/1 var(--sans); padding: 10px 15px; cursor: pointer; background: transparent; color: var(--ink2); border: 0; white-space: nowrap; }
.seg button + button { border-left: 1px solid var(--hair); }
.seg button:hover { background: var(--fill-hi); }
.seg button.on { background: var(--accent-soft); color: #f0d3ab; font-weight: 600; }

/* the scorecard — built to be screenshotted, so it names itself and states
   what it was played under */
.card {
  border: 1px solid var(--hair); border-radius: 14px;
  padding: 26px 22px 22px; margin: 8px 0 4px;
  background: var(--pane); text-align: center;
}
.brand {
  font: italic 700 22px/1 var(--serif); color: var(--accent);
  letter-spacing: 0.3px; margin-bottom: 12px;
}
.tally { display: flex; align-items: baseline; justify-content: center; gap: 5px; }
/* Proportional figures, not tabular. Tabular ones earn their keep in the HUD,
   where the count changes in place round to round; here the number is shown
   once and against nothing, and the side bearing they pad a 1 with is exactly
   the gap that made "1 of 10" read as two separate things. */
.tally b { font: 700 58px/1 var(--sans); color: var(--ink-hi); }
.tally i { font: 500 18px/1 var(--sans); font-style: normal; color: var(--muted); }
.under {
  font-size: 14px; color: var(--muted); margin-top: 8px;
  letter-spacing: 0.4px;
}
.under.foot { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--hair); }

.grid {
  list-style: none; margin: 22px 0 0; padding: 0;
  display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px 10px;
}
.cell { display: grid; justify-items: center; gap: 3px; min-width: 0; }
.cell b { font: 700 15px/1.2 var(--serif); color: var(--ink-warm); }
.cell .said { font-size: 12px; line-height: 1.3; color: var(--muted); min-height: 16px; }
.cell.bad .said { color: var(--bad); }
/* ten chords lay out two ways and no other: five across in two rows, or two
   across in five. Four- and three-column steps left an orphan row. */
@media (max-width: 560px) {
  .grid { grid-template-columns: repeat(2, 1fr); gap: 12px 10px; margin-top: 16px; }
  /* five rows is a lot taller than two, and a scorecard that runs off the
     bottom cannot be screenshotted */
  .cell .wheel { width: 54px; height: 54px; }
  .card { padding: 18px 14px 16px; }
  .tally b { font-size: 44px; }
  .brand { margin-bottom: 8px; }
  .under.foot { margin-top: 12px; padding-top: 12px; }
}
```
**index.html**

```html
<div id="root"></div>
```
**config.json**

```json
{
  "description": "Jazz chord ear training. Hear a chord, name it against the clock, then see the answer. Triads and sevenths through to altered and extended chords.",
  "dependencies": {
    "react": "^19.2.7",
    "react-dom": "^19.2.7"
  }
}
```