Skip to content
← Public packages

@kentcdodds/pocket-piano

A pocket piano for choir practice: your singing part's range on your phone, installable and offline.

FollowForkOpen app

AGENTS.md

203 lines · 10.2 KB · Markdown
Raw

@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 from packageContext.appBasePath.
  • src/client-script.ts: browser script as a String.raw template. 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 with python3 scripts/make-icons.py (needs Pillow). .kody/icon.png is a copy of the 512 icon.
  • src/notes.ts, src/index.ts: note helpers and the noteInfo export.
  • src/parts.ts: singing parts (MIDI ranges), the pocket-piano:part localStorage key, and PARTS_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's packageStorage (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 fileHere
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.tsxsrc/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.tssrc/update-script.ts
app/ui/document.tsx (data-running-sha, manifest use-credentials)documentHtml in src/app.ts
src/version.ts, src/record-version.tssame paths
public/sw.js (SKIP_WAITING, no 403 caching, /api/* bypass)serviceWorker() in src/app.ts
  • Every page renders data-running-sha from the recorded version. The update check compares it with /api/version and also checks for a waiting service worker. Refresh posts SKIP_WAITING to a waiting worker, asks the active worker to REFRESH_SHELL (re-fetch cached pages), then reloads.
  • localStorage keys follow the starter: pocket-piano-last-update-check and pocket-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 .row holds a full 88-key .strip translated 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).
  • maxPos is 52 white keys minus the total span (15 in both layouts).

Gestures

  • Pointer events on #keyboard with touch-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 through maxPos, 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.animating reports 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 is internet_visible, served at https://piano.kentcdodds.com/ (app-level custom hostname, set up with @kentcdodds/cloudflare/custom-domain: a DNS-only CNAME to domains.kody.run). The kody.run mount (/pocket-piano/) still works.
  • Every in-app URL (manifest, icons, sw.js and its scope, start_url, /about, /api/version) is built from packageContext.appBasePath, which is empty at the custom domain root and /pocket-piano on kody.run. The absolute rel=canonical, og:url, og:image, and twitter:image URLs use publicOrigin() in src/app.ts (the x-forwarded-host or host header, 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 from node 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 needs playwright-core resolvable (symlink a node_modules, which is gitignored). The address printed at the bottom is the host of appUrl (default https://piano.kentcdodds.com/) or OG_SITE_LABEL. Rerun it after visible UI changes, then bump VERSION.
  • 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 VERSION in src/app.ts to rotate the cache.
  • Keep internet_visible on, or piano.kentcdodds.com stops serving.

Forking

  • The package has no credentials, secrets, jobs, webhooks, or kody: imports and no kody.dependencies, so a fork in any org publishes as is. The only kody:@kentcdodds/... strings are in docs and JSDoc examples; rewrite them to the fork's name.
  • Steps for a fork:
    1. Rename name in package.json (@you/<leaf>) and the kody.id if you want a different mount path. Update the doc examples to match.
    2. Set CODE_URL in src/about-page.ts to the fork's package page (it drives the "View this app on Kody" button on About).
    3. kody dev check, commit, push, publish (packagePublishExternalPush).
    4. 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.
    5. Optional custom domain: set internet_visible: true (owner approves at /@you/<leaf>/approve-capabilities because the app uses packageStorage), then run kody:@kentcdodds/cloudflare/custom-domain from execute (needs Cloudflare DNS for the zone and an org admin), or packageAppCustomHostnameAdd with binding_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.
    6. Optional: re-render the share image with node scripts/render-og.mjs https://your.domain/ so the address printed in it is yours, then bump VERSION.

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 pageshow and storage, 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:sustain and pocket-piano:part. The old volume and octave keys are removed on load.
  • No em dashes in UI text or docs.