import { 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 `

About

Singing part

Highlights your range and opens the piano there. Saved on this device. ${PARTS.map( (part) => ` `, ).join('\n')}

How it works

Source and fork on Kody

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.

SHA
${shortSha}
Message
${message}
Published
${published || '-'}
Committed
${committed || '-'}
Last checked
Never
` }