# @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 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-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-`. ### After every publish Record the published commit, or About shows dashes and update checks stay silent: ```ts import recordVersion from 'kody:@kentcdodds/pocket-piano/record-version' await recordVersion({ sha: '', message: '', committedAt: '' }) ``` Call this package's export, never the kit's (that writes the kit's storage). ## Imports ```ts 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/`) 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//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.