← Public packages
@kentcdodds/pocket-piano
A pocket piano for choir practice: your singing part's range on your phone, installable and offline.
src/about-page.ts
228 lines · 11.7 KB · TypeScriptimport { DEFAULT_PART_ID, PARTS } from './parts.ts'
import type { AppVersion } from './version.ts'
// About page, adapted from @kentcdodds/package-app-kit starter-remix:
// app/controllers/about.tsx (route + data), app/ui/layout.tsx (header, brand, nav),
// app/ui/about-panel.tsx (SHA, message, published, committed, last checked, code link,
// "Check for updates"), and app/ui/local-time.ts (times in the device's timezone).
export const CODE_URL = 'https://kody.codes/@kentcdodds/pocket-piano'
export const ABOUT_CSS = String.raw`
:root { color-scheme: dark; --bg: #14161c; --surface: #1d2029; --line: #3a3f4e; --text: #eef0f5; --muted: #9aa1b2; --accent: #f2b544; --bad: #e5484d; }
* { box-sizing: border-box; }
html, body { margin: 0; background: var(--bg); color: var(--text); font: 16px/1.45 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; -webkit-tap-highlight-color: transparent; }
body { padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); }
.wrap { max-width: 40rem; margin: 0 auto; padding: 16px; display: flex; flex-direction: column; gap: 16px; }
.header { display: flex; flex-direction: column; gap: 16px; }
.brand { display: flex; align-items: center; gap: 12px; min-height: 44px; }
.logo { width: 28px; height: 28px; border-radius: 7px; }
.title { margin: 0; font-size: 1.35rem; line-height: 1.2; flex: 1 1 auto; }
.nav { display: flex; flex-wrap: wrap; gap: 8px; }
.btn, button { font: inherit; color: var(--text); background: #2a2e3a; border: 1px solid var(--line); border-radius: 10px; min-height: 44px; padding: 0 14px; display: inline-flex; align-items: center; text-decoration: none; cursor: pointer; touch-action: manipulation; }
.btn[aria-current='page'] { border-color: var(--accent); color: var(--accent); }
.btn.kody { background: var(--accent); border-color: var(--accent); color: #1a1a1a; font-weight: 700; }
button[aria-busy='true'] { opacity: 0.72; cursor: progress; }
.card { background: var(--surface); border: 1px solid var(--line); border-radius: 14px; padding: 16px; display: flex; flex-direction: column; gap: 16px; }
dl { margin: 0; display: flex; flex-direction: column; gap: 10px; }
dt { color: var(--muted); font-size: 0.85rem; }
dd { margin: 0; overflow-wrap: anywhere; }
code { font: 0.95em ui-monospace, SFMono-Regular, Menlo, monospace; background: #2a2e3a; padding: 1px 6px; border-radius: 6px; }
a { color: var(--accent); }
.cluster { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.muted { color: var(--muted); }
.error { border: 1px solid color-mix(in srgb, var(--bad) 45%, var(--line)); background: color-mix(in srgb, var(--bad) 10%, var(--surface)); border-radius: 10px; padding: 10px 12px; }
.error p { margin: 4px 0 0; }
[hidden] { display: none !important; }
h2 { margin: 0; font-size: 1.1rem; }
.howto { margin: 0; padding-left: 1.2rem; display: flex; flex-direction: column; gap: 8px; }
.legend { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.legend li { display: flex; align-items: center; gap: 10px; }
.swatch { flex: 0 0 auto; width: 28px; height: 18px; border-radius: 4px; background: #f7f6f2; position: relative; overflow: hidden; }
.swatch.part::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 5px; background: #5cc8a8; }
.swatch.middle-c { background: linear-gradient(to bottom, #f6e2b3, #fff3d6 30%); box-shadow: inset 0 -4px 0 #f2b544; }
.parts { border: 0; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 8px; }
.parts legend { padding: 0; margin-bottom: 8px; color: var(--muted); font-size: 0.9rem; }
.part-option { display: flex; align-items: center; gap: 10px; min-height: 52px; padding: 8px 12px; border: 1px solid var(--line); border-radius: 10px; background: #2a2e3a; cursor: pointer; touch-action: manipulation; }
.part-option:has(input:checked) { border-color: #5cc8a8; background: color-mix(in srgb, #5cc8a8 14%, #2a2e3a); }
.part-option input { accent-color: #5cc8a8; width: 20px; height: 20px; margin: 0; }
.part-option span { display: flex; flex-direction: column; line-height: 1.2; }
.part-option small { color: var(--muted); font-size: 0.85rem; }
`
export const UPDATE_TOAST_CSS = String.raw`
.update-toast {
position: fixed;
z-index: 80;
left: 50%;
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
transform: translateX(-50%);
width: min(420px, calc(100% - 24px));
display: grid;
grid-template-columns: 1fr auto;
gap: 8px 12px;
align-items: center;
padding: 12px;
background: #1d2029;
color: #eef0f5;
border: 1px solid #3a3f4e;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
font: 14px/1.3 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
touch-action: manipulation;
}
.update-toast[hidden] { display: none; }
.update-toast p { margin: 2px 0 0; color: #9aa1b2; }
.update-toast .update-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.update-toast button { font: inherit; min-height: 40px; padding: 0 12px; border-radius: 10px; border: 1px solid #3a3f4e; background: #2a2e3a; color: #eef0f5; }
.update-toast button.accent { background: #f2b544; border-color: #f2b544; color: #1a1a1a; font-weight: 700; }
.update-toast button[aria-busy='true'] { opacity: 0.72; }
`
export const ABOUT_JS = String.raw`
(function () {
'use strict'
var parts = window.pocketPianoParts
var current = parts.read()
var partName = document.querySelector('[data-part-name]')
var partRange = document.querySelector('[data-part-range]')
function showPart(part) {
document.querySelectorAll('input[name="part"]').forEach(function (input) { input.checked = input.value === part.id })
partName.textContent = part.label
partRange.textContent = part.lowName + ' to ' + part.highName
}
showPart(current)
document.querySelectorAll('input[name="part"]').forEach(function (input) {
input.addEventListener('change', function () {
if (input.checked) showPart(parts.write(input.value))
})
})
window.addEventListener('pageshow', function () { showPart(parts.read()) })
var api = window.pocketPianoUpdate
document.querySelectorAll('[data-local-time]').forEach(function (el) {
var iso = el.getAttribute('data-local-time')
if (iso) { el.textContent = api.formatLocal(iso); el.title = iso }
})
var lastEl = document.querySelector('[data-last-checked]')
function showLast() {
var iso = api.readLastCheck()
lastEl.textContent = iso ? api.formatLocal(iso) : 'Never'
if (iso) lastEl.title = iso
}
showLast()
var btn = document.querySelector('[data-about-check-update]')
var status = document.querySelector('[data-about-status]')
var errorBox = document.querySelector('[data-about-error]')
var checking = false
btn.addEventListener('click', function () {
if (checking) return
checking = true
status.textContent = ''
errorBox.hidden = true
btn.disabled = true
btn.setAttribute('aria-busy', 'true')
// Spin-delay: only say "Checking..." if it takes a moment, then keep it briefly.
var shownAt = null
var timer = setTimeout(function () { btn.textContent = 'Checking...'; shownAt = Date.now() }, 200)
function finish() {
clearTimeout(timer)
var wait = shownAt ? Math.max(0, 400 - (Date.now() - shownAt)) : 0
return new Promise(function (r) { setTimeout(r, wait) }).then(function () {
checking = false
btn.disabled = false
btn.removeAttribute('aria-busy')
btn.textContent = 'Check for updates'
showLast()
})
}
api.check().then(function (result) {
return finish().then(function () {
if (result.kind === 'worker') status.textContent = 'Service worker waiting'
else if (result.kind === 'published') status.textContent = 'Latest ' + result.token
else status.textContent = 'Up to date'
api.showResult(result)
})
}, function (error) {
return finish().then(function () {
errorBox.querySelector('p').textContent = error && error.message ? error.message : String(error)
errorBox.hidden = false
})
})
})
})()
`
function escapeHtml(value: string) {
return value
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
}
/** Render the About page body (inside the shared document head built by app.ts). */
export function aboutBodyHtml(base: string, version: AppVersion) {
const sha = escapeHtml(version.sha)
const shortSha = escapeHtml(version.shortSha || version.sha || '-')
const message = escapeHtml(version.message || '-')
const published = escapeHtml(version.publishedAt)
const committed = escapeHtml(version.committedAt)
return `<div class="wrap">
<header class="header">
<div class="brand">
<img class="logo" src="${base}/icons/icon-192.png" width="28" height="28" alt="" decoding="async" title="@kentcdodds/pocket-piano" />
<h1 class="title">About</h1>
</div>
<nav class="nav" aria-label="App">
<a class="btn" href="${base}/">Piano</a>
<a class="btn" href="${base}/about" aria-current="page">About</a>
</nav>
</header>
<section class="card" aria-labelledby="part-heading">
<h2 id="part-heading">Singing part</h2>
<fieldset class="parts">
<legend>Highlights your range and opens the piano there. Saved on this device.</legend>
${PARTS.map(
(part) => ` <label class="part-option"><input type="radio" name="part" value="${part.id}"${part.id === DEFAULT_PART_ID ? ' checked' : ''} /><span>${part.label}<small>${part.lowName} to ${part.highName}</small></span></label>`,
).join('\n')}
</fieldset>
</section>
<section class="card" aria-labelledby="howto-heading">
<h2 id="howto-heading">How it works</h2>
<ul class="legend">
<li><span class="swatch part" aria-hidden="true"></span><span>Green strip: your part's range (<span data-part-name>Tenor</span>, <span data-part-range>C3 to A4</span>)</span></li>
<li><span class="swatch middle-c" aria-hidden="true"></span><span>Gold key: middle C (C4)</span></li>
</ul>
<ul class="howto">
<li>Tap a key to play it. Slide one finger to glide between keys. Press several keys for a chord.</li>
<li>Drag two fingers sideways to move along the keyboard (all 88 keys). When you let go it settles on whole keys.</li>
<li>In portrait the two rows are one keyboard: the top row picks up where the bottom row ends.</li>
<li>Tap the range button at the top left to jump back to your part.</li>
<li>Sustain lets notes ring after you lift your finger. Tap the note readout to hear the last note again.</li>
<li>Use your phone's volume buttons for loudness. There is no volume setting in the app.</li>
<li>Install it as an app: tap Install when it shows up, or use your browser menu and choose Install app or Add to Home screen. After the first visit it works offline.</li>
</ul>
</section>
<section class="card" aria-labelledby="kody-heading">
<h2 id="kody-heading">Source and fork on Kody</h2>
<p>This app is built and hosted with Kody. Its code is public and MIT licensed, so you can read it or make your own copy.</p>
<div class="cluster"><a class="btn kody" href="${CODE_URL}" target="_blank" rel="noopener" data-kody-link>View this app on Kody ↗</a></div>
</section>
<section class="card" data-about-panel>
<dl>
<div><dt>SHA</dt><dd><code title="${sha}">${shortSha}</code></dd></div>
<div><dt>Message</dt><dd>${message}</dd></div>
<div><dt>Published</dt><dd data-local-time="${published}">${published || '-'}</dd></div>
<div><dt>Committed</dt><dd data-local-time="${committed}">${committed || '-'}</dd></div>
<div><dt>Last checked</dt><dd data-last-checked>Never</dd></div>
</dl>
<div class="cluster">
<button type="button" data-about-check-update>Check for updates</button>
<span class="muted" data-about-status role="status"></span>
</div>
<div class="error" data-about-error role="alert" hidden><strong>Update check failed</strong><p></p></div>
</section>
</div>`
}