Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,17 @@ v2026.09.07.001 Visual pass — one icon language, real type hierarchy, readabl
`install()` in the long-press IIFE now takes the opener as its 3rd argument.
Played = a `✓` in the `.num` slot only. Version tag moved to `#aboutLine` in the
≡ sheet; see §9 for why `.brand small` must stay in the DOM.
v2026.09.07.002 Real-world charts render. `isChordLine` no longer requires every token to be a
chord — one mangled token used to drop the whole line (and its good chords) to
plain lyrics. New rule: every token is a chord, separator, cue, or **chord-shaped**
(`chordShaped()`: starts A-G, no run of 3+ lowercase after the root); ≥1 real
chord; and real chords ≥ half the musical tokens (that ratio is the lyric guard —
"A big deal" is 1-in-3). Unrecognised tokens render as `.chordpill.unk` (neutral,
dashed, literal text) and are **never transposed or pitch-coloured** — the root
parser would read "G#dD#" as G#. Cues (`CHORD_CUE`: riff/solo/(x4)/2x…) render as
`.chordpill.cue`. Also: bare `Intro Riff:` / `Verse 1:` / `Chorus:` labels become
headings (colon must be the LAST character, excluding `Key: G`; chord lines win
the tie). Both at RENDER time, so existing songs heal with no re-import.
```

> **Note:** the changelog comment at the top of `setlist69.html` is missing entries
Expand Down Expand Up @@ -519,7 +530,15 @@ the used count — measure distinct `.sect` x-offsets to know what actually rend
/^[A-G][#b]?(?:maj|min|m|M|dim|aug|sus|add|°|ø|\+|-|[#b]?(?:2|4|5|6|7|9|11|13)|\([^)]*\))*(?:\/[A-G][#b]?)?[!*]?$/
```

`isChordLine(line)` — all tokens must be chords OR pure separators (`/`, `-`, `|`); at least one chord required. This handles "Cm / Bb / Dm - D" correctly.
`isChordLine(line)` (rewritten v2026.09.07.002) — **tolerant of tokens it can't parse**, because real charts arrive with mangled chords and inline cues. A line qualifies when:

1. every token is a real chord, a pure separator (`/ - |`), a cue (`CHORD_CUE`: `riff`, `solo`, `(x4)`, `2x`…), or at least **chord-shaped** — `chordShaped()`: starts on `A-G`, only chord-legal characters, and no run of 3+ lowercase letters after the root (so `G#dD#`, `C#d` pass; `Cause`, `Blackis`, `Don't` fail);
2. **at least one token is a real chord**; and
3. real chords are **at least half** the musical tokens.

Rules 2 and 3 are the lyric guard — drop either and lines like "A big deal" (1 chord in 3) become chord rows. The old all-or-nothing rule meant one bad token dropped the line, its good chords included, to plain lyrics.

**An unrecognised token must never be treated as a chord.** `renderLine` gives it `.chordpill.unk` (neutral, dashed, literal text), `retuneSheet` skips it, and `renderSheet` keeps it out of `sheetRawChords`. Transposing it would corrupt it: `CHORD_RE` reads `G#dD#` as root `G#` + suffix `dD#` and would shift only the root.

### Chordie auto-clean

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
<br>

[![CI](https://github.com/cdburgess75/SetList69/actions/workflows/check.yml/badge.svg)](https://github.com/cdburgess75/SetList69/actions/workflows/check.yml)
[![Version](https://img.shields.io/badge/version-2026.09.07.001-informational?style=flat-square)](#versioning)
[![Version](https://img.shields.io/badge/version-2026.09.07.002-informational?style=flat-square)](#versioning)
[![PWA](https://img.shields.io/badge/PWA-installable-5A0FC8?style=flat-square)](#save-it-to-your-phone)
[![Offline](https://img.shields.io/badge/offline-first-2ea043?style=flat-square)](#features)
[![Runtime deps](https://img.shields.io/badge/runtime_dependencies-0-f5a623?style=flat-square)](#tech-stack)
Expand Down
78 changes: 73 additions & 5 deletions setlist69.html
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,26 @@
the DOM but display:none because CI greps it and APP_VERSION reads it, and the
5-tap gesture-debug trigger moved onto #aboutLine with it. Sheet renderer
verified byte-identical across all six seed songs.
v2026.09.07.002 Real-world charts render (owner screenshot: a ZZ Top chart with almost no
coloured chords and no visible sections). Two defects, both in detection.
(1) isChordLine required EVERY token to be a chord, so one mangled token took
the whole line down to plain lyrics — "E G#dD#" lost its E, and a song pasted
with damaged chords showed nearly none. Now a line qualifies when every token
is a chord, a separator, a cue, or at least chord-SHAPED (starts A-G, no
word-like run of 3+ lowercase after the root — so "G#dD#" and "C#d" pass while
"Cause" and "Blackis" do not); at least one is a REAL chord; and real chords
are at least half the musical tokens. That ratio is what keeps lyrics out —
"A big deal" is one chord in three. Unrecognised tokens render as a neutral
dashed pill (.unk) keeping their literal text, and are NEVER transposed or
colour-coded: the root parser would read "G#dD#" as G# and shift only that.
Inline cues ("E riff", "A (x4)") get a muted italic .cue pill instead.
(2) Bare "Intro Riff:" / "Verse 1:" / "Chorus:" labels — what chord sites and
OnSong actually emit — now become headings; only {c:...} did before, so the
shipped showcase looked immaculate while every imported song's structure was
invisible. The colon must be the LAST character, which excludes metadata
("Key: G" carries a value) and ordinary lyrics; chord lines win the tie.
Both fixes are at RENDER time, so existing songs heal with no re-import.
Seed songs verified byte-identical.
-->
<html lang="en">
<head>
Expand Down Expand Up @@ -398,6 +418,12 @@
.chev{color:var(--faint);font-size:1.1rem;}
.num{font-family:var(--mono);color:var(--faint);font-size:.85rem;min-width:1.3rem;text-align:right;}

/* chord-line token we can't parse: neutral, outlined, obviously not a real chord */
.chordpill.unk{background:transparent;color:var(--faint);border:1px dashed var(--line);
box-shadow:none;font-weight:600;}
/* a performance cue on the chord line ("E riff") — muted, no border, clearly not a chord */
.chordpill.cue{background:transparent;color:var(--muted);border:none;box-shadow:none;
font-family:var(--ui);font-style:italic;font-weight:600;letter-spacing:0;}
.pill{font-family:var(--mono);font-size:.72rem;font-weight:700;padding:.2rem .5rem;border-radius:7px;
color:#fff;letter-spacing:.3px;white-space:nowrap;box-shadow:inset 0 0 0 1px #ffffff22;}

Expand Down Expand Up @@ -663,7 +689,7 @@
<div class="app">
<header>
<button class="back" id="backBtn" style="display:none" aria-label="Back"><svg class="ico" aria-hidden="true"><use href="#i-back"/></svg><span id="backLabel">Back</span></button>
<div class="brand" id="brand"><span class="dot"></span>SetList69<small>v2026.09.07.001</small></div>
<div class="brand" id="brand"><span class="dot"></span>SetList69<small>v2026.09.07.002</small></div>
<div class="htitle" id="headerTitle" style="display:none"></div>
<div class="spacer"></div>
<button class="iconbtn" id="updateHdr" title="Update ready" aria-label="Update ready — refresh" hidden><svg class="ico" aria-hidden="true"><use href="#i-refresh"/></svg></button>
Expand Down Expand Up @@ -978,7 +1004,29 @@ <h2>Songs</h2><span class="count" id="allCount"></span>
// marker. Recognises Cmaj7, C7sus4, Cm7b5, Bbm7b5, D7#9, Cadd9, F°, Bø, slash chords, Gm! — while
// still rejecting ordinary lyric words (used by isChordLine for chords-above detection + import).
function looksChord(t){return /^[A-G][#b]?(?:maj|min|m|M|dim|aug|sus|add|°|ø|\+|-|[#b]?(?:2|4|5|6|7|9|11|13)|\([^)]*\))*(?:\/[A-G][#b]?)?[!*]?$/.test(t);}
function isChordLine(line){const toks=line.trim().split(/\s+/).filter(Boolean);if(!toks.length)return false;let chords=0;for(const t of toks){if(looksChord(t))chords++;else if(/^[\/\-|]+$/.test(t))continue;else return false;}return chords>0;}
/* Inline cues that sit on a chord line without being chords: "E riff", "A (x4)", "D 2x". */
const CHORD_CUE=/^(\(.*\)|x\s*\d+|\d+\s*x|riff|solo|fill|break|tacet|hold|ring|let|only|repeat|out|end|tag|intro|outro|verse|chorus|bridge)$/i;
/* "Chord-shaped" = could plausibly BE a chord: starts on A-G, no punctuation a chord never has,
and no word-like run of 3+ lowercase letters after the root ("Cause" and "Blackis" are words,
"G#dD#" and "C#d" are mangled chords). */
function chordShaped(t){return /^[A-G][#b]?[A-Za-z0-9#b°ø+\-\/()]*$/.test(t)&&!/[a-z]{3}/.test(t.slice(1));}
/* A single unrecognised token used to drop the WHOLE line to plain lyrics, taking its good chords
with it — a chart pasted with mangled chords ("E G#dD#") lost the E as well, and every chord in
the song vanished. Now a line is a chord line when every token is a chord, a separator, a cue, or
at least chord-shaped; at least one is a REAL chord; and real chords are at least half the
musical tokens. That last ratio is what keeps lyrics out: "A big deal" has one chord in three. */
function isChordLine(line){
const toks=line.trim().split(/\s+/).filter(Boolean);if(!toks.length)return false;
let real=0,musical=0;
for(const t of toks){
if(/^[\/\-|]+$/.test(t))continue;
if(CHORD_CUE.test(t))continue;
musical++;
if(looksChord(t))real++;
else if(!chordShaped(t))return false; // an actual word — this is a lyric line
}
return real>0&&real*2>=musical;
}
function pitchClass(key){const m=(key||"").match(CHORD_RE);return m&&m[1]in IDX?IDX[m[1]]:-1;}
function keyColor(key){const pc=pitchClass(key);if(pc<0)return"#7a7160";const hue=(pc*30)%360;return"hsl("+hue+" 52% 42%)";}
/* Nashville Number System: chord root -> scale degree relative to the key. Computed from the
Expand Down Expand Up @@ -1041,6 +1089,15 @@ <h2>Songs</h2><span class="count" id="allCount"></span>
out.push({type:"comment",text:bareLbl[1].trim()+(bareLbl[2]?" "+bareLbl[2].trim():"")});continue;}
if(/^[eEADGB]b?\s*\|[-0-9hpbrx\/\\~|\s]*\|?\s*$/.test(line.trim())){
out.push({type:"tab",text:line});continue;}
/* Bare section labels — "Intro Riff:", "Verse 1:", "Chorus:" — which is what chord sites and
OnSong actually emit. Only {c:...} used to become a heading, so the markers in real imported
songs stayed invisible while the shipped showcase looked immaculate. The colon must be the
LAST character: that excludes metadata rows ("Key: G" carries a value) and ordinary lyrics
(they carry sentence punctuation). Chord lines win the tie, so "E:" stays a chord. */
const secLbl=line.trim();
if(/^[A-Za-z][A-Za-z0-9 #'&.\/-]{0,38}:$/.test(secLbl)&&secLbl.split(/\s+/).length<=5
&&!isChordLine(secLbl.slice(0,-1))){
out.push({type:"comment",text:secLbl.slice(0,-1)});continue;}
if(!hasInline){
const n=line.replace(/\b([A-G][#b]?)(\s+)slide(\s+)([A-G][#b]?)\b/g,"$1$2 $3$4")
.replace(/\b([A-G][#b]?)s([A-G][#b]?)\b/g,"$1 $2");
Expand Down Expand Up @@ -1108,8 +1165,18 @@ <h2>Songs</h2><span class="count" id="allCount"></span>
const lineHasChord=groups.some(g=>g.some(c=>c.chord));
return groups.map(g=>{
const inner=g.map(cell=>{
/* A token on the chord line that isn't a recognisable chord gets a neutral pill instead of a
pitch colour, and keeps its literal text: transposing or colouring it would be a lie (the
root parser would read "G#dD#" as G# and shift only that). It stays visible so a mangled
import can be spotted and fixed, rather than silently dropping the whole line to lyrics. */
const known=cell.raw?looksChord(cell.raw):false;
const crow=lineHasChord
? '<span class="c">'+(cell.chord?'<span class="chordpill" data-ch="'+esc(cell.raw)+'" style="background:'+keyColor(cell.chord)+'">'+((nashville&&nashKeyPc>=0)?nashPill(toNashville(cell.raw,nashKeyPc)):esc(cell.chord))+'</span>':'')+'</span>'
? '<span class="c">'+(cell.chord?(known
? '<span class="chordpill" data-ch="'+esc(cell.raw)+'" style="background:'+keyColor(cell.chord)+'">'+((nashville&&nashKeyPc>=0)?nashPill(toNashville(cell.raw,nashKeyPc)):esc(cell.chord))+'</span>'
: (CHORD_CUE.test(cell.raw)
? '<span class="chordpill cue">'+esc(cell.raw)+'</span>'
: '<span class="chordpill unk" title="Not a recognised chord — tap the title to edit">'+esc(cell.raw)+'</span>'))
:'')+'</span>'
: '';
return '<span class="seg">'+crow+'<span class="l">'+(esc(cell.text)||"&nbsp;")+'</span></span>';
}).join("");
Expand All @@ -1121,7 +1188,8 @@ <h2>Songs</h2><span class="count" id="allCount"></span>
function retuneSheet(){
const pills=document.querySelectorAll("#sheet .chordpill");
const nash=nashville&&nashKeyPc>=0;
for(const p of pills){const raw=p.dataset.ch||"",t=transposeChord(raw);p.style.background=keyColor(t);
for(const p of pills){if(p.classList.contains("unk")||p.classList.contains("cue"))continue; // literal text, never transposed
const raw=p.dataset.ch||"",t=transposeChord(raw);p.style.background=keyColor(t);
if(nash)p.innerHTML=nashPill(toNashville(raw,nashKeyPc));else p.textContent=t;}
renderChordChart();
updateScrollProg();
Expand Down Expand Up @@ -1170,7 +1238,7 @@ <h2>Songs</h2><span class="count" id="allCount"></span>
}
closeSect();
sheetRawChords=[];
for(const ln of r.lines)if(ln.type==="segs")for(const sg of ln.segs)if(sg.chord)sheetRawChords.push(sg.chord);
for(const ln of r.lines)if(ln.type==="segs")for(const sg of ln.segs)if(sg.chord&&looksChord(sg.chord))sheetRawChords.push(sg.chord);
renderChordChart();
const sheet=document.getElementById("sheet");sheet.innerHTML=html;sheet.style.setProperty("--sheet-size",fontSize+"rem");
}
Expand Down
2 changes: 1 addition & 1 deletion sw.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const CACHE = 'setlist69-v2026.09.07.001';
const CACHE = 'setlist69-v2026.09.07.002';
const PRECACHE = [
'./',
'./index.html',
Expand Down
Loading