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
75 changes: 71 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,8 +296,43 @@ v2026.08.26.002 Pre-fix charts self-repair at render. The .21.004 cleanup runs
monospace) for guitar-tab rows. **Slide rewrites must stay LENGTH-PRESERVING**
(`slide`→5 spaces, `s`→1 space) in BOTH paths: `pairToSegs` aligns chords by
column, so a shortened chord line drops every chord onto the first word.
v2026.08.26.003 Share a song as a LINK. `shareSong()` packs `{app,kind:"song",version:1,song}` into
the URL **fragment** (never sent to a server, so a shared chart never reaches a
Pages access log — do NOT "simplify" this to a `?query=`), deflate-raw'd via
`CompressionStream` (`z.` tag; `j.` = plain-JSON fallback). A full chart ≈800 chars.
Share from `#dockSheet` or the long-press menu. **Two receive paths, both needed:**
`maybeImportFromLink()` in `boot()` reads a `#s=` fragment (always confirms, then
strips the hash so a reload can't re-prompt), and ≡ → "Add a song from a link"
takes a pasted URL — on iOS an installed A2HS app has storage SEPARATE from
Safari's, so a *tapped* link lands in a copy of the app the owner never opens, and
pasting is the only route that reaches the installed one. Incoming songs are
untrusted input: `mergeSetImport`'s coerce/clamp/fresh-id shape. `index.html` now
redirects in script — a meta refresh DROPS the fragment.
v2026.08.26.004 Showcase restored: House of the Rising Sun grows from a 4-line stub to a full
arrangement (intro/solo/outro bar rows, five verses, `{soc}` chorus, section
headings, banter). `retireShowcase()` dropped Danny Boy in .26.001 and left the app
with **no** worked example. One-time `upgradeShowcase()` (guard `state.showcaseV2`)
reaches existing libraries but replaces the body ONLY if it still matches the old
stub exactly — an owner-edited copy is never overwritten. Also: `restoreData`'s
`toast("Restored")` had been swallowed into a trailing `//` comment and never ran.
v2026.08.26.005 Fix the screen sleeping on a song (owner: "when I took away gig mode my power
settings changed" — correct, and it was a bug, not a setting). A
`WakeLockSentinel` is **auto-released by the browser every time the page hides**,
but `wakeLock` kept pointing at the dead sentinel, so the guard
`if(!wakeLock)requestWake()` was never true again — from the FIRST background/
foreground cycle the app held no lock at all, on every screen. Masked until
.21.001, when `show()` still re-requested per navigation and gig mode held its own;
going app-wide left the broken guard as the only re-acquire path. `requestWake()`
now tests `.released`, nulls the handle on the sentinel's `release` event and
self-guards; `visibilitychange` re-requests unconditionally; `openCurrent()` and
`startScroll()` re-assert. Dead `releaseWake()` and the orphaned gig-mode comment
removed. Reproduced against a stubbed `wakeLock` first: 8 failing, then 11 passing.
```

> **Note:** the changelog comment at the top of `setlist69.html` is missing entries
> `v2026.08.21.001` – `v2026.08.26.002`; they live only in this file. Backfill from here if you
> want the two in sync.

A GitHub Actions workflow (`.github/workflows/check.yml`) enforces the version discipline on every push: it syntax-checks the extracted inline script and `sw.js`, **fails if the `<small>` brand version ≠ `sw.js` CACHE version**, and fails on duplicate element ids in the markup.

-----
Expand Down Expand Up @@ -359,7 +394,7 @@ Single-page app with a manual screen router. Layout is a flex column (`.app`): a
| `pasteModal` | Search-and-paste lyrics assistant (opens chord sites, parses pasted text). |
| `dockSheet` | ⚙ song-controls sheet: transpose, ♯/♭, font/fit, edit (edit hidden in stage mode). |

**Navigation** is driven by `show(name)` where `name` ∈ `setlists | setSongs | song`. It toggles `.show`, swaps the header, manages the wake lock, and stops auto-scroll on leave. `goBack()` implements contextual back.
**Navigation** is driven by `show(name)` where `name` ∈ `setlists | setSongs | song`. It toggles `.show`, swaps the header, and stops auto-scroll on leave. (It no longer touches the wake lock — that has been app-wide since v2026.08.21.001; see §9.) `goBack()` implements contextual back.

**Navigation state globals:**

Expand Down Expand Up @@ -527,6 +562,18 @@ Filename: `setlist69-backup-YYYY-MM-DD.json`. Payload: full `state`.

**Not supported:** `.onsongarchive` / `.onsongbook` (proprietary binary), `.backup` (ZIP but SQLite inside). Bulk OnSong export path: export individual songs as `.onsong`, zip them, import the zip.

**Share one song as a link** — `shareSong(id)` → `songToLink(song)` builds
`<app-url>#s=<tag>.<base64url>`, where the payload is `{app:"setlist69",kind:"song",version:1,song}`
and `tag` is `z` (deflate-raw via `CompressionStream`) or `j` (plain JSON when that API is missing).
**It must stay a fragment** — a fragment is never transmitted to the server, so a shared chart never
lands in a GitHub Pages access log; a `?query=` would break the no-server constraint in spirit.
Received two ways, and **both are load-bearing**: `maybeImportFromLink()` (called from `boot()`)
handles a `#s=` fragment on open, and ≡ → *Add a song from a link* accepts a pasted URL — on iOS an
installed A2HS app has storage separate from Safari's, so a tapped link imports into a copy of the
app the owner never uses. Either way `offerSharedSong()` confirms first and `addSharedSong()`
sanitises (coerce every field, clamp `capo`/`defaultTranspose`, fresh `newSongId()`, payload id
ignored). Unlike a file import, a link carries **`banter`** — `parseImport` has no field for it.

**Search & paste modal** (`pasteModal`) — editor has a "Find chords" button that opens this. User types a song name, opens one of 6 linked sites in a new tab (Chordie, E-Chords, Cifraclub, AZLyrics, Genius, Google), copies the result, pastes into the textarea, taps Import. `parseImport()` handles the rest. There is no server-side proxy — CORS prevents fetching arbitrary external sites from the browser; the manual copy-paste step is intentional.

-----
Expand All @@ -553,7 +600,9 @@ Globals: `transpose` (semitones, 0 on song open), `preferFlats` (bool), `fontSiz

**Gesture debug overlay (v2026.08.13.002):** 5 quick taps on the version tag (`.brand small`) toggle `#gestureDbg`, a fixed, `pointer-events:none` readout of the last ~6 touch events seen at document level (type, target, dx/dy) plus screen name and rendered column count. Session-only, never persisted. Exists so a dead gesture on a real device can be reported with the actual event stream — ask the owner to 5-tap, swipe once, and read back the overlay.

**Wake lock (v2026.08.21.001 — app-wide):** acquired at `boot()` and re-acquired on `visibilitychange→visible` on ANY screen; never released between screens (the OS drops it on hide). The owner runs the app on a stand all night — it must not sleep anywhere.
**Wake lock (app-wide; sentinel trap fixed v2026.08.26.005):** acquired at `boot()`, re-requested on every `visibilitychange→visible` on ANY screen, and re-asserted by `openCurrent()` and `startScroll()`. The owner runs the app on a stand all night — it must not sleep anywhere.

> **Never test the handle for null.** `navigator.wakeLock.request()` returns a `WakeLockSentinel` that the browser **auto-releases whenever the page hides**, leaving `wakeLock` pointing at a dead object. The original guard `if(!wakeLock)requestWake()` was therefore never true after the first background cycle, and the app silently held no lock at all. `requestWake()` now checks **`wakeLock.released`**, attaches a `release` listener that nulls the handle, and self-guards so redundant calls are free. There is no `releaseWake()` — nothing releases it deliberately; the OS does. Regression cover: `test-wake.js` stubs `navigator.wakeLock` with a sentinel that auto-releases on hide.

**Dock layout (v2026.07.04.004):** the dock keeps only the between-songs-frequent controls with ≥2.6rem targets — prev/pos/next, play/pause (`scrollBtn`), speed −/+, and a `⚙` (`dockMore`) button. Transpose, `♯/♭`, font (`A−`/`fit`/`A+`) and `✎ Edit` live in `#dockSheet`, a `.modal.bottom` opened by `⚙`. **The controls kept their original ids**, so all handlers, boot wiring, and `openCurrent`'s set-nav hide still work after the DOM move — don't rename them. `openDockSheet()` hides `#editSong` when `stageMode` is on.

Expand Down Expand Up @@ -615,11 +664,13 @@ Router/nav: show goBack
List renders: renderSetlists renderSetSongs renderAllSongs renderPicker renderAdder
Song view: openSongInSet openSongStandalone openCurrent reRender libraryIds
nextSong prevSong startScroll stopScroll updateScrollProg
requestWake releaseWake openDockSheet closeDockSheet nudgeFont
requestWake openDockSheet closeDockSheet nudgeFont
Editor: openEditor closeEditor saveSong
Context/dialogs: openSongContext closeSongContext openAdder closeAdder showPrompt showConfirm
closeModal loadPlayed savePlayed
Backup/share: saveJsonFile exportData shareApp shareSetlist restoreData mergeSetImport
Song links: b64urlEnc b64urlDec streamBytes songToLink linkToSong shareSong
addSharedSong offerSharedSong maybeImportFromLink
Import: parseImport expandFiles unzip parseOpenSong
PWA: showUpdateBanner maybeShowUpdatePill maybeShowInstallBanner isStandalone dismissInstall
```
Expand Down Expand Up @@ -648,7 +699,23 @@ No test framework — verification is manual:

**Shipped:** the **per-song `banter` field** (v2026.07.19.003) — `song.banter`, an editor input (`#fBanter`), and a `.svbanter` line (accent, italic) under the title in the performance view (`renderSheet` reads `song.banter`, not `meta`). The Doubloon Bayou Band interim workaround (banter stuffed into `sub`) is migrated by the one-time, idempotent `migrateBanter()` in `boot()` — guarded by `state.bantersMigrated`, it **moves** (not deletes) `sub`→`banter` for that set's songs when `banter` is empty, and no-ops on any device without that set. Safe to delete once every device has booted past it.

**Showcase song (v2026.07.19.005):** `addShowcaseSong()` (guarded by `state.showcaseAdded`, called once from `boot()`) adds a perfectly-formatted demo song — **"Danny Boy"** (id `danny`) — plus a `★ Showcase` set (id `showcase`). **Legal basis:** it is public domain — Frederic Weatherly's 1913 lyrics + the traditional "Londonderry Air" (composer unknown) — the only kind of full song safe to ship. Do **not** hardcode a copyrighted song here. Idempotent and delete-safe (the flag stops re-adding, so removing the song/set sticks); reaches existing libraries as well as fresh installs.
**Showcase song (v2026.08.26.004):** the one fully-worked example in the app is
**House of the Rising Sun** (seed id `hr`) — traditional, **public domain**, which is the only kind
of song safe to ship in a repo that is public and served publicly by GitHub Pages. It exists to show
what a well-formatted chart looks like: `{c:}` section headings, bar-only rows for intro/solo/outro,
a `{soc}`/`{eoc}` chorus, a `banter` line, and chords that all resolve to fingering diagrams.

`upgradeShowcase()` (one-time, guarded by `state.showcaseV2`, called from `boot()`) carries it to
libraries that already exist, since `seed()` only runs on a fresh install. It replaces the body
**only when it still matches `HR_STUB_BODY` exactly** — if the owner edited their copy, theirs
wins. Never make this migration unconditional; overwriting someone's edited chart breaks design
priority #1.

*History:* the previous showcase was Danny Boy (v2026.07.19.005), also public domain, retired by
`retireShowcase()` in v2026.08.26.001 — which left the app with no example at all until .26.004.

**Do not hardcode a copyrighted song here.** Requests to do so are answered with a **song link**
(§8): the chart travels in the link's own payload and never enters the repo.

**Explicitly out of scope (by design):** cloud sync and any built-in online song catalog. Moving songs between devices is handled by backup-to-file.

Expand Down
15 changes: 14 additions & 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.08.26.002-informational?style=flat-square)](#versioning)
[![Version](https://img.shields.io/badge/version-2026.08.26.005-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 Expand Up @@ -86,6 +86,19 @@ The whole thing is a single `setlist69.html` file. No framework, no build step,

---

## Sending a song to a bandmate

Open a song → **⚙** → **Share this song** (or long-press it in the library). You get a link with the
whole chart — chords, sections, key, stage banter — packed into it. Text it, email it, AirDrop it.
Whoever opens it is asked whether to add it to their library.

Nothing is uploaded anywhere: the song rides in the URL's `#fragment`, which browsers never send to
a server. There's no account and no backend to sync through.

> **On an installed iPhone/iPad app,** open **≡ → Add a song from a link** and paste the link there.
> Tapping a link opens Safari, and iOS gives an installed home-screen app its own separate storage —
> so a tapped link would land in a copy of the app you don't use.

## Tech stack

![HTML5](https://img.shields.io/badge/HTML5-E34F26?style=for-the-badge&logo=html5&logoColor=white)
Expand Down
6 changes: 5 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
<!DOCTYPE html>
<meta http-equiv="refresh" content="0; url=setlist69.html">
<title>SetList69</title>
<link rel="canonical" href="setlist69.html">
<!-- A meta refresh DROPS the URL fragment, which is where a shared song rides (#s=...).
Redirect in script so the payload survives a root-URL link; the meta is the no-JS fallback. -->
<script>location.replace("setlist69.html"+location.search+location.hash);</script>
<noscript><meta http-equiv="refresh" content="0; url=setlist69.html"></noscript>
Loading
Loading