@kentcdodds/pocket-piano
A pocket piano for choir practice: your singing part's range on your phone, installable and offline.
AGENTS.md
203 lines · 10.2 KB · Markdown@kentcdodds/pocket-piano
Static, self-contained package app (no credentials, no packageStorage). One Worker fetch handler renders the page with inline CSS and JS and serves the PWA files.
Layout
src/app.ts:kody.app.entry. Routes (mount-stripped):/(HTML),/about,/api/version,/manifest.webmanifest,/og.jpg,/icon.svg,/icons/{icon,maskable}-{192,512}.png,/sw.js. Anything else is 404. Non GET/HEAD is 405. All URLs come frompackageContext.appBasePath.src/client-script.ts: browser script as aString.rawtemplate. No backticks or template placeholders inside it.src/styles.ts: CSS, same rule.src/og-image.ts: generated base64 JPEG for/og.jpg(node scripts/render-og.mjs).src/icons.ts: generated base64 PNGs. Regenerate withpython3 scripts/make-icons.py(needs Pillow)..kody/icon.pngis a copy of the 512 icon.src/notes.ts,src/index.ts: note helpers and thenoteInfoexport.src/parts.ts: singing parts (MIDI ranges), thepocket-piano:partlocalStorage key, andPARTS_JS, a browser helper inlined on both pages (window.pocketPianoParts:read,write,byId,homePos).test/parts.test.ts:npm test(node --test test/, Node 22.6+ with type stripping). Covers range lookup, persistence, and home positions.src/version.ts,src/record-version.ts: publish metadata in this app'spackageStorage(copied from the package-app-kit starter).src/about-page.ts: About page HTML, CSS, and script.src/update-script.ts: update check, SW registration, and the update toast (inlined on every page).
About and update pattern
Follows @kentcdodds/package-app-kit starter-remix (the kit's scaffolder
template), translated from Remix components to this plain fetch handler:
| Starter file | Here |
|---|---|
app/routes.ts (about: '/about', versionApi: '/api/version') | routes in src/app.ts |
app/controllers/about.tsx, app/ui/about-panel.tsx, app/ui/layout.tsx | src/about-page.ts |
app/controllers/version.ts | /api/version in src/app.ts (cache-control: no-store) |
app/ui/update-banner.tsx, app/ui/local-time.ts | src/update-script.ts |
app/ui/document.tsx (data-running-sha, manifest use-credentials) | documentHtml in src/app.ts |
src/version.ts, src/record-version.ts | same paths |
public/sw.js (SKIP_WAITING, no 403 caching, /api/* bypass) | serviceWorker() in src/app.ts |
- Every page renders
data-running-shafrom the recorded version. The update check compares it with/api/versionand also checks for a waiting service worker. Refresh postsSKIP_WAITINGto a waiting worker, asks the active worker toREFRESH_SHELL(re-fetch cached pages), then reloads. - localStorage keys follow the starter:
pocket-piano-last-update-checkandpocket-piano-update-dismissed-<sha>.
After every publish
Record the published commit, or About shows dashes and update checks stay silent:
import recordVersion from 'kody:@kentcdodds/pocket-piano/record-version'
await recordVersion({ sha: '<published_commit>', message: '<commit subject>', committedAt: '<ISO>' })Call this package's export, never the kit's (that writes the kit's storage).
Imports
import noteInfo from 'kody:@kentcdodds/pocket-piano'
const info = await noteInfo({ midi: 60 }) // { midi: 60, name: 'C4', frequency: 261.63 }noteInfo rejects unknown keys and non-integer or out-of-range midi.
Smoke tests
Git lane: kody dev check, commit, push, then packagePublishExternalPush.
Local: kody dev --app-base-path /pocket-piano so URLs match production.
After publish, packageAppFetch with path /, /manifest.webmanifest,
/icons/icon-512.png, and /sw.js should all be 200 with the right
content type.
Browser checks (Playwright, Pixel 7) read window.pocketPiano.state: rows
(2 in portrait, 1 in landscape), pos (white-key index at the left edge of
the bottom row, 16 is C3), range, part, home (the part's start pos), mode (idle, play, scroll),
voices, held, noteOns, cancels, scrolls, installPromptAvailable.
Drive touches with CDP Input.dispatchTouchEvent. In portrait every key
exists once per row, so pick the element that is inside its row's visible
box. Installability: CDP Page.getInstallabilityErrors in a persistent
(non-incognito) context.
Layout model
- Each
.rowholds a full 88-key.striptranslated by-(pos + row offset) * keyWidth. Landscape: 1 row, 15 white keys visible. Portrait: 2 rows, 8 white keys each, top row offset 7 (it starts on the bottom row's last key, so C3 to C4 below and C4 to C5 above). maxPosis 52 white keys minus the total span (15 in both layouts).
Gestures
- Pointer events on
#keyboardwithtouch-action: none. - With 2+ pointers down, gliding is off. When every pointer has moved at least
6 px horizontally in the same direction and the mean is at least
12 px, mode becomes
scroll: those notes are cut with a 6 ms fade and no note plays until all pointers lift. Fingers land as a chord first, so a drag can sound for the few milliseconds before it is recognized. - On release, velocity from the last 100 ms of samples is projected to where
momentum would stop (
velocity / -ln(0.994)), rounded to a whole white key, clamped to 0 throughmaxPos, and reached in one ease-out cubic (200 to 650 ms, matched to release speed). No overshoot. Range reset lands exactly on the part's home pos. - Taps during a glide play normally and let it finish. A new two-finger drag
takes over the animation.
state.animatingreports a running glide.
Audio
- AudioContext created on the first pointerdown, resumed on every touch.
- Master gain is fixed at 1 into a limiter (threshold -6 dB, ratio 20, 2 ms attack). There is no in-app volume by design.
- Web Audio in Chrome for Android plays on the media stream, so hardware volume buttons control it.
Hosting, PWA, and the Kody gate
- Source is public and forkable (
visibility: public). The app isinternet_visible, served athttps://piano.kentcdodds.com/(app-level custom hostname, set up with@kentcdodds/cloudflare/custom-domain: a DNS-only CNAME todomains.kody.run). The kody.run mount (/pocket-piano/) still works. - Every in-app URL (manifest, icons,
sw.jsand its scope,start_url,/about,/api/version) is built frompackageContext.appBasePath, which is empty at the custom domain root and/pocket-pianoon kody.run. The absoluterel=canonical,og:url,og:image, andtwitter:imageURLs usepublicOrigin()insrc/app.ts(thex-forwarded-hostorhostheader, https unless localhost) plus the app base, so they follow whatever address served the page. Nothing in the app hard-codes a domain. - The share image (
/og.jpg,src/og-image.ts) comes fromnode scripts/render-og.mjs [appUrl]. It is made like Asteroids and Roadster: headless Chrome screenshots the real app (tenor, C major chord held), places it in a lit phone on an HTML/SVG stage, and all text is live HTML (Playfair Display and Inter). No AI-generated art or text. It needsplaywright-coreresolvable (symlink anode_modules, which is gitignored). The address printed at the bottom is the host ofappUrl(defaulthttps://piano.kentcdodds.com/) orOG_SITE_LABEL. Rerun it after visible UI changes, then bumpVERSION. - The manifest link keeps
crossorigin="use-credentials"(harmless when public; needed if the app is ever gated again). - The service worker precaches the shell with credentials, serves cache first
and refreshes in the background, never caches non-200, 403, or redirected
responses, and skips navigations with a query string so a Kody session
handoff always reaches the network. Bump
VERSIONinsrc/app.tsto rotate the cache. - Keep
internet_visibleon, or piano.kentcdodds.com stops serving.
Forking
- The package has no credentials, secrets, jobs, webhooks, or
kody:imports and nokody.dependencies, so a fork in any org publishes as is. The onlykody:@kentcdodds/...strings are in docs and JSDoc examples; rewrite them to the fork's name. - Steps for a fork:
- Rename
nameinpackage.json(@you/<leaf>) and thekody.idif you want a different mount path. Update the doc examples to match. - Set
CODE_URLinsrc/about-page.tsto the fork's package page (it drives the "View this app on Kody" button on About). kody dev check, commit, push, publish (packagePublishExternalPush).- After every publish, call the fork's own record-version (below) with the published commit. Never call this package's export from a fork: it writes this package's storage.
- Optional custom domain: set
internet_visible: true(owner approves at/@you/<leaf>/approve-capabilitiesbecause the app uses packageStorage), then runkody:@kentcdodds/cloudflare/custom-domainfromexecute(needs Cloudflare DNS for the zone and an org admin), orpackageAppCustomHostnameAddwithbinding_kind: "app"and add the CNAME and TXT records by hand. Skipping this is fine: the kody.run address works, and canonical/og URLs follow the request host. - Optional: re-render the share image with
node scripts/render-og.mjs https://your.domain/so the address printed in it is yours, then bumpVERSION.
- Rename
Singing part
- Picked on the About page (radio buttons). Stored as JSON in
pocket-piano:part. Missing, unknown, or unreadable values fall back to tenor. - Parts: soprano C4 to A5, alto F3 to D5, tenor C3 to A4, baritone A2 to F4,
bass E2 to E4. Keys in range get
.in-part(green top strip). - Home pos (
homePos): among windows that show the whole part, the one starting on a C closest to centered; otherwise the centered window, clamped. With 15 visible white keys: tenor C3 to C5, soprano C4 to C6, alto E3 to E5, baritone G2 to G4, bass E2 to E4. - The piano re-reads the part on
pageshowandstorage, so going back from About applies a change without a reload. - The main screen has no legend or instructions by design. They live in the About page's "How it works" card.
Edge cases
- localStorage:
pocket-piano:sustainandpocket-piano:part. The oldvolumeandoctavekeys are removed on load. - No em dashes in UI text or docs.