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 `
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.
${shortSha}