Skip to content

Kody is live

Watch the launch video — what Kody is, and why it exists.

← Public packages

@kentcdodds/package-app-kit

Design tokens, PWA install/update, About/version, cache helpers, and optional realtime notes sync for Kody package apps.

src/starter-templates.ts

54 lines · 96.7 KB · TypeScript
/**
 * Embedded copy of `starter-remix/` (excluding STARTER.md and binary icons) for the Worker scaffolder.
 * Source of truth is the `starter-remix/` directory — regenerate via `node scripts/embed-starter.mjs` when it changes.
 * Icons: scaffolded apps use kit `iconPngResponse` (no binary copy required).
 */
export const STARTER_FILES: Record<string, string> = {
	".kody/icon.svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 64 64\" role=\"img\" aria-label=\"Replace with your product mark\">\n  <rect width=\"64\" height=\"64\" rx=\"14\" fill=\"#0a0a0a\"/>\n  <rect x=\"10\" y=\"10\" width=\"44\" height=\"44\" rx=\"12\" fill=\"#121212\" stroke=\"#3bffff\" stroke-width=\"2\"/>\n  <text x=\"32\" y=\"40\" text-anchor=\"middle\" font-family=\"ui-sans-serif, system-ui, sans-serif\" font-size=\"22\" font-weight=\"700\" fill=\"#3bffff\">R</text>\n</svg>\n",
	"AGENTS.template.md": "# __PACKAGE_NAME__ — agent notes\n\nScaffolded from `@kentcdodds/package-app-kit/starter-remix`. Human Intent lives in README.\n\n- **Handoff:** open private hosted apps with **Open Package App** from kody.codes; re-open from the package page when you need a fresh session or service worker.\n\n## After scaffolding\n\nAfter `create-package-app` lands this tree (or you copy `starter-remix/`), work through this checklist before treating the app as product-ready:\n\n1. **Brand the list mark** — Generate a distinctive square mark for this app and place it at `.kody/icon.png` (do not reuse the kit demo race-driver mark) (also `.svg` / `.webp` / `.jpg` / `.jpeg`). Prefer PNG on the Artifacts git lane. This is package / repo / community list identity. The scaffold ships `.kody/icon.svg` as a starting point — replace it with the product mark.\n2. **Brand PWA + in-app chrome marks** — Place matching product install icons at `icons/icon-192.png` and `icons/icon-512.png` (add maskable variants when the manifest uses them). Serve them from **app routes** (`iconPngResponse` or `image/png` handlers) and point favicon / header `<img>` at `routes.…href()`. For a hero or alternate bust, add the same kind of route (for example `/brand/logo-full.png`). Keep list identity in `.kody/`; keep `public/` for CSS, `sw.js`, and other asset-host files.\n3. **Fill product identity** — Set `__APP_TITLE__` / title, description, and `packageName` (and related placeholders) to the real product names.\n4. **Keep local version writers** — Use this package's `src/version.ts` + `src/record-version.ts` against **this** app's `packageStorage`. Call **this** package's `./record-version` (`kody:__PACKAGE_NAME__/record-version`), not the kit's export.\n5. **Record each publish** — After every successful publish, call this package's `./record-version` with the published SHA (and message / `committedAt` when available).\n6. **Remix recipe ([#2312](https://github.com/kentcdodds/kody/pull/2312))** — Confirm root `tsconfig.json` has `\"jsx\": \"react-jsx\"` + `\"jsxImportSource\": \"remix/ui\"`; put **both** file pragmas at the top of every SSR `.tsx` that uses JSX (`/** @jsxRuntime automatic */` and `/** @jsxImportSource remix/ui */`); `app/router.ts` remounts the Request and `export default { fetch }`; islands use `clientEntry('kody:app#…', …)` with matching browser registry keys.\n7. **Match manifest colors to the mark** — Set `themeColor` / `backgroundColor` in `buildWebManifest` (see `app/controllers/manifest.ts`) and the document `theme-color` meta so they harmonize with the mark.\n8. **Optional UI icons** — Add Lucide glyphs via `node scripts/add-lucide-icon.mjs <names>` into `app/icons/` when the UI needs icon-only or labeled controls.\n\nThen smoke with `packageAppFetch` (paths in **Smoke** below).\n\n## Remix recipe (framework-agnostic host)\n\n```json\n\"kody\": {\n  \"app\": {\n    \"entry\": \"./app/router.ts\",\n    \"client\": \"./app/assets/entry.ts\",\n    \"assets\": \"./public\"\n  }\n}\n```\n\n- **tsconfig** — root `tsconfig.json` with `\"jsx\": \"react-jsx\"` and `\"jsxImportSource\": \"remix/ui\"` (supporting config).\n- **Per-file JSX pragmas (required)** — at the top of every SSR `.tsx` that uses JSX:\n  ```tsx\n  /** @jsxRuntime automatic */\n  /** @jsxImportSource remix/ui */\n  ```\n- **Router** `app/router.ts` — remount the Request when routes include `appBasePath`, then `export default { fetch(request) { return router.fetch(remountRequest(request)) } }`. Keep the Worker/server graph free of `app/assets/entry.ts` except shared island modules that stay free of `kody:`.\n- **Routes** `app/routes.ts` — `route(packageContext?.appBasePath ?? '', { … })` so `href()` / redirects stay mounted.\n- **Client** `app/assets/entry.ts` — `run({ loadModule })` registry of named islands.\n- **Islands** — `clientEntry('kody:app#Name', function Name…)` + matching registry key.\n- **Assets** `public/` — `styles.css`, `sw.js` (reads `__version.json`; no content hash in source).\n\n## Imports — pin platform `remix/*` only\n\n```ts\nimport { createRouter } from 'remix/router'\nimport { form, route } from 'remix/routes'\nimport { formData } from 'remix/middleware/form-data'\n// In actions: context.get(FormData) — FormData is the global Web API constructor (context key).\n// Do not import FormData from remix/middleware/form-data (it is not exported). context.formData is equivalent but docs use get().\nimport { redirect } from 'remix/response/redirect'\nimport { renderToStream } from 'remix/ui/server'\nimport { clientEntry, on, run } from 'remix/ui'\nimport { KodyRuntime, packageContext } from 'kody:runtime'\n```\n\n**Forbid:**\n\n- `@remix-run/*` in `dependencies` / import maps\n- Fat npm `remix` runtime dependency (optional `devDependencies.remix@3.0.0-rc.2` for editor types only)\n- Vite / HMR / Pitlane tooling inside the Worker publish\n\n## Kit (Worker / SSR only)\n\n```ts\nimport { formatAboutPageData } from 'kody:@kentcdodds/package-app-kit/about'\nimport { buildWebManifest } from 'kody:@kentcdodds/package-app-kit/sw'\nimport { defaultManifestIcons, iconPngResponse } from 'kody:@kentcdodds/package-app-kit/icons'\n```\n\n**Version metadata:** use this package's local `./record-version` (`src/record-version.ts` → this app's `packageStorage`). Do not re-export or call `kody:@kentcdodds/package-app-kit/record-version` from execute — that writes the kit's storage and fails provenance. About / `/api/version` import `readRecordedVersion` relatively from `../../src/record-version.ts`.\n\nBrowser islands must **not** import `kody:` / `kody:@…`.\n\n## List / identity mark\n\nPut the package list mark at `.kody/icon.png` (also `.svg` / `.webp` / `.jpg` / `.jpeg`). This scaffold includes `.kody/icon.svg` — replace it with your product's official logo (prefer PNG on the git lane). Do **not** add root `community-icon.*`. Do **not** move PWA icons (`icons/icon-192.png`, routes served by kit `iconPngResponse`) into `.kody/`.\n\n## PWA / SW\n\nRegister `${assetBasePath}/sw.js` with `{ scope: `${appBasePath}/` }`. Precache via `GET …/_assets/__version.json` → `clientModuleUrl`. Icons via kit `iconPngResponse` routes (192 + 512). Manifest `crossorigin=\"use-credentials\"`.\n\n\n## Optional L3 — Realtime\n\nPackage apps can add live sync with kit `./realtime` + `./realtime-client` (see kit AGENTS **L3 — Realtime**). Typical recipe: export `handleRealtimeEvent` from `app/router.ts`, broadcast after durable writes via `KodyRuntime.realtime`, connect from a browser island with `connectPackageRealtime`. Do not wire WebSocket into every scaffold by default — adopt when the product needs multi-session notify (Notes-style).\n\n## Smoke\n\n```ts\nimport { kody } from 'kody:runtime'\nexport default async function main() {\n  const paths = ['/', '/notes', '/about', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/icon-512.png', '/health']\n  const out = []\n  for (const path of paths) {\n    const res = await kody.packageAppFetch({ kody_id: '__PACKAGE_ID__', path })\n    out.push({ path, status: res.status, type: res.headers?.['content-type'] || res.headers?.['Content-Type'] })\n  }\n  return out\n}\n```\n\n## Prefer zero content layout shift (CLS)\n\nKeep document flow stable. Update-available, toasts, and other post-hydration chrome use **fixed overlays** (see `UpdateBanner` / `.pak-update-toast`). Do not inject in-flow banners that push content down. Reserve height for async UI; hide install chrome in `display-mode: standalone`.\n\n\n## Tooltips (`data-tip`)\n\nTip when the tip adds information: icon-only install in the header, truncated SHA, ISO behind local time. Labeled nav and action buttons rely on their visible words. Color swatches use visible text labels.\n\n## Install header icon\n\nPlace `InstallCta` in the header brand row **to the right of the title**. Keep a reserved same-size slot (`pak-install-slot`) so BIP reveal does not shift layout. No large in-flow Install button.\n\n## Optimistic UI + press feedback\n\nImmediate `data-pending` / `aria-busy` on press. Use `app/ui/busy.ts` (`createBusyGate`) for spin-delay timing: delay busy labels ~200ms, keep them ≥400ms once shown, so fast ops do not flash \"Loading…\".\n\n\n## Lucide icons\n\n```bash\n# From the kit repo while editing the starter twin, or copy script into the app:\nnode scripts/add-lucide-icon.mjs download trash-2 plus\n```\n\nImport generated Remix Handle components from `app/icons/{name}.tsx` (they return a render function). Icon-only controls get `data-tip` + `aria-label`; labeled buttons keep visible text.\n\n## packageStorage migrations\n\n```ts\nimport {\n  createMigrationRunner,\n} from 'kody:@kentcdodds/package-app-kit/storage-migrations'\n\nconst ensureSchema = createMigrationRunner({\n  storage: context.get(KodyRuntime).packageStorage(),\n  versionKey: '__PACKAGE_ID__-schema-version',\n  migrations: [/* { version, name, up } */],\n})\nawait ensureSchema()\n```\n\nSee `app/data/notes.ts` for a copy-paste example.\n",
	"README.template.md": "# __APP_TITLE__\n\n## Intent\n\nDesktop + mobile friendly Kody package app scaffolded from `@kentcdodds/package-app-kit` `starter-remix/`.\nReplace this Intent with the product goal before the first real publish.\n\n## Stack\n\n- **Remix recipe** under the framework-agnostic host — `app/router.ts` remounts + `export default { fetch }` (no `kody.app.runtime`)\n- Root `tsconfig.json` with `\"jsx\": \"react-jsx\"` + `\"jsxImportSource\": \"remix/ui\"`\n- Dual per-file pragmas on every SSR `.tsx` that uses JSX: `/** @jsxRuntime automatic */` + `/** @jsxImportSource remix/ui */`\n- Mount-prefixed routes (`route(packageContext.appBasePath, …)`) with remount in the entry\n- Controllers + form actions with `get(KodyRuntime)` / `packageStorage()`\n- Browser `run()` entry (`app/assets/entry.ts`) hydrating `clientEntry('kody:app#Name', …)` islands — **no** `@remix-run/*`, **no** import map\n- Static assets (`public/styles.css`, `public/sw.js` via `__version.json`)\n- Kit: design tokens CSS, record-version / About, install CTA + update banner islands, Notes form + double-check\n\n## List mark\n\nIdentity/list icon: `.kody/icon.png` (scaffold ships `.kody/icon.svg`). Replace with your logo. PWA icons stay under `/icons/` via the kit — not in `.kody/`.\n\n## After scaffolding\n\nSee **After scaffolding** in [`AGENTS.md`](./AGENTS.md): brand the list mark + PWA icons, fill titles/description, keep local `record-version`, confirm the Remix recipe, and match manifest theme colors to the mark.\n\n## After publish\n\nCall **this package's** `./record-version` (local `packageStorage`), not the kit's:\n\n```ts\nimport recordVersion from 'kody:__PACKAGE_NAME__/record-version'\nawait recordVersion({ sha: '<published_commit>', message: '<commit message>' })\n```\n",
	"app/assets/entry.ts": "import { run } from 'remix/ui'\nimport { Counter } from '../ui/counter.tsx'\nimport { InstallCta } from '../ui/install-cta.tsx'\nimport { UpdateBanner } from '../ui/update-banner.tsx'\nimport { DoubleCheckButton } from '../ui/double-check-button.tsx'\nimport { AboutPanel } from '../ui/about-panel.tsx'\nimport { NotesDemo } from '../ui/notes-demo.tsx'\n\n// One browser module — hydration resolves islands by export name (no import map).\nconst clientEntries: Record<string, unknown> = {\n\tCounter,\n\tInstallCta,\n\tUpdateBanner,\n\tDoubleCheckButton,\n\tAboutPanel,\n\tNotesDemo,\n}\n\nconst app = run({\n\tasync loadModule(_moduleUrl, exportName) {\n\t\tconst component = clientEntries[exportName]\n\t\tif (typeof component !== 'function') {\n\t\t\tthrow new Error(`Unknown client entry \"${exportName}\"`)\n\t\t}\n\t\treturn component\n\t},\n})\n\napp.addEventListener('error', (event) => {\n\tconsole.error('Hydration error:', event.error)\n})\n\nfunction registerServiceWorker() {\n\tif (!('serviceWorker' in navigator)) return\n\tconst root = document.documentElement\n\tconst appBase = (root.getAttribute('data-app-base') || '').replace(/\\/$/, '')\n\tconst assetBase = (root.getAttribute('data-asset-base') || '').replace(/\\/$/, '')\n\tconst swUrl =\n\t\troot.getAttribute('data-sw-url') ||\n\t\t(assetBase ? `${assetBase}/sw.js` : `${appBase}/sw.js`)\n\tif (!appBase || !swUrl) return\n\tvoid navigator.serviceWorker\n\t\t.register(swUrl, { scope: `${appBase}/`, updateViaCache: 'none' })\n\t\t.catch((error) => console.warn('SW register failed', error))\n}\n\nvoid app.ready().then(() => {\n\tdocument.documentElement.dataset.hydrated = 'true'\n\tregisterServiceWorker()\n})\n",
	"app/controllers/about.tsx": "/** @jsxRuntime automatic */\n/** @jsxImportSource remix/ui */\nimport type { BuildAction } from 'remix/router'\nimport { readRecordedVersion } from '../../src/record-version.ts'\nimport { formatAboutPageData } from 'kody:@kentcdodds/package-app-kit/about'\nimport { render } from '../ui/render.tsx'\nimport { AppShell } from '../ui/layout.tsx'\nimport { AboutPanel } from '../ui/about-panel.tsx'\nimport { routes } from '../routes.ts'\n\nexport default {\n\tasync handler(context) {\n\t\tconst version = await readRecordedVersion()\n\t\tconst about = formatAboutPageData({\n\t\t\tversion,\n\t\t\trunningSha: version.sha,\n\t\t\tcodeUrl: null,\n\t\t})\n\n\t\treturn render(\n\t\t\tcontext,\n\t\t\t<AppShell page=\"about\" heading=\"About\">\n\t\t\t\t<AboutPanel\n\t\t\t\t\tsha={about.sha}\n\t\t\t\t\tshortSha={about.shortSha}\n\t\t\t\t\tmessage={about.message}\n\t\t\t\t\tpublishedAt={about.publishedAt}\n\t\t\t\t\tcommittedAt={about.committedAt}\n\t\t\t\t\tcodeUrl={about.codeUrl}\n\t\t\t\t\trunningSha={about.runningSha || about.sha}\n\t\t\t\t\tappId=\"__PACKAGE_ID__\"\n\t\t\t\t/>\n\t\t\t</AppShell>,\n\t\t\t{\n\t\t\t\tpage: 'about',\n\t\t\t\ttitle: 'About · __APP_TITLE__',\n\t\t\t\trunningSha: version.sha,\n\t\t\t},\n\t\t)\n\t},\n} satisfies BuildAction<'ANY', typeof routes.about>\n",
	"app/controllers/home.tsx": "/** @jsxRuntime automatic */\n/** @jsxImportSource remix/ui */\nimport type { BuildAction } from 'remix/router'\nimport { listNotes } from '../data/notes.ts'\nimport { render } from '../ui/render.tsx'\nimport { AppShell } from '../ui/layout.tsx'\nimport { Counter } from '../ui/counter.tsx'\nimport { routes } from '../routes.ts'\n\nexport default {\n\tasync handler(context) {\n\t\tconst notes = await listNotes(context)\n\t\tconst noteCount = notes.length\n\t\treturn render(\n\t\t\tcontext,\n\t\t\t<AppShell page=\"home\" heading=\"__APP_TITLE__\">\n\t\t\t\t<section class=\"pak-card pak-stack\" aria-label=\"Design tokens\">\n\t\t\t\t\t<p class=\"pak-eyebrow\">Colors</p>\n\t\t\t\t\t<div class=\"pak-swatches\">\n\t\t\t\t\t\t<span class=\"pak-swatch-label\">\n\t\t\t\t\t\t\t<span class=\"pak-swatch\" style=\"background:var(--accent)\" aria-hidden=\"true\" />\n\t\t\t\t\t\t\tAccent\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<span class=\"pak-swatch-label\">\n\t\t\t\t\t\t\t<span class=\"pak-swatch\" style=\"background:var(--ink)\" aria-hidden=\"true\" />\n\t\t\t\t\t\t\tInk\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<span class=\"pak-swatch-label\">\n\t\t\t\t\t\t\t<span class=\"pak-swatch\" style=\"background:var(--surface)\" aria-hidden=\"true\" />\n\t\t\t\t\t\t\tSurface\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<span class=\"pak-swatch-label\">\n\t\t\t\t\t\t\t<span class=\"pak-swatch\" style=\"background:var(--good)\" aria-hidden=\"true\" />\n\t\t\t\t\t\t\tGood\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</div>\n\t\t\t\t</section>\n\n\t\t\t\t<section class=\"pak-card pak-stack\" aria-label=\"Notes\">\n\t\t\t\t\t<p class=\"pak-eyebrow\">Notes</p>\n\t\t\t\t\t<a\n\t\t\t\t\t\tclass=\"pak-btn pak-btn-accent\"\n\t\t\t\t\t\thref={routes.notes.index.href()}\n\t\t\t\t\t\taria-label={`Notes (${noteCount})`}\n\t\t\t\t\t>\n\t\t\t\t\t\tNotes\n\t\t\t\t\t\t<span class=\"pak-pill\" aria-hidden=\"true\">\n\t\t\t\t\t\t\t{noteCount}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</a>\n\t\t\t\t</section>\n\n\t\t\t\t<section class=\"pak-card pak-stack\" aria-label=\"Remix island pattern\">\n\t\t\t\t\t<p class=\"pak-eyebrow\">Pattern example</p>\n\t\t\t\t\t<p class=\"pak-muted\">\n\t\t\t\t\t\tHydrated <code>clientEntry</code> island — copy this shape for interactive bits.\n\t\t\t\t\t</p>\n\t\t\t\t\t<div class=\"pak-cluster\">\n\t\t\t\t\t\t<Counter initialCount={0} label=\"Taps\" />\n\t\t\t\t\t</div>\n\t\t\t\t</section>\n\t\t\t</AppShell>,\n\t\t\t{ page: 'home', title: '__APP_TITLE__' },\n\t\t)\n\t},\n} satisfies BuildAction<'ANY', typeof routes.home>\n",
	"app/controllers/icons.ts": "import type { BuildAction } from 'remix/router'\nimport { iconPngResponse } from 'kody:@kentcdodds/package-app-kit/icons'\nimport { routes } from '../routes.ts'\n\nexport const icon192 = {\n\tasync handler() {\n\t\treturn iconPngResponse(192)\n\t},\n} satisfies BuildAction<'ANY', typeof routes.icon192>\n\nexport const icon512 = {\n\tasync handler() {\n\t\treturn iconPngResponse(512)\n\t},\n} satisfies BuildAction<'ANY', typeof routes.icon512>\n",
	"app/controllers/manifest.ts": "import type { BuildAction } from 'remix/router'\nimport { KodyRuntime } from 'kody:runtime'\nimport { buildWebManifest } from 'kody:@kentcdodds/package-app-kit/sw'\nimport { defaultManifestIcons } from 'kody:@kentcdodds/package-app-kit/icons'\nimport { routes } from '../routes.ts'\n\nexport default {\n\tasync handler(context) {\n\t\tconst { packageContext } = context.get(KodyRuntime)\n\t\tconst appBasePath = packageContext?.appBasePath ?? ''\n\t\tconst body = buildWebManifest({\n\t\t\tappBasePath,\n\t\t\tname: '__APP_TITLE__',\n\t\t\tshortName: '__APP_TITLE__',\n\t\t\tdescription: 'Scaffolded from @kentcdodds/package-app-kit starter-remix/.',\n\t\t\tthemeColor: '#0a0a0a',\n\t\t\tbackgroundColor: '#0a0a0a',\n\t\t\ticons: defaultManifestIcons(appBasePath),\n\t\t})\n\t\treturn new Response(JSON.stringify(body), {\n\t\t\theaders: {\n\t\t\t\t'content-type': 'application/manifest+json; charset=utf-8',\n\t\t\t\t'cache-control': 'no-store',\n\t\t\t},\n\t\t})\n\t},\n} satisfies BuildAction<'ANY', typeof routes.manifest>\n",
	"app/controllers/notes.tsx": "/** @jsxRuntime automatic */\n/** @jsxImportSource remix/ui */\nimport type { Controller } from 'remix/router'\nimport * as s from 'remix/data-schema'\nimport * as f from 'remix/data-schema/form-data'\nimport { redirect } from 'remix/response/redirect'\nimport { addNote, deleteNote, listNotes } from '../data/notes.ts'\nimport { render } from '../ui/render.tsx'\nimport { AppShell } from '../ui/layout.tsx'\nimport { ErrorBanner } from '../ui/error-banner.tsx'\nimport { NotesDemo } from '../ui/notes-demo.tsx'\nimport { routes } from '../routes.ts'\n\nconst addSchema = f.object({\n\tintent: f.field(s.string()),\n\ttext: f.field(s.string()),\n})\n\nconst deleteSchema = f.object({\n\tintent: f.field(s.string()),\n\tid: f.field(s.string()),\n})\n\nfunction wantsJson(context: { request: Request }) {\n\t// Client enhance sends Accept: application/json. Browser document POSTs send text/html…\n\tconst accept = (context.request.headers.get('accept') || '').toLowerCase()\n\treturn accept.startsWith('application/json')\n}\n\nexport default {\n\tactions: {\n\t\tasync index(context) {\n\t\t\tconst notes = await listNotes(context)\n\t\t\treturn render(\n\t\t\t\tcontext,\n\t\t\t\t<AppShell page=\"notes\" heading=\"Notes\">\n\t\t\t\t\t<NotesDemo notes={notes} actionHref={routes.notes.action.href()} />\n\t\t\t\t</AppShell>,\n\t\t\t\t{ page: 'notes', title: 'Notes · __APP_TITLE__' },\n\t\t\t)\n\t\t},\n\n\t\tasync action(context) {\n\t\t\tconst form = context.get(FormData)\n\t\t\tconst intent = String(form?.get?.('intent') ?? '')\n\t\t\tconst json = wantsJson(context)\n\n\t\t\tif (intent === 'delete') {\n\t\t\t\tconst parsed = s.parseSafe(deleteSchema, form)\n\t\t\t\tconst id = parsed.success ? String(parsed.value.id || '').trim() : ''\n\t\t\t\tconst result = id ? await deleteNote(context, id) : { ok: false }\n\t\t\t\tif (json) return Response.json({ ok: Boolean(result?.ok), id })\n\t\t\t\treturn redirect(routes.notes.index.href(), 303)\n\t\t\t}\n\n\t\t\tconst parsed = s.parseSafe(addSchema, form)\n\t\t\tconst text = parsed.success ? String(parsed.value.text || '').trim() : ''\n\t\t\tif (!text) {\n\t\t\t\tif (json) {\n\t\t\t\t\treturn Response.json(\n\t\t\t\t\t\t{ ok: false, error: 'A note needs some text.' },\n\t\t\t\t\t\t{ status: 400 },\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\treturn render(\n\t\t\t\t\tcontext,\n\t\t\t\t\t<AppShell page=\"notes\" heading=\"Notes\">\n\t\t\t\t\t\t<ErrorBanner\n\t\t\t\t\t\t\ttitle=\"Could not add note\"\n\t\t\t\t\t\t\tmessage=\"A note needs some text.\"\n\t\t\t\t\t\t\thint={\n\t\t\t\t\t\t\t\t<a class=\"pak-btn\" href={routes.notes.index.href()}>\n\t\t\t\t\t\t\t\t\tBack\n\t\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</AppShell>,\n\t\t\t\t\t{ status: 400, page: 'notes', title: 'Notes · __APP_TITLE__' },\n\t\t\t\t)\n\t\t\t}\n\n\t\t\tconst note = await addNote(context, text)\n\t\t\tif (json) return Response.json({ ok: true, note })\n\t\t\treturn redirect(routes.notes.index.href(), 303)\n\t\t},\n\t},\n} satisfies Controller<typeof routes.notes>\n",
	"app/controllers/version.ts": "import type { BuildAction } from 'remix/router'\nimport { readRecordedVersion } from '../../src/record-version.ts'\nimport { routes } from '../routes.ts'\n\nexport default {\n\tasync handler() {\n\t\tconst version = await readRecordedVersion()\n\t\treturn Response.json(version, {\n\t\t\theaders: { 'cache-control': 'no-store' },\n\t\t})\n\t},\n} satisfies BuildAction<'ANY', typeof routes.versionApi>\n",
	"app/data/notes.ts": "import { KodyRuntime } from 'kody:runtime'\nimport type { RequestContext } from 'remix/router'\nimport { createMigrationRunner } from 'kody:@kentcdodds/package-app-kit/storage-migrations'\n\nexport type Note = {\n\tid: string\n\ttext: string\n\tcreatedAt: string\n}\n\n/** Document shape after schema v1. */\ntype NotesDocument = {\n\titems: Array<Note>\n}\n\nconst notesKey = '__PACKAGE_ID__-notes'\nconst legacyNotesKey = '__PACKAGE_ID__-notes-v1'\nconst schemaVersionKey = '__PACKAGE_ID__-schema-version'\n\nfunction getStorage(context: RequestContext) {\n\treturn context.get(KodyRuntime).packageStorage()\n}\n\n/**\n * Demo schema bump: legacy flat array key → `{ items: Note[] }` document.\n * Runner is isolate-memoized; safe to await on every notes read/write.\n */\nconst ensureNotesSchema = (() => {\n\tlet ensure: ReturnType<typeof createMigrationRunner> | null = null\n\treturn (context: RequestContext) => {\n\t\tif (!ensure) {\n\t\t\tconst storage = getStorage(context)\n\t\t\tensure = createMigrationRunner({\n\t\t\t\tstorage,\n\t\t\t\tversionKey: schemaVersionKey,\n\t\t\t\tmigrations: [\n\t\t\t\t\t{\n\t\t\t\t\t\tversion: 1,\n\t\t\t\t\t\tname: 'notes-array-to-document',\n\t\t\t\t\t\tasync up(s) {\n\t\t\t\t\t\t\tconst legacy = await s.get(legacyNotesKey)\n\t\t\t\t\t\t\tconst existing = await s.get(notesKey)\n\t\t\t\t\t\t\tif (Array.isArray(legacy)) {\n\t\t\t\t\t\t\t\tawait s.set(notesKey, { items: legacy } satisfies NotesDocument)\n\t\t\t\t\t\t\t\tawait s.delete?.(legacyNotesKey)\n\t\t\t\t\t\t\t} else if (Array.isArray(existing)) {\n\t\t\t\t\t\t\t\t// Older builds may have stored a bare array at the new key.\n\t\t\t\t\t\t\t\tawait s.set(notesKey, { items: existing } satisfies NotesDocument)\n\t\t\t\t\t\t\t} else if (existing == null) {\n\t\t\t\t\t\t\t\tawait s.set(notesKey, { items: [] } satisfies NotesDocument)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t})\n\t\t}\n\t\treturn ensure()\n\t}\n})()\n\nasync function readDocument(context: RequestContext): Promise<NotesDocument> {\n\tawait ensureNotesSchema(context)\n\tconst stored = await getStorage(context).get(notesKey)\n\tif (stored && typeof stored === 'object' && Array.isArray((stored as NotesDocument).items)) {\n\t\treturn stored as NotesDocument\n\t}\n\treturn { items: [] }\n}\n\nexport async function listNotes(context: RequestContext): Promise<Array<Note>> {\n\tconst doc = await readDocument(context)\n\treturn doc.items\n}\n\nexport async function addNote(context: RequestContext, text: string) {\n\tconst storage = getStorage(context)\n\tconst doc = await readDocument(context)\n\tconst note: Note = {\n\t\tid: crypto.randomUUID(),\n\t\ttext,\n\t\tcreatedAt: new Date().toISOString(),\n\t}\n\tconst next: NotesDocument = { items: [note, ...doc.items] }\n\tawait storage.set(notesKey, next)\n\treturn note\n}\n\nexport async function deleteNote(context: RequestContext, id: string) {\n\tconst storage = getStorage(context)\n\tconst doc = await readDocument(context)\n\tconst items = doc.items.filter((note) => note.id !== id)\n\tawait storage.set(notesKey, { items } satisfies NotesDocument)\n\treturn { ok: items.length !== doc.items.length }\n}\n",
	"app/icons/ATTRIBUTION.md": "# Icon attribution\n\nIcons in this folder are from [Lucide](https://lucide.dev) ([GitHub](https://github.com/lucide-icons/lucide)).\n\n- License: **ISC** (and MIT for icons derived from Feather — see Lucide LICENSE)\n- Added via `node scripts/add-lucide-icon.mjs <name…>` from `lucide-static@0.544.0`\n- Prefer checked-in SVG + generated TSX over bundling the full Lucide package\n\nKeep this notice when copying icons into scaffolded apps.\n",
	"app/icons/download.svg": "<svg\n  class=\"lucide lucide-download\"\n  xmlns=\"http://www.w3.org/2000/svg\"\n  width=\"24\"\n  height=\"24\"\n  viewBox=\"0 0 24 24\"\n  fill=\"none\"\n  stroke=\"currentColor\"\n  stroke-width=\"2\"\n  stroke-linecap=\"round\"\n  stroke-linejoin=\"round\"\n>\n  <path d=\"M12 15V3\" />\n  <path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\" />\n  <path d=\"m7 10 5 5 5-5\" />\n</svg>\n",
	"app/icons/download.tsx": "/** @jsxRuntime automatic */\n/** @jsxImportSource remix/ui */\n/**\n * Lucide icon `download` (ISC) — generated by scripts/add-lucide-icon.mjs\n * Source: https://lucide.dev/icons/download\n * Remix Handle component (returns a render function). Re-run the script to refresh.\n */\nimport type { Handle } from 'remix/ui'\n\nexport type IconDownloadProps = {\n\tsize?: number\n\tclass?: string\n}\n\nexport function IconDownload(handle: Handle<IconDownloadProps>) {\n\treturn () => {\n\t\tconst size = handle.props.size ?? 20\n\t\treturn (\n\t\t\t<svg\n\t\t\t\txmlns=\"http://www.w3.org/2000/svg\"\n\t\t\t\twidth={size}\n\t\t\t\theight={size}\n\t\t\t\tviewBox=\"0 0 24 24\"\n\t\t\t\tfill=\"none\"\n\t\t\t\tstroke=\"currentColor\"\n\t\t\t\tstroke-width=\"2\"\n\t\t\t\tstroke-linecap=\"round\"\n\t\t\t\tstroke-linejoin=\"round\"\n\t\t\t\tclass={handle.props.class}\n\t\t\t\taria-hidden=\"true\"\n\t\t\t\tdata-lucide=\"download\"\n\t\t\t>\n\t\t\t\t<path d=\"M12 15V3\" />\n\t\t\t\t<path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\" />\n\t\t\t\t<path d=\"m7 10 5 5 5-5\" />\n\t\t\t</svg>\n\t\t)\n\t}\n}\n",
	"app/icons/plus.svg": "<svg\n  class=\"lucide lucide-plus\"\n  xmlns=\"http://www.w3.org/2000/svg\"\n  width=\"24\"\n  height=\"24\"\n  viewBox=\"0 0 24 24\"\n  fill=\"none\"\n  stroke=\"currentColor\"\n  stroke-width=\"2\"\n  stroke-linecap=\"round\"\n  stroke-linejoin=\"round\"\n>\n  <path d=\"M5 12h14\" />\n  <path d=\"M12 5v14\" />\n</svg>\n",
	"app/icons/plus.tsx": "/** @jsxRuntime automatic */\n/** @jsxImportSource remix/ui */\n/**\n * Lucide icon `plus` (ISC) — generated by scripts/add-lucide-icon.mjs\n * Source: https://lucide.dev/icons/plus\n * Remix Handle component (returns a render function). Re-run the script to refresh.\n */\nimport type { Handle } from 'remix/ui'\n\nexport type IconPlusProps = {\n\tsize?: number\n\tclass?: string\n}\n\nexport function IconPlus(handle: Handle<IconPlusProps>) {\n\treturn () => {\n\t\tconst size = handle.props.size ?? 20\n\t\treturn (\n\t\t\t<svg\n\t\t\t\txmlns=\"http://www.w3.org/2000/svg\"\n\t\t\t\twidth={size}\n\t\t\t\theight={size}\n\t\t\t\tviewBox=\"0 0 24 24\"\n\t\t\t\tfill=\"none\"\n\t\t\t\tstroke=\"currentColor\"\n\t\t\t\tstroke-width=\"2\"\n\t\t\t\tstroke-linecap=\"round\"\n\t\t\t\tstroke-linejoin=\"round\"\n\t\t\t\tclass={handle.props.class}\n\t\t\t\taria-hidden=\"true\"\n\t\t\t\tdata-lucide=\"plus\"\n\t\t\t>\n\t\t\t\t<path d=\"M5 12h14\" />\n\t\t\t\t<path d=\"M12 5v14\" />\n\t\t\t</svg>\n\t\t)\n\t}\n}\n",
	"app/icons/trash-2.svg": "<svg\n  class=\"lucide lucide-trash-2\"\n  xmlns=\"http://www.w3.org/2000/svg\"\n  width=\"24\"\n  height=\"24\"\n  viewBox=\"0 0 24 24\"\n  fill=\"none\"\n  stroke=\"currentColor\"\n  stroke-width=\"2\"\n  stroke-linecap=\"round\"\n  stroke-linejoin=\"round\"\n>\n  <path d=\"M10 11v6\" />\n  <path d=\"M14 11v6\" />\n  <path d=\"M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6\" />\n  <path d=\"M3 6h18\" />\n  <path d=\"M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2\" />\n</svg>\n",
	"app/icons/trash-2.tsx": "/** @jsxRuntime automatic */\n/** @jsxImportSource remix/ui */\n/**\n * Lucide icon `trash-2` (ISC) — generated by scripts/add-lucide-icon.mjs\n * Source: https://lucide.dev/icons/trash-2\n * Remix Handle component (returns a render function). Re-run the script to refresh.\n */\nimport type { Handle } from 'remix/ui'\n\nexport type IconTrash2Props = {\n\tsize?: number\n\tclass?: string\n}\n\nexport function IconTrash2(handle: Handle<IconTrash2Props>) {\n\treturn () => {\n\t\tconst size = handle.props.size ?? 20\n\t\treturn (\n\t\t\t<svg\n\t\t\t\txmlns=\"http://www.w3.org/2000/svg\"\n\t\t\t\twidth={size}\n\t\t\t\theight={size}\n\t\t\t\tviewBox=\"0 0 24 24\"\n\t\t\t\tfill=\"none\"\n\t\t\t\tstroke=\"currentColor\"\n\t\t\t\tstroke-width=\"2\"\n\t\t\t\tstroke-linecap=\"round\"\n\t\t\t\tstroke-linejoin=\"round\"\n\t\t\t\tclass={handle.props.class}\n\t\t\t\taria-hidden=\"true\"\n\t\t\t\tdata-lucide=\"trash-2\"\n\t\t\t>\n\t\t\t\t<path d=\"M10 11v6\" />\n\t\t\t\t<path d=\"M14 11v6\" />\n\t\t\t\t<path d=\"M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6\" />\n\t\t\t\t<path d=\"M3 6h18\" />\n\t\t\t\t<path d=\"M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2\" />\n\t\t\t</svg>\n\t\t)\n\t}\n}\n",
	"app/middleware/request-id.ts": "import { createContextKey, type Middleware } from 'remix/router'\n\nexport const RequestId = createContextKey<string>()\n\nexport function requestId(): Middleware {\n\treturn async (context, next) => {\n\t\tcontext.set(RequestId, crypto.randomUUID())\n\t\tconst response = await next()\n\t\tresponse.headers.set('x-request-id', context.get(RequestId) ?? '')\n\t\treturn response\n\t}\n}\n",
	"app/router.ts": "import { packageContext } from 'kody:runtime'\nimport { createRouter } from 'remix/router'\nimport { formData } from 'remix/middleware/form-data'\nimport { requestId } from './middleware/request-id.ts'\nimport { routes } from './routes.ts'\nimport home from './controllers/home.tsx'\nimport about from './controllers/about.tsx'\nimport notes from './controllers/notes.tsx'\nimport manifest from './controllers/manifest.ts'\nimport version from './controllers/version.ts'\nimport { icon192, icon512 } from './controllers/icons.ts'\n\nconst router = createRouter({ middleware: [requestId(), formData()] })\n\nrouter.map(routes.home, home)\nrouter.map(routes.about, about)\nrouter.map(routes.notes, notes)\nrouter.map(routes.manifest, manifest)\nrouter.map(routes.versionApi, version)\nrouter.map(routes.icon192, icon192)\nrouter.map(routes.icon512, icon512)\nrouter.get(routes.health, () => Response.json({ ok: true }))\n\n// Host strips the mount before forwarding. Remix route contracts that include\n// appBasePath need the hosted pathname, so remount here.\nfunction remountRequest(request: Request) {\n\tconst appBasePath = String(packageContext?.appBasePath ?? '').replace(/\\/+$/, '')\n\tif (!appBasePath) return request\n\tconst url = new URL(request.url)\n\turl.pathname = url.pathname === '/' ? appBasePath : appBasePath + url.pathname\n\treturn new Request(url, request)\n}\n\n// Default-export { fetch } — do not export the router object (Worker env is not RequestInit).\nexport default {\n\tfetch(request: Request) {\n\t\treturn router.fetch(remountRequest(request))\n\t},\n}\n",
	"app/routes.ts": "import { packageContext } from 'kody:runtime'\nimport { form, route } from 'remix/routes'\n\n// Hosted apps live under a mount (/packages/<leaf> on the subdomain).\n// Prefix the contract so every href(), redirect, and form action stays inside it.\nexport const routes = route(packageContext?.appBasePath ?? '', {\n\thome: '/',\n\tabout: '/about',\n\tnotes: form('notes'),\n\tmanifest: '/manifest.webmanifest',\n\ticon192: '/icons/icon-192.png',\n\ticon512: '/icons/icon-512.png',\n\tversionApi: '/api/version',\n\thealth: '/health',\n})\n",
	"app/ui/about-panel.tsx": "/** @jsxRuntime automatic */\n/** @jsxImportSource remix/ui */\nimport { clientEntry, on, type Handle } from 'remix/ui'\nimport {\n\tformatLocalDateTime,\n\treadLastUpdateCheckAt,\n\twriteLastUpdateCheckAt,\n} from './local-time.ts'\nimport { createBusyGate } from './busy.ts'\nimport { ErrorBanner } from './error-banner.tsx'\n\nexport type AboutPanelProps = {\n\tsha: string\n\tshortSha: string\n\tmessage: string\n\tpublishedAt: string\n\tcommittedAt: string\n\tcodeUrl: string | null\n\trunningSha: string\n\t/** localStorage key prefix */\n\tappId?: string\n}\n\n/**\n * About metadata + manual update check.\n * Times render in the browser's local timezone; last-checked is stored locally.\n */\nexport const AboutPanel = clientEntry(\n\t'kody:app#AboutPanel',\n\tfunction AboutPanel(handle: Handle<AboutPanelProps>) {\n\t\tconst appId = handle.props.appId || '__PACKAGE_ID__'\n\t\tconst checkBusy = createBusyGate({ delayMs: 200, minDurationMs: 400 })\n\t\tlet lastCheckedAt: string | null = null\n\t\tlet checking = false\n\t\tlet status: 'idle' | 'ok' | 'update' | 'error' = 'idle'\n\t\tlet statusDetail = ''\n\n\t\tif (typeof window !== 'undefined') {\n\t\t\tlastCheckedAt = readLastUpdateCheckAt(appId)\n\t\t}\n\n\t\tasync function runCheck() {\n\t\t\tif (checking || typeof window === 'undefined') return\n\t\t\tchecking = true\n\t\t\tstatus = 'idle'\n\t\t\tstatusDetail = ''\n\t\t\tcheckBusy.start(() => handle.update())\n\t\t\thandle.update()\n\t\t\ttry {\n\t\t\t\tconst root = document.documentElement\n\t\t\t\tconst appBase = root.getAttribute('data-app-base') || ''\n\t\t\t\tconst runningSha =\n\t\t\t\t\thandle.props.runningSha || root.getAttribute('data-running-sha') || ''\n\t\t\t\tconst res = await fetch(`${appBase}/api/version`, {\n\t\t\t\t\tcache: 'no-store',\n\t\t\t\t\tcredentials: 'same-origin',\n\t\t\t\t})\n\t\t\t\tconst version = res.ok\n\t\t\t\t\t? ((await res.json()) as { sha?: string; shortSha?: string })\n\t\t\t\t\t: {}\n\t\t\t\tlet waiting = false\n\t\t\t\tif ('serviceWorker' in navigator) {\n\t\t\t\t\tconst regs = await navigator.serviceWorker.getRegistrations()\n\t\t\t\t\tfor (const reg of regs) {\n\t\t\t\t\t\tif (reg.waiting) waiting = true\n\t\t\t\t\t\tif (reg.update) await reg.update().catch(() => null)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tlastCheckedAt = writeLastUpdateCheckAt(new Date().toISOString(), appId)\n\t\t\t\tconst latestSha = String(version.sha || '')\n\t\t\t\tif (waiting || (runningSha && latestSha && runningSha !== latestSha)) {\n\t\t\t\t\tstatus = 'update'\n\t\t\t\t\tstatusDetail = waiting\n\t\t\t\t\t\t? 'Service worker waiting'\n\t\t\t\t\t\t: `Latest ${version.shortSha || latestSha.slice(0, 7)}`\n\t\t\t\t} else {\n\t\t\t\t\tstatus = 'ok'\n\t\t\t\t\tstatusDetail = 'Up to date'\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tstatus = 'error'\n\t\t\t\tstatusDetail = error instanceof Error ? error.message : String(error)\n\t\t\t\tlastCheckedAt = writeLastUpdateCheckAt(new Date().toISOString(), appId)\n\t\t\t} finally {\n\t\t\t\tchecking = false\n\t\t\t\tawait checkBusy.stop(() => handle.update())\n\t\t\t\thandle.update()\n\t\t\t}\n\t\t}\n\n\t\treturn () => {\n\t\t\tconst {\n\t\t\t\tsha,\n\t\t\t\tshortSha,\n\t\t\t\tmessage,\n\t\t\t\tpublishedAt,\n\t\t\t\tcommittedAt,\n\t\t\t\tcodeUrl,\n\t\t\t} = handle.props\n\t\t\tconst publishedLocal = formatLocalDateTime(publishedAt)\n\t\t\tconst committedLocal = formatLocalDateTime(committedAt)\n\t\t\tconst checkedLocal = formatLocalDateTime(lastCheckedAt)\n\n\t\t\treturn (\n\t\t\t\t<section class=\"pak-card pak-stack\" data-about-panel>\n\t\t\t\t\t<dl class=\"pak-stack-sm\">\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<dt class=\"pak-muted\">SHA</dt>\n\t\t\t\t\t\t\t<dd>\n\t\t\t\t\t\t\t\t<code tabindex=\"0\" data-tip={sha || ''}>\n\t\t\t\t\t\t\t\t\t{shortSha || sha || '—'}\n\t\t\t\t\t\t\t\t</code>\n\t\t\t\t\t\t\t</dd>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<dt class=\"pak-muted\">Message</dt>\n\t\t\t\t\t\t\t<dd>{message || '—'}</dd>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<dt class=\"pak-muted\">Published</dt>\n\t\t\t\t\t\t\t<dd data-tip={publishedAt || undefined}>{publishedLocal}</dd>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<dt class=\"pak-muted\">Committed</dt>\n\t\t\t\t\t\t\t<dd data-tip={committedAt || undefined}>{committedLocal}</dd>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<dt class=\"pak-muted\">Last checked</dt>\n\t\t\t\t\t\t\t<dd data-tip={lastCheckedAt || undefined}>\n\t\t\t\t\t\t\t\t{lastCheckedAt ? checkedLocal : 'Never'}\n\t\t\t\t\t\t\t</dd>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{codeUrl ? (\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<dt class=\"pak-muted\">Code</dt>\n\t\t\t\t\t\t\t\t<dd>\n\t\t\t\t\t\t\t\t\t<a href={codeUrl} data-tip={codeUrl}>\n\t\t\t\t\t\t\t\t\t\tPackage\n\t\t\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t\t</dd>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t) : null}\n\t\t\t\t\t</dl>\n\t\t\t\t\t<div class=\"pak-cluster\">\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tclass=\"pak-btn\"\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tdata-about-check-update\n\t\t\t\t\t\t\tdisabled={checking ? true : undefined}\n\t\t\t\t\t\t\tdata-pending={checking ? 'true' : undefined}\n\t\t\t\t\t\t\taria-busy={checking ? 'true' : undefined}\n\t\t\t\t\t\t\tmix={on('click', () => void runCheck())}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{checkBusy.showBusy ? 'Checking…' : 'Check for updates'}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t{status === 'ok' ? (\n\t\t\t\t\t\t\t<span class=\"pak-muted\">Up to date</span>\n\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t{status === 'update' ? (\n\t\t\t\t\t\t\t<span class=\"pak-muted\">{statusDetail || 'Update available'}</span>\n\t\t\t\t\t\t) : null}\n\t\t\t\t\t</div>\n\t\t\t\t\t{status === 'error' ? (\n\t\t\t\t\t\t<ErrorBanner title=\"Update check failed\" message={statusDetail || 'Unknown error'} />\n\t\t\t\t\t) : null}\n\t\t\t\t</section>\n\t\t\t)\n\t\t}\n\t},\n)\n",
	"app/ui/busy.ts": "/**\n * Busy / pending helpers inspired by `spin-delay`:\n * - Give immediate affordance on press (so taps never feel ignored)\n * - Delay showing a \"loading\" label/spinner so fast ops do not flash\n * - Once shown, keep it visible for a minimum duration\n */\n\nexport type BusyPhase = 'idle' | 'pending' | 'busy'\n\nexport type BusyGateOptions = {\n\t/** Wait this long before flipping pending → busy (default 200ms). */\n\tdelayMs?: number\n\t/** Once busy, stay busy at least this long (default 400ms). */\n\tminDurationMs?: number\n}\n\nexport function createBusyGate(options: BusyGateOptions = {}) {\n\tconst delayMs = options.delayMs ?? 200\n\tconst minDurationMs = options.minDurationMs ?? 400\n\tlet phase: BusyPhase = 'idle'\n\tlet delayTimer: ReturnType<typeof setTimeout> | null = null\n\tlet busySince = 0\n\tlet generation = 0\n\n\tfunction clearDelay() {\n\t\tif (delayTimer) {\n\t\t\tclearTimeout(delayTimer)\n\t\t\tdelayTimer = null\n\t\t}\n\t}\n\n\treturn {\n\t\tget phase() {\n\t\t\treturn phase\n\t\t},\n\t\t/** Call synchronously on pointer/click — marks pending immediately. */\n\t\tstart(onChange?: (phase: BusyPhase) => void) {\n\t\t\tgeneration += 1\n\t\t\tconst gen = generation\n\t\t\tclearDelay()\n\t\t\tphase = 'pending'\n\t\t\tonChange?.(phase)\n\t\t\tdelayTimer = setTimeout(() => {\n\t\t\t\tif (gen !== generation) return\n\t\t\t\tphase = 'busy'\n\t\t\t\tbusySince = Date.now()\n\t\t\t\tonChange?.(phase)\n\t\t\t}, delayMs)\n\t\t},\n\t\t/** Resolve when the async work finishes; respects min busy duration. */\n\t\tasync stop(onChange?: (phase: BusyPhase) => void) {\n\t\t\tconst gen = generation\n\t\t\tclearDelay()\n\t\t\tif (phase === 'busy') {\n\t\t\t\tconst elapsed = Date.now() - busySince\n\t\t\t\tconst wait = Math.max(0, minDurationMs - elapsed)\n\t\t\t\tif (wait > 0) await new Promise((r) => setTimeout(r, wait))\n\t\t\t}\n\t\t\tif (gen !== generation) return\n\t\t\tphase = 'idle'\n\t\t\tonChange?.(phase)\n\t\t},\n\t\t/** True while the user should see a loading treatment (after delay). */\n\t\tget showBusy() {\n\t\t\treturn phase === 'busy'\n\t\t},\n\t\t/** True from the moment of press until stop — use for disable / aria-busy. */\n\t\tget isActive() {\n\t\t\treturn phase !== 'idle'\n\t\t},\n\t}\n}\n",
	"app/ui/counter.tsx": "/** @jsxRuntime automatic */\n/** @jsxImportSource remix/ui */\nimport { clientEntry, on, type Handle } from 'remix/ui'\n\n/** Hydrated island — explicit id + named function so the browser registry key matches. */\nexport const Counter = clientEntry(\n\t'kody:app#Counter',\n\tfunction Counter(handle: Handle<{ initialCount: number; label: string }>) {\n\t\tlet count = handle.props.initialCount\n\t\treturn () => (\n\t\t\t<button\n\t\t\t\ttype=\"button\"\n\t\t\t\tclass=\"pak-btn pak-btn-accent\"\n\t\t\t\tmix={on('click', () => {\n\t\t\t\t\tcount += 1\n\t\t\t\t\thandle.update()\n\t\t\t\t})}\n\t\t\t>\n\t\t\t\t{handle.props.label}: {count}\n\t\t\t</button>\n\t\t)\n\t},\n)\n",
	"app/ui/document.tsx": "/** @jsxRuntime automatic */\n/** @jsxImportSource remix/ui */\nimport type { Handle, RemixNode } from 'remix/ui'\n\nexport type DocumentProps = {\n\ttitle: string\n\tappBasePath: string\n\tassetBasePath: string\n\tclientModuleUrl: string | null\n\trunningSha?: string\n\tpage?: string\n\tchildren?: RemixNode\n}\n\nexport function Document(handle: Handle<DocumentProps>) {\n\treturn () => {\n\t\tconst appBase = handle.props.appBasePath.replace(/\\/$/, '')\n\t\tconst assetBase = handle.props.assetBasePath.replace(/\\/$/, '')\n\t\tconst icon192 = `${appBase}/icons/icon-192.png?v=4`\n\t\tconst manifest = `${appBase}/manifest.webmanifest`\n\t\tconst swUrl = assetBase ? `${assetBase}/sw.js` : `${appBase}/sw.js`\n\t\tconst pakConfig = JSON.stringify({\n\t\t\tappName: '__APP_TITLE__',\n\t\t\tappLabel: '__APP_TITLE__',\n\t\t\thintDismissKey: '__PACKAGE_ID__-install-hint-dismissed',\n\t\t\tversionPath: '/api/version',\n\t\t\tappBase,\n\t\t\tassetBase: assetBase || undefined,\n\t\t\tclientModuleUrl: handle.props.clientModuleUrl || undefined,\n\t\t\tswUrl,\n\t\t})\n\n\t\treturn (\n\t\t\t<html\n\t\t\t\tlang=\"en\"\n\t\t\t\tdata-app-base={appBase}\n\t\t\t\tdata-asset-base={assetBase || undefined}\n\t\t\t\tdata-client-module={handle.props.clientModuleUrl || undefined}\n\t\t\t\tdata-sw-url={swUrl}\n\t\t\t\tdata-running-sha={handle.props.runningSha || ''}\n\t\t\t\tdata-page={handle.props.page || ''}\n\t\t\t\tdata-app-name=\"__APP_TITLE__\"\n\t\t\t\tdata-pak-config={pakConfig}\n\t\t\t>\n\t\t\t\t<head>\n\t\t\t\t\t<meta charset=\"utf-8\" />\n\t\t\t\t\t<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\" />\n\t\t\t\t\t<meta name=\"color-scheme\" content=\"dark\" />\n\t\t\t\t\t<meta name=\"theme-color\" content=\"#0a0a0a\" />\n\t\t\t\t\t<meta name=\"apple-mobile-web-app-capable\" content=\"yes\" />\n\t\t\t\t\t<meta name=\"mobile-web-app-capable\" content=\"yes\" />\n\t\t\t\t\t<title>{handle.props.title}</title>\n\t\t\t\t\t<link rel=\"manifest\" href={manifest} crossorigin=\"use-credentials\" />\n\t\t\t\t\t<link rel=\"icon\" type=\"image/png\" sizes=\"192x192\" href={icon192} />\n\t\t\t\t\t<link rel=\"apple-touch-icon\" href={icon192} />\n\t\t\t\t\t<link rel=\"stylesheet\" href={`${assetBase}/styles.css`} />\n\t\t\t\t</head>\n\t\t\t\t<body>\n\t\t\t\t\t{handle.props.children}\n\t\t\t\t\t<div class=\"pak-toast-host\" data-toast-host aria-live=\"polite\" />\n\t\t\t\t\t{handle.props.clientModuleUrl ? (\n\t\t\t\t\t\t<script type=\"module\" src={handle.props.clientModuleUrl}></script>\n\t\t\t\t\t) : null}\n\t\t\t\t</body>\n\t\t\t</html>\n\t\t)\n\t}\n}\n",
	"app/ui/double-check-button.tsx": "/** @jsxRuntime automatic */\n/** @jsxImportSource remix/ui */\nimport { clientEntry, on, type Handle, type RemixNode } from 'remix/ui'\n\n/**\n * Two-step confirm for destructive form submits (progressive enhancement).\n * SSR / no-JS: `type=\"submit\"` so the parent form POSTs normally.\n * With JS: first click arms (preventDefault); second click submits; blur resets.\n * Pass `icon` for a Lucide (or other) glyph beside the label — labeled buttons stay tip-free.\n */\nexport const DoubleCheckButton = clientEntry(\n\t'kody:app#DoubleCheckButton',\n\tfunction DoubleCheckButton(\n\t\thandle: Handle<{\n\t\t\tlabel?: string\n\t\t\tconfirmLabel?: string\n\t\t\tclassName?: string\n\t\t\ticon?: RemixNode\n\t\t}>,\n\t) {\n\t\tlet armed = false\n\t\tconst label = handle.props.label ?? 'Delete'\n\t\tconst confirmLabel = handle.props.confirmLabel ?? 'Are you sure?'\n\n\t\treturn () => (\n\t\t\t<button\n\t\t\t\ttype=\"submit\"\n\t\t\t\tclass={handle.props.className ?? 'pak-btn pak-btn-danger'}\n\t\t\t\tdata-double-check\n\t\t\t\tdata-armed={armed ? 'true' : 'false'}\n\t\t\t\taria-pressed={armed ? 'true' : 'false'}\n\t\t\t\tmix={[\n\t\t\t\t\ton('click', (event) => {\n\t\t\t\t\t\tif (!armed) {\n\t\t\t\t\t\t\tevent.preventDefault()\n\t\t\t\t\t\t\tarmed = true\n\t\t\t\t\t\t\thandle.update()\n\t\t\t\t\t\t}\n\t\t\t\t\t}),\n\t\t\t\t\ton('blur', () => {\n\t\t\t\t\t\tif (armed) {\n\t\t\t\t\t\t\tarmed = false\n\t\t\t\t\t\t\thandle.update()\n\t\t\t\t\t\t}\n\t\t\t\t\t}),\n\t\t\t\t]}\n\t\t\t>\n\t\t\t\t{armed ? (\n\t\t\t\t\tconfirmLabel\n\t\t\t\t) : (\n\t\t\t\t\t<span class=\"pak-cluster pak-gap-2\">\n\t\t\t\t\t\t{handle.props.icon}\n\t\t\t\t\t\t{label}\n\t\t\t\t\t</span>\n\t\t\t\t)}\n\t\t\t</button>\n\t\t)\n\t},\n)\n",
	"app/ui/error-banner.tsx": "/** @jsxRuntime automatic */\n/** @jsxImportSource remix/ui */\nimport type { Handle, RemixNode } from 'remix/ui'\n\nexport type ErrorBannerProps = {\n\ttitle?: string\n\tmessage: string\n\thint?: RemixNode\n\t/** Shown via data-tip (hover/focus) */\n\tdetail?: string\n}\n\n/**\n * Inline error alert for reserved content areas.\n * Prefer toasts for ephemeral failures (zero CLS); use this when replacing expected content.\n */\nexport function ErrorBanner(handle: Handle<ErrorBannerProps>) {\n\treturn () => {\n\t\tconst { title, message, hint, detail } = handle.props\n\t\treturn (\n\t\t\t<div\n\t\t\t\tclass=\"pak-error\"\n\t\t\t\trole=\"alert\"\n\t\t\t\ttabindex=\"0\"\n\t\t\t\tdata-tip={detail || undefined}\n\t\t\t>\n\t\t\t\t<strong class=\"pak-error-title\">{title || 'Something went wrong'}</strong>\n\t\t\t\t<p class=\"pak-error-message\">{message}</p>\n\t\t\t\t{hint ? <p class=\"pak-muted pak-error-hint\">{hint}</p> : null}\n\t\t\t</div>\n\t\t)\n\t}\n}\n",
	"app/ui/install-cta.tsx": "/** @jsxRuntime automatic */\n/** @jsxImportSource remix/ui */\nimport { clientEntry, on, type Handle } from 'remix/ui'\nimport { IconDownload } from '../icons/download.tsx'\n\ntype DeferredPrompt = {\n\tprompt: () => Promise<void>\n\tuserChoice?: Promise<unknown>\n}\n\n/**\n * Module-level capture so island remounts cannot drop a preventDefault'd BIP\n * event (Chrome warns if preventDefault runs and prompt() never does).\n */\nlet capturedPrompt: DeferredPrompt | null = null\nlet bipListenerBound = false\nlet onCapturedChange: (() => void) | null = null\n\nfunction bindBeforeInstallPrompt() {\n\tif (typeof window === 'undefined' || bipListenerBound) return\n\tbipListenerBound = true\n\twindow.addEventListener('beforeinstallprompt', (event) => {\n\t\tconst promptFn = (event as unknown as DeferredPrompt).prompt\n\t\t// Only suppress the native banner when a header Install CTA can call prompt().\n\t\tif (typeof promptFn !== 'function' || !document.querySelector('[data-install]')) {\n\t\t\treturn\n\t\t}\n\t\tevent.preventDefault()\n\t\tcapturedPrompt = event as unknown as DeferredPrompt\n\t\tonCapturedChange?.()\n\t})\n\twindow.addEventListener('appinstalled', () => {\n\t\tcapturedPrompt = null\n\t\tonCapturedChange?.()\n\t})\n}\n\nasync function promptCapturedInstall() {\n\tconst event = capturedPrompt\n\tif (typeof event?.prompt !== 'function') return false\n\tcapturedPrompt = null\n\ttry {\n\t\t// Must run in the user-gesture turn — do not re-render before prompt().\n\t\tawait event.prompt()\n\t\tif (event.userChoice) await event.userChoice\n\t\treturn true\n\t} catch {\n\t\treturn false\n\t}\n}\n\n/**\n * Header install control (reserved slot) + optional iOS A2HS overlay.\n *\n * Install is an icon-only button in a reserved header slot (no CLS when BIP\n * arrives). Tip is appropriate here — the control has no visible text label.\n *\n * iOS Add-to-Home-Screen copy opens as a fixed overlay from the header icon.\n * There is no Welcome / first-run splash in the document flow.\n */\nexport const InstallCta = clientEntry(\n\t'kody:app#InstallCta',\n\tfunction InstallCta(handle: Handle<{ appName: string }>) {\n\t\tlet standalone = false\n\t\tlet ios = false\n\t\tlet hintDismissed = false\n\t\tlet iosHintOpen = false\n\t\tconst hintKey = '__PACKAGE_ID__-install-hint-dismissed'\n\n\t\tfunction detect() {\n\t\t\ttry {\n\t\t\t\tstandalone =\n\t\t\t\t\t(navigator as { standalone?: boolean }).standalone === true ||\n\t\t\t\t\twindow.matchMedia('(display-mode: standalone), (display-mode: fullscreen), (display-mode: minimal-ui)')\n\t\t\t\t\t\t.matches\n\t\t\t} catch {\n\t\t\t\tstandalone = false\n\t\t\t}\n\t\t\tios =\n\t\t\t\t/iphone|ipad|ipod/i.test(navigator.userAgent) ||\n\t\t\t\t(navigator.platform === 'MacIntel' && (navigator.maxTouchPoints || 0) > 1)\n\t\t\ttry {\n\t\t\t\thintDismissed = localStorage.getItem(hintKey) !== null\n\t\t\t} catch {\n\t\t\t\t/* ignore */\n\t\t\t}\n\t\t}\n\n\t\tif (typeof window !== 'undefined') {\n\t\t\tdetect()\n\t\t\tbindBeforeInstallPrompt()\n\t\t\tonCapturedChange = () => {\n\t\t\t\tdetect()\n\t\t\t\thandle.update()\n\t\t\t}\n\t\t}\n\n\t\treturn () => {\n\t\t\t// SSR keeps the reserved slot; reveal the control only in the browser.\n\t\t\tconst clientReady = typeof window !== 'undefined'\n\t\t\tif (clientReady) detect()\n\t\t\tconst canNative = Boolean(capturedPrompt?.prompt)\n\t\t\tconst showIos = clientReady && !standalone && ios && !hintDismissed && !canNative\n\t\t\tconst installAvailable = canNative || showIos\n\t\t\tconst tip = `Install ${handle.props.appName}`\n\n\t\t\treturn (\n\t\t\t\t<span class=\"pak-install-root\">\n\t\t\t\t\t{/*\n\t\t\t\t\t  Reserved same-size slot to the right of the title.\n\t\t\t\t\t  When BIP / iOS install guidance is unavailable, keep the slot\n\t\t\t\t\t  but hide the control (visibility) so appearing later is CLS-free.\n\t\t\t\t\t*/}\n\t\t\t\t\t<div\n\t\t\t\t\t\tclass=\"pak-install-slot\"\n\t\t\t\t\t\tdata-install-wrap\n\t\t\t\t\t\tdata-available={installAvailable ? 'true' : 'false'}\n\t\t\t\t\t>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tclass=\"pak-btn pak-btn-icon\"\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tdata-install\n\t\t\t\t\t\t\tdata-tip={tip}\n\t\t\t\t\t\t\taria-label={tip}\n\t\t\t\t\t\t\taria-hidden={installAvailable ? undefined : 'true'}\n\t\t\t\t\t\t\ttabindex={installAvailable ? undefined : -1}\n\t\t\t\t\t\t\tmix={on('click', async () => {\n\t\t\t\t\t\t\t\tif (capturedPrompt?.prompt) {\n\t\t\t\t\t\t\t\t\tawait promptCapturedInstall()\n\t\t\t\t\t\t\t\t\tdetect()\n\t\t\t\t\t\t\t\t\thandle.update()\n\t\t\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tif (showIos) {\n\t\t\t\t\t\t\t\t\tiosHintOpen = !iosHintOpen\n\t\t\t\t\t\t\t\t\thandle.update()\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<IconDownload size={20} />\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{showIos && iosHintOpen ? (\n\t\t\t\t\t\t<div class=\"pak-install-hint-overlay\" data-install-hint role=\"dialog\" aria-label=\"Add to Home Screen\">\n\t\t\t\t\t\t\t<div class=\"pak-card pak-stack pak-install-hint-card\">\n\t\t\t\t\t\t\t\t<p class=\"pak-muted\">\n\t\t\t\t\t\t\t\t\tAdd <strong>{handle.props.appName}</strong> to your Home Screen: tap{' '}\n\t\t\t\t\t\t\t\t\t<strong>Share</strong>, then <strong>Add to Home Screen</strong>.\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tclass=\"pak-btn\"\n\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\tdata-install-hint-dismiss\n\t\t\t\t\t\t\t\t\tmix={on('click', () => {\n\t\t\t\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\t\t\t\tlocalStorage.setItem(hintKey, String(Date.now()))\n\t\t\t\t\t\t\t\t\t\t} catch {\n\t\t\t\t\t\t\t\t\t\t\t/* ignore */\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\thintDismissed = true\n\t\t\t\t\t\t\t\t\t\tiosHintOpen = false\n\t\t\t\t\t\t\t\t\t\thandle.update()\n\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tDismiss\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t) : null}\n\t\t\t\t</span>\n\t\t\t)\n\t\t}\n\t},\n)\n",
	"app/ui/layout.tsx": "/** @jsxRuntime automatic */\n/** @jsxImportSource remix/ui */\nimport type { Handle, RemixNode } from 'remix/ui'\nimport { routes } from '../routes.ts'\nimport { InstallCta } from './install-cta.tsx'\nimport { UpdateBanner } from './update-banner.tsx'\n\nexport type AppShellProps = {\n\tpage: 'home' | 'about' | 'notes'\n\theading: string\n\t/** Optional muted lede under the nav. */\n\tlede?: RemixNode\n\tchildren?: RemixNode\n}\n\n/**\n * Tooltips (`data-tip`): tip only controls that are NOT obvious from visible\n * label/text. Labeled nav and action buttons do not get tips. Icon-only /\n * ambiguous controls (logo package id, header install) do.\n */\nexport function AppShell(handle: Handle<AppShellProps>) {\n\treturn () => {\n\t\tconst { page, heading, lede, children } = handle.props\n\t\treturn (\n\t\t\t<div class=\"pak-wrap pak-stack\">\n\t\t\t\t<header class=\"pak-header\">\n\t\t\t\t\t<div class=\"pak-brand\">\n\t\t\t\t\t\t<img\n\t\t\t\t\t\t\tclass=\"pak-logo\"\n\t\t\t\t\t\t\tsrc={routes.icon192.href()}\n\t\t\t\t\t\t\twidth=\"28\"\n\t\t\t\t\t\t\theight=\"28\"\n\t\t\t\t\t\t\talt=\"\"\n\t\t\t\t\t\t\tdecoding=\"async\"\n\t\t\t\t\t\t\ttabindex=\"0\"\n\t\t\t\t\t\t\tdata-tip=\"__PACKAGE_NAME__\"\n\t\t\t\t\t\t\taria-label=\"__PACKAGE_NAME__\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<h1 class=\"pak-title\">{heading}</h1>\n\t\t\t\t\t\t{/* Reserved install slot — right of title; icon tip is intentional */}\n\t\t\t\t\t\t<InstallCta appName=\"__APP_TITLE__\" />\n\t\t\t\t\t</div>\n\t\t\t\t\t<nav class=\"pak-nav\" aria-label=\"App\">\n\t\t\t\t\t\t<a\n\t\t\t\t\t\t\tclass=\"pak-btn\"\n\t\t\t\t\t\t\thref={routes.home.href()}\n\t\t\t\t\t\t\taria-current={page === 'home' ? 'page' : undefined}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tHome\n\t\t\t\t\t\t</a>\n\t\t\t\t\t\t<a\n\t\t\t\t\t\t\tclass=\"pak-btn\"\n\t\t\t\t\t\t\thref={routes.notes.index.href()}\n\t\t\t\t\t\t\taria-current={page === 'notes' ? 'page' : undefined}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tNotes\n\t\t\t\t\t\t</a>\n\t\t\t\t\t\t<a\n\t\t\t\t\t\t\tclass=\"pak-btn\"\n\t\t\t\t\t\t\thref={routes.about.href()}\n\t\t\t\t\t\t\taria-current={page === 'about' ? 'page' : undefined}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tAbout\n\t\t\t\t\t\t</a>\n\t\t\t\t\t</nav>\n\t\t\t\t\t{lede ? <p class=\"pak-muted pak-lede\">{lede}</p> : null}\n\t\t\t\t</header>\n\t\t\t\t{/* Fixed dismissible toast — does not affect document flow / CLS */}\n\t\t\t\t<UpdateBanner />\n\t\t\t\t{children}\n\t\t\t</div>\n\t\t)\n\t}\n}\n",
	"app/ui/local-time.ts": "/** Format an ISO timestamp in the browser's local timezone. */\nexport function formatLocalDateTime(iso: string | null | undefined): string {\n\tconst raw = String(iso || '').trim()\n\tif (!raw) return '—'\n\tconst ms = Date.parse(raw)\n\tif (!Number.isFinite(ms)) return '—'\n\ttry {\n\t\treturn new Intl.DateTimeFormat(undefined, {\n\t\t\tdateStyle: 'medium',\n\t\t\ttimeStyle: 'short',\n\t\t}).format(new Date(ms))\n\t} catch {\n\t\treturn new Date(ms).toLocaleString()\n\t}\n}\n\nexport function lastUpdateCheckStorageKey(appId = 'package-app-kit-demo'): string {\n\treturn `${appId}-last-update-check`\n}\n\nexport function readLastUpdateCheckAt(appId?: string): string | null {\n\tif (typeof localStorage === 'undefined') return null\n\ttry {\n\t\tconst value = localStorage.getItem(lastUpdateCheckStorageKey(appId))\n\t\treturn value && Date.parse(value) ? value : null\n\t} catch {\n\t\treturn null\n\t}\n}\n\nexport function writeLastUpdateCheckAt(iso = new Date().toISOString(), appId?: string): string {\n\ttry {\n\t\tlocalStorage.setItem(lastUpdateCheckStorageKey(appId), iso)\n\t} catch {\n\t\t/* ignore */\n\t}\n\treturn iso\n}\n",
	"app/ui/notes-demo.tsx": "/** @jsxRuntime automatic */\n/** @jsxImportSource remix/ui */\nimport { clientEntry, on, type Handle } from 'remix/ui'\nimport { DoubleCheckButton } from './double-check-button.tsx'\nimport { IconTrash2 } from '../icons/trash-2.tsx'\nimport { IconPlus } from '../icons/plus.tsx'\nimport { createBusyGate } from './busy.ts'\n\nexport type NotesDemoNote = {\n\tid: string\n\ttext: string\n\tcreatedAt: string\n}\n\nexport type NotesDemoProps = {\n\tnotes: Array<NotesDemoNote>\n\tactionHref: string\n}\n\ntype Row = NotesDemoNote & { optimistic?: boolean }\n\n/**\n * Notes list + add form as a progressive-enhancement island.\n * Without JS: native POST forms + 303 redirect. With JS: optimistic add (clears\n * the input, keeps focus) and isolated parallel optimistic adds/deletes — the\n * form stays enabled so notes can be hammered in rapid succession.\n */\nexport const NotesDemo = clientEntry(\n\t'kody:app#NotesDemo',\n\tfunction NotesDemo(handle: Handle<NotesDemoProps>) {\n\t\tlet rows: Array<Row> = handle.props.notes.map((n) => ({ ...n }))\n\t\tlet propsKey = JSON.stringify(handle.props.notes)\n\t\tconst deletePending = new Set<string>()\n\t\tconst addPending = new Set<string>()\n\t\tconst addBusy = createBusyGate({ delayMs: 200, minDurationMs: 400 })\n\n\t\tfunction syncFromPropsWhenIdle() {\n\t\t\tconst next = JSON.stringify(handle.props.notes)\n\t\t\tif (next === propsKey) return\n\t\t\tif (deletePending.size > 0 || addPending.size > 0) return\n\t\t\tpropsKey = next\n\t\t\trows = handle.props.notes.map((n) => ({ ...n }))\n\t\t}\n\n\t\tfunction focusAddInput() {\n\t\t\tconst el = document.querySelector<HTMLInputElement>(\n\t\t\t\t'[data-notes-demo] input[name=\"text\"]',\n\t\t\t)\n\t\t\tel?.focus()\n\t\t}\n\n\t\tasync function postAction(formData: FormData) {\n\t\t\tconst res = await fetch(handle.props.actionHref, {\n\t\t\t\tmethod: 'POST',\n\t\t\t\tbody: formData,\n\t\t\t\tcredentials: 'same-origin',\n\t\t\t\theaders: { Accept: 'application/json' },\n\t\t\t})\n\t\t\tlet data: unknown = null\n\t\t\ttry {\n\t\t\t\tdata = await res.json()\n\t\t\t} catch {\n\t\t\t\tdata = null\n\t\t\t}\n\t\t\treturn { ok: res.ok, data }\n\t\t}\n\n\t\tasync function onAddSubmit(event: Event) {\n\t\t\tevent.preventDefault()\n\t\t\tconst form = event.currentTarget as HTMLFormElement\n\t\t\tconst formData = new FormData(form)\n\t\t\tformData.set('intent', 'add')\n\t\t\tconst text = String(formData.get('text') || '').trim()\n\t\t\tif (!text) return\n\n\t\t\tconst tempId = `optimistic-${crypto.randomUUID()}`\n\t\t\tconst createdAt = new Date().toISOString()\n\t\t\tconst wasIdle = addPending.size === 0\n\t\t\taddPending.add(tempId)\n\t\t\trows = [{ id: tempId, text, createdAt, optimistic: true }, ...rows]\n\t\t\tform.reset()\n\t\t\tif (wasIdle) addBusy.start(() => handle.update())\n\t\t\thandle.update()\n\t\t\t// Keep caret in the field so Enter can fire another add immediately.\n\t\t\tfocusAddInput()\n\n\t\t\ttry {\n\t\t\t\tconst { ok, data } = await postAction(formData)\n\t\t\t\tconst note =\n\t\t\t\t\tdata && typeof data === 'object' && data !== null && 'note' in data\n\t\t\t\t\t\t? (data as { note?: NotesDemoNote }).note\n\t\t\t\t\t\t: null\n\t\t\t\tif (!ok || !note?.id) throw new Error('add failed')\n\t\t\t\trows = rows.map((row) =>\n\t\t\t\t\trow.id === tempId\n\t\t\t\t\t\t? { id: note.id, text: note.text, createdAt: note.createdAt }\n\t\t\t\t\t\t: row,\n\t\t\t\t)\n\t\t\t} catch {\n\t\t\t\trows = rows.filter((row) => row.id !== tempId)\n\t\t\t\tconst input = form.querySelector<HTMLInputElement>('input[name=\"text\"]')\n\t\t\t\tif (input && !input.value) input.value = text\n\t\t\t} finally {\n\t\t\t\taddPending.delete(tempId)\n\t\t\t\tif (addPending.size === 0) {\n\t\t\t\t\tawait addBusy.stop(() => handle.update())\n\t\t\t\t}\n\t\t\t\thandle.update()\n\t\t\t}\n\t\t}\n\n\t\tasync function onDeleteSubmit(event: Event, noteId: string) {\n\t\t\tevent.preventDefault()\n\t\t\tif (deletePending.has(noteId)) return\n\t\t\tconst form = event.currentTarget as HTMLFormElement\n\t\t\tconst formData = new FormData(form)\n\t\t\tformData.set('intent', 'delete')\n\t\t\tformData.set('id', noteId)\n\n\t\t\tconst index = rows.findIndex((row) => row.id === noteId)\n\t\t\tif (index < 0) return\n\t\t\tconst removed = rows[index]\n\t\t\tdeletePending.add(noteId)\n\t\t\t// Optimistic remove — each row has its own inflight flag so deletes stay parallel.\n\t\t\trows = rows.filter((row) => row.id !== noteId)\n\t\t\thandle.update()\n\n\t\t\ttry {\n\t\t\t\tconst { ok } = await postAction(formData)\n\t\t\t\tif (!ok) throw new Error('delete failed')\n\t\t\t} catch {\n\t\t\t\trows = [...rows.slice(0, index), removed, ...rows.slice(index)]\n\t\t\t} finally {\n\t\t\t\tdeletePending.delete(noteId)\n\t\t\t\thandle.update()\n\t\t\t}\n\t\t}\n\n\t\treturn () => {\n\t\t\tsyncFromPropsWhenIdle()\n\t\t\tconst adding = addBusy.isActive\n\t\t\tconst addLabel = addBusy.showBusy ? 'Adding…' : 'Add'\n\n\t\t\treturn (\n\t\t\t\t<section class=\"pak-card pak-stack\" data-notes-demo>\n\t\t\t\t\t<form\n\t\t\t\t\t\tmethod=\"post\"\n\t\t\t\t\t\taction={handle.props.actionHref}\n\t\t\t\t\t\tclass=\"pak-row\"\n\t\t\t\t\t\tmix={on('submit', (event) => void onAddSubmit(event))}\n\t\t\t\t\t>\n\t\t\t\t\t\t<input type=\"hidden\" name=\"intent\" value=\"add\" />\n\t\t\t\t\t\t<label class=\"pak-grow\" style=\"flex:1 1 180px\">\n\t\t\t\t\t\t\t<span class=\"visually-hidden\">New note</span>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\tclass=\"pak-input\"\n\t\t\t\t\t\t\t\tname=\"text\"\n\t\t\t\t\t\t\t\tmaxlength=\"120\"\n\t\t\t\t\t\t\t\tplaceholder=\"Write a note…\"\n\t\t\t\t\t\t\t\trequired\n\t\t\t\t\t\t\t\taria-label=\"New note\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tclass=\"pak-btn pak-btn-accent\"\n\t\t\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\t\t\tstyle=\"flex:0 0 auto\"\n\t\t\t\t\t\t\tdata-pending={adding ? 'true' : undefined}\n\t\t\t\t\t\t\taria-busy={adding ? 'true' : undefined}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<span class=\"pak-cluster pak-gap-2\">\n\t\t\t\t\t\t\t\t<IconPlus size={18} />\n\t\t\t\t\t\t\t\t{addLabel}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</form>\n\n\t\t\t\t\t<ul class=\"pak-list\" id=\"notes\" aria-live=\"polite\">\n\t\t\t\t\t\t{rows.length === 0 ? (\n\t\t\t\t\t\t\t<li class=\"pak-list-item\">\n\t\t\t\t\t\t\t\t<span class=\"pak-muted\">No notes yet</span>\n\t\t\t\t\t\t\t</li>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\trows.map((note) => {\n\t\t\t\t\t\t\t\tconst pendingAdd = Boolean(note.optimistic)\n\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t<li\n\t\t\t\t\t\t\t\t\t\tclass=\"pak-list-item\"\n\t\t\t\t\t\t\t\t\t\tkey={note.id}\n\t\t\t\t\t\t\t\t\t\tdata-note-id={note.id}\n\t\t\t\t\t\t\t\t\t\tdata-pending={pendingAdd ? 'true' : undefined}\n\t\t\t\t\t\t\t\t\t\taria-busy={pendingAdd ? 'true' : undefined}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t\t\t<strong>{note.text}</strong>\n\t\t\t\t\t\t\t\t\t\t\t{pendingAdd ? (\n\t\t\t\t\t\t\t\t\t\t\t\t<span class=\"pak-pending-label\"> Saving…</span>\n\t\t\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"pak-muted\" style=\"font-size:0.8rem\">\n\t\t\t\t\t\t\t\t\t\t\t\t{note.createdAt}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t{pendingAdd ? (\n\t\t\t\t\t\t\t\t\t\t\t<span class=\"pak-muted\" aria-hidden=\"true\" />\n\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t<form\n\t\t\t\t\t\t\t\t\t\t\t\tmethod=\"post\"\n\t\t\t\t\t\t\t\t\t\t\t\taction={handle.props.actionHref}\n\t\t\t\t\t\t\t\t\t\t\t\tmix={on('submit', (event) =>\n\t\t\t\t\t\t\t\t\t\t\t\t\tvoid onDeleteSubmit(event, note.id),\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<input type=\"hidden\" name=\"intent\" value=\"delete\" />\n\t\t\t\t\t\t\t\t\t\t\t\t<input type=\"hidden\" name=\"id\" value={note.id} />\n\t\t\t\t\t\t\t\t\t\t\t\t<DoubleCheckButton\n\t\t\t\t\t\t\t\t\t\t\t\t\tlabel=\"Delete\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tconfirmLabel=\"Are you sure?\"\n\t\t\t\t\t\t\t\t\t\t\t\t\ticon={<IconTrash2 size={18} />}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t</form>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</li>\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t)}\n\t\t\t\t\t</ul>\n\t\t\t\t</section>\n\t\t\t)\n\t\t}\n\t},\n)\n",
	"app/ui/render.tsx": "/** @jsxRuntime automatic */\n/** @jsxImportSource remix/ui */\nimport type { RequestContext } from 'remix/router'\nimport { KodyRuntime } from 'kody:runtime'\nimport type { RemixNode } from 'remix/ui'\nimport { renderToStream } from 'remix/ui/server'\nimport { createHtmlResponse } from 'remix/response/html'\nimport { Document } from './document.tsx'\nimport { ErrorBanner } from './error-banner.tsx'\nimport { toErrorMessage } from './to-error-message.ts'\n\nexport function render(\n\tcontext: RequestContext,\n\tchildren: RemixNode,\n\tinit?: ResponseInit & { title?: string; page?: string; runningSha?: string },\n) {\n\tconst { packageContext } = context.get(KodyRuntime)\n\tconst title = init?.title ?? '__APP_TITLE__'\n\tconst { title: _t, page: _p, runningSha: _r, ...responseInit } = init ?? {}\n\n\ttry {\n\t\tconst stream = renderToStream(\n\t\t\t<Document\n\t\t\t\ttitle={title}\n\t\t\t\tpage={init?.page}\n\t\t\t\trunningSha={init?.runningSha}\n\t\t\t\tappBasePath={packageContext?.appBasePath ?? ''}\n\t\t\t\tassetBasePath={packageContext?.assetBasePath ?? ''}\n\t\t\t\tclientModuleUrl={packageContext?.clientModuleUrl ?? null}\n\t\t\t>\n\t\t\t\t{children}\n\t\t\t</Document>,\n\t\t\t{\n\t\t\t\tframeSrc: context.url.href,\n\t\t\t\tonError(error) {\n\t\t\t\t\tconsole.error('SSR render error:', error)\n\t\t\t\t},\n\t\t\t},\n\t\t)\n\t\treturn createHtmlResponse(stream, responseInit)\n\t} catch (error) {\n\t\tconst message = toErrorMessage(error)\n\t\tconsole.error('SSR render failed:', error)\n\t\tconst stream = renderToStream(\n\t\t\t<Document\n\t\t\t\ttitle={`Error · ${title}`}\n\t\t\t\tpage={init?.page}\n\t\t\t\trunningSha={init?.runningSha}\n\t\t\t\tappBasePath={packageContext?.appBasePath ?? ''}\n\t\t\t\tassetBasePath={packageContext?.assetBasePath ?? ''}\n\t\t\t\tclientModuleUrl={packageContext?.clientModuleUrl ?? null}\n\t\t\t>\n\t\t\t\t<div class=\"pak-wrap pak-stack\">\n\t\t\t\t\t<ErrorBanner title=\"Something went wrong\" message={message} detail={message} />\n\t\t\t\t</div>\n\t\t\t</Document>,\n\t\t\t{ frameSrc: context.url.href },\n\t\t)\n\t\treturn createHtmlResponse(stream, { ...responseInit, status: responseInit.status ?? 500 })\n\t}\n}\n",
	"app/ui/to-error-message.ts": "export function toErrorMessage(error: unknown): string {\n\tif (error instanceof Error && error.message) return error.message\n\tif (typeof error === 'string' && error.trim()) return error.trim()\n\ttry {\n\t\treturn JSON.stringify(error)\n\t} catch {\n\t\treturn String(error)\n\t}\n}\n",
	"app/ui/update-banner.tsx": "/** @jsxRuntime automatic */\n/** @jsxImportSource remix/ui */\nimport { clientEntry, on, type Handle } from 'remix/ui'\nimport { writeLastUpdateCheckAt } from './local-time.ts'\nimport { createBusyGate } from './busy.ts'\n\n/**\n * Update check as a fixed dismissible toast — never inserts into document flow\n * (avoids content layout shift). Prefer overlays/toasts over in-flow banners.\n * Refresh gives immediate press feedback; busy label uses spin-delay timing.\n */\nexport const UpdateBanner = clientEntry(\n\t'kody:app#UpdateBanner',\n\tfunction UpdateBanner(_handle: Handle<Record<string, never>>) {\n\t\tlet available: string | null = null\n\t\tlet checking = false\n\t\tlet dismissed = false\n\t\tlet waitingWorker: ServiceWorker | null = null\n\t\tconst appId = 'package-app-kit-demo'\n\t\tconst refreshBusy = createBusyGate({ delayMs: 120, minDurationMs: 400 })\n\n\t\tfunction dismissKey(token: string) {\n\t\t\treturn `${appId}-update-dismissed-${token}`\n\t\t}\n\n\t\tfunction isDismissed(token: string) {\n\t\t\ttry {\n\t\t\t\treturn localStorage.getItem(dismissKey(token)) !== null\n\t\t\t} catch {\n\t\t\t\treturn false\n\t\t\t}\n\t\t}\n\n\t\tfunction markDismissed(token: string) {\n\t\t\ttry {\n\t\t\t\tlocalStorage.setItem(dismissKey(token), String(Date.now()))\n\t\t\t} catch {\n\t\t\t\t/* ignore */\n\t\t\t}\n\t\t}\n\n\t\tasync function check() {\n\t\t\tif (checking || typeof window === 'undefined') return\n\t\t\tchecking = true\n\t\t\ttry {\n\t\t\t\tconst root = document.documentElement\n\t\t\t\tconst appBase = root.getAttribute('data-app-base') || ''\n\t\t\t\tconst runningSha = root.getAttribute('data-running-sha') || ''\n\t\t\t\tconst versionPath = `${appBase}/api/version`\n\t\t\t\tconst res = await fetch(versionPath, { cache: 'no-store', credentials: 'same-origin' })\n\t\t\t\tconst version = res.ok ? ((await res.json()) as { sha?: string; shortSha?: string }) : {}\n\t\t\t\twaitingWorker = null\n\t\t\t\tif ('serviceWorker' in navigator) {\n\t\t\t\t\tconst regs = await navigator.serviceWorker.getRegistrations()\n\t\t\t\t\tfor (const reg of regs) {\n\t\t\t\t\t\tif (reg.waiting) waitingWorker = reg.waiting\n\t\t\t\t\t\tif (reg.update) await reg.update().catch(() => null)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif (waitingWorker) available = 'worker'\n\t\t\t\telse if (runningSha && version.sha && runningSha !== version.sha) {\n\t\t\t\t\tavailable = version.shortSha || String(version.sha).slice(0, 7)\n\t\t\t\t} else {\n\t\t\t\t\tavailable = null\n\t\t\t\t}\n\t\t\t\tdismissed = available ? isDismissed(available) : false\n\t\t\t\twriteLastUpdateCheckAt(new Date().toISOString(), appId)\n\t\t\t} catch {\n\t\t\t\t/* ignore */\n\t\t\t} finally {\n\t\t\t\tchecking = false\n\t\t\t\t_handle.update()\n\t\t\t}\n\t\t}\n\n\t\tif (typeof window !== 'undefined') {\n\t\t\tvoid check()\n\t\t\tdocument.addEventListener('visibilitychange', () => {\n\t\t\t\tif (document.visibilityState === 'visible') void check()\n\t\t\t})\n\t\t}\n\n\t\treturn () => {\n\t\t\tconst show = Boolean(available) && !dismissed\n\t\t\tif (!show) {\n\t\t\t\treturn <span hidden data-update-idle aria-hidden=\"true\" />\n\t\t\t}\n\t\t\tconst label =\n\t\t\t\tavailable === 'worker'\n\t\t\t\t\t? 'Update ready'\n\t\t\t\t\t: `Update available (${available})`\n\t\t\tconst refreshing = refreshBusy.isActive\n\t\t\tconst refreshLabel = refreshBusy.showBusy || refreshing ? 'Refreshing…' : 'Refresh'\n\t\t\treturn (\n\t\t\t\t<div\n\t\t\t\t\tclass=\"pak-toast pak-update-toast\"\n\t\t\t\t\tdata-update-toast\n\t\t\t\t\tdata-tone=\"default\"\n\t\t\t\t\trole=\"status\"\n\t\t\t\t\taria-live=\"polite\"\n\t\t\t\t>\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<strong>{label}</strong>\n\t\t\t\t\t\t<p class=\"pak-muted\">Refresh when you are ready</p>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"pak-toast-actions\">\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tclass=\"pak-btn pak-btn-accent\"\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tdata-pending={refreshing ? 'true' : undefined}\n\t\t\t\t\t\t\taria-busy={refreshing ? 'true' : undefined}\n\t\t\t\t\t\t\tdisabled={refreshing ? true : undefined}\n\t\t\t\t\t\t\tmix={on('click', () => {\n\t\t\t\t\t\t\t\tif (refreshBusy.isActive) return\n\t\t\t\t\t\t\t\trefreshBusy.start(() => {\n\t\t\t\t\t\t\t\t\t_handle.update()\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t_handle.update()\n\t\t\t\t\t\t\t\tif (waitingWorker) waitingWorker.postMessage('SKIP_WAITING')\n\t\t\t\t\t\t\t\t// Let paint flush pending state before reload\n\t\t\t\t\t\t\t\trequestAnimationFrame(() => {\n\t\t\t\t\t\t\t\t\tlocation.reload()\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{refreshLabel}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tclass=\"pak-btn\"\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tdata-tip=\"Dismiss until the next version\"\n\t\t\t\t\t\t\taria-label=\"Dismiss update\"\n\t\t\t\t\t\t\tdisabled={refreshing ? true : undefined}\n\t\t\t\t\t\t\tmix={on('click', () => {\n\t\t\t\t\t\t\t\tif (available) markDismissed(available)\n\t\t\t\t\t\t\t\tdismissed = true\n\t\t\t\t\t\t\t\t_handle.update()\n\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tDismiss\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)\n\t\t}\n\t},\n)\n",
	"package.template.json": "{\n  \"name\": \"__PACKAGE_NAME__\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"exports\": {\n    \".\": \"./src/index.ts\",\n    \"./record-version\": \"./src/record-version.ts\"\n  },\n  \"devDependencies\": {\n    \"remix\": \"3.0.0-rc.2\"\n  },\n  \"kody\": {\n    \"id\": \"__PACKAGE_ID__\",\n    \"description\": \"__KODY_DESCRIPTION__\",\n    \"searchText\": \"package app pwa remix starter kit install about update notes\",\n    \"tags\": [\n      \"app\",\n      \"pwa\",\n      \"remix\"\n    ],\n    \"category\": \"apps\",\n    \"app\": {\n      \"entry\": \"./app/router.ts\",\n      \"client\": \"./app/assets/entry.ts\",\n      \"assets\": \"./public\"\n    },\n    \"dependencies\": {\n      \"@kentcdodds/package-app-kit\": \"*\"\n    }\n  }\n}\n",
	"public/styles.css": "/* package-app-kit Remix starter tokens + layout (static copy; no Vite) */\n:root {\n  color-scheme: light dark;\n  --pak-bg0: #fafafa;\n  --pak-bg1: #f4f4f5;\n  --pak-surface: #ffffff;\n  --pak-surface-2: #f4f4f5;\n  --pak-ink: #18181b;\n  --pak-muted: #71717a;\n  --pak-line: rgba(24, 24, 27, 0.12);\n  --pak-accent: #0891b2;\n  --pak-accent-2: #06b6d4;\n  --pak-accent-ink: #ffffff;\n  --pak-stripe-cyan: #06b6d4;\n  --pak-stripe-green: #16a34a;\n  --pak-stripe-yellow: #ca8a04;\n  --pak-stripe-magenta: #db2777;\n  --pak-stripe-red: #dc2626;\n  --pak-good: #16a34a;\n  --pak-warn: #ca8a04;\n  --pak-bad: #dc2626;\n  --pak-radius: 12px;\n  --pak-radius-sm: 10px;\n  --pak-tap: 48px;\n  --pak-gap: 12px;\n  --pak-space-1: 4px;\n  --pak-space-2: 8px;\n  --pak-space-3: 12px;\n  --pak-space-4: 16px;\n  --pak-space-5: 24px;\n  --pak-space-6: 32px;\n  --pak-space-7: 48px;\n  --pak-space-8: 64px;\n  --bg0: var(--pak-bg0);\n  --bg1: var(--pak-bg1);\n  --surface: var(--pak-surface);\n  --surface-2: var(--pak-surface-2);\n  --ink: var(--pak-ink);\n  --muted: var(--pak-muted);\n  --line: var(--pak-line);\n  --accent: var(--pak-accent);\n  --accent-2: var(--pak-accent-2);\n  --accent-ink: var(--pak-accent-ink);\n  --good: var(--pak-good);\n  --warn: var(--pak-warn);\n  --bad: var(--pak-bad);\n  --radius: var(--pak-radius);\n  --radius-sm: var(--pak-radius-sm);\n  --tap: var(--pak-tap);\n  --gap: var(--pak-gap);\n  --space-1: var(--pak-space-1);\n  --space-2: var(--pak-space-2);\n  --space-3: var(--pak-space-3);\n  --space-4: var(--pak-space-4);\n  --space-5: var(--pak-space-5);\n  --space-6: var(--pak-space-6);\n  --space-7: var(--pak-space-7);\n  --space-8: var(--pak-space-8);\n}\n@media (prefers-color-scheme: dark) {\n  :root {\n    --pak-bg0: #0a0a0a;\n    --pak-bg1: #121212;\n    --pak-surface: #161616;\n    --pak-surface-2: #1c1c1c;\n    --pak-ink: #f5f5f5;\n    --pak-muted: #a3a3a3;\n    --pak-line: rgba(245, 245, 245, 0.12);\n    --pak-accent: #3bffff;\n    --pak-accent-2: #22d3ee;\n    --pak-accent-ink: #0a0a0a;\n    --pak-stripe-cyan: #3bffff;\n    --pak-stripe-green: #00e887;\n    --pak-stripe-yellow: #ffe600;\n    --pak-stripe-magenta: #ff2bd6;\n    --pak-stripe-red: #ff2a2a;\n    --pak-good: #00e887;\n    --pak-warn: #ffe600;\n    --pak-bad: #ff2a2a;\n  }\n}\n* { box-sizing: border-box; }\nhtml, body { margin: 0; min-height: 100%; }\nbody {\n  font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;\n  background: linear-gradient(160deg, var(--bg0), var(--bg1));\n  color: var(--ink);\n  line-height: 1.45;\n}\na { color: var(--accent-2); }\ncode { font-size: 0.9em; }\n.pak-wrap { max-width: 42rem; margin: 0 auto; padding: var(--space-5) var(--space-5) var(--space-8); }\n.pak-stack { display: flex; flex-direction: column; gap: var(--space-5); }\n.pak-stack-sm { display: flex; flex-direction: column; gap: var(--space-2); }\n.pak-cluster { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; }\n.pak-row { display: flex; flex-wrap: wrap; gap: var(--space-3); align-items: end; }\n.pak-inset { padding: var(--space-4); }\n.pak-mt-1 { margin-top: var(--space-1); }\n.pak-mt-2 { margin-top: var(--space-2); }\n.pak-gap-2 { gap: var(--space-2); }\n.pak-card {\n  background: var(--surface);\n  border: 1px solid var(--line);\n  border-radius: var(--radius);\n  padding: var(--space-5);\n}\n.pak-eyebrow { margin: 0; font-size: 0.75rem; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); }\n.pak-muted { color: var(--muted); margin: 0; }\n.pak-nav { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-top: 0; }\n.pak-btn {\n  display: inline-flex; align-items: center; justify-content: center;\n  min-height: var(--tap); padding: 0 var(--space-4);\n  border-radius: var(--radius-sm); border: 1px solid var(--line);\n  background: var(--surface-2); color: var(--ink); font: inherit; text-decoration: none; cursor: pointer;\n}\n.pak-btn[aria-current=\"page\"] { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }\n.pak-btn-accent { background: var(--accent); border-color: transparent; color: var(--accent-ink); }\n.pak-btn-danger { background: transparent; border-color: var(--bad); color: var(--bad); }\n.pak-btn-danger[data-armed=\"true\"] { background: var(--bad); color: #fff; }\n.pak-btn:disabled { opacity: 0.55; cursor: not-allowed; }\n.pak-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-2); }\n.pak-list-item {\n  display: flex; justify-content: space-between; gap: var(--space-3); align-items: center;\n  padding: var(--space-3); border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2);\n}\n\n.pak-list-item[data-pending=\"true\"] {\n  opacity: 0.62;\n  border-style: dashed;\n}\n.pak-list-item[data-pending=\"true\"] .pak-pending-label {\n  display: inline;\n  color: var(--muted);\n  font-size: 0.8rem;\n  font-weight: 650;\n}\n.pak-pending-label { display: none; }\n.pak-input {\n  display: block; width: 100%; min-height: var(--tap); margin-top: var(--space-1);\n  padding: 0 var(--space-3); border: 1px solid var(--line); border-radius: var(--radius-sm);\n  font: inherit; background: var(--surface); color: var(--ink);\n}\n.pak-swatches { display: flex; flex-wrap: wrap; gap: var(--space-3); }\n.pak-swatch { display: inline-block; width: 1.25rem; height: 1.25rem; border-radius: 999px; border: 1px solid var(--line); vertical-align: middle; margin-right: var(--space-1); }\n.pak-swatch-label { display: inline-flex; align-items: center; font-size: 0.85rem; color: var(--muted); }\n.pak-toast-host {\n  position: fixed; right: var(--space-4); bottom: var(--space-4); z-index: 40;\n  display: flex; flex-direction: column; gap: var(--space-2); max-width: min(22rem, calc(100vw - 2rem));\n}\n.pak-toast {\n  background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);\n  padding: var(--space-3); box-shadow: 0 8px 24px rgba(0,0,0,0.12);\n}\n.pak-toast[data-tone=\"success\"] { border-color: var(--good); }\n.pak-toast[data-tone=\"error\"] { border-color: var(--bad); }\n.pak-toast-actions { display: flex; gap: var(--space-2); margin-top: var(--space-2); }\n[hidden] { display: none !important; }\n\n/* Hide install / first-run chrome when already installed (CSS before JS hydrates). */\n@media (display-mode: standalone), (display-mode: fullscreen), (display-mode: minimal-ui) {\n  [data-install-wrap], [data-install-hint], [data-first-run-splash],\n  .pak-splash-overlay, .pak-install-hint-overlay { display: none !important; }\n}\n\n.visually-hidden {\n  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;\n  overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0;\n}\n.pak-header { display: flex; flex-direction: column; gap: var(--space-4); }\n.pak-brand { display: flex; align-items: center; gap: var(--space-3); min-height: var(--tap); }\n.pak-logo {\n  width: 28px; height: 28px; border-radius: 8px; flex: 0 0 auto;\n  border: 1px solid var(--line); background: transparent; object-fit: cover;\n}\n.pak-title { margin: 0; font-size: clamp(1.2rem, 2.5vw, 1.55rem); line-height: 1.2; flex: 1 1 auto; min-width: 0; }\n.pak-lede { margin: 0; }\n.pak-grow { display: block; }\n/* Mobile-friendly tips: hover + keyboard/tap focus (no hover-only). */\n[data-tip] { position: relative; }\n[data-tip]::after {\n  content: attr(data-tip);\n  position: absolute; left: 50%; bottom: calc(100% + 8px); transform: translateX(-50%);\n  z-index: 30; max-width: min(16rem, 70vw); padding: 0.4rem 0.55rem;\n  border-radius: 8px; border: 1px solid var(--line); background: var(--ink); color: var(--surface);\n  font-size: 0.75rem; line-height: 1.3; white-space: normal; text-align: center;\n  opacity: 0; pointer-events: none; transition: opacity 0.12s ease;\n}\n[data-tip]:hover::after,\n[data-tip]:focus::after,\n[data-tip]:focus-within::after,\n[data-tip]:focus-visible::after { opacity: 1; }\n\n\n.pak-pill {\n  display: inline-flex; align-items: center; justify-content: center;\n  min-width: 1.5rem; padding: 0 0.45rem; margin-left: var(--space-2);\n  border-radius: 999px; font-size: 0.8rem; font-weight: 650;\n  background: color-mix(in srgb, var(--accent-ink) 18%, transparent);\n  color: inherit;\n}\n.pak-btn-icon {\n  width: var(--tap); min-width: var(--tap); padding: 0;\n  flex: 0 0 auto;\n}\n.pak-install-root { display: contents; }\n.pak-install-slot {\n  width: var(--tap); height: var(--tap);\n  flex: 0 0 auto;\n  margin-left: auto;\n  display: inline-flex; align-items: center; justify-content: center;\n}\n/* Reserve space always; hide control without collapsing the slot (no CLS). */\n.pak-install-slot[data-available=\"false\"] .pak-btn-icon {\n  visibility: hidden;\n  pointer-events: none;\n}\n.pak-splash-overlay,\n.pak-install-hint-overlay {\n  position: fixed;\n  z-index: 70;\n  left: 50%;\n  top: calc(12px + env(safe-area-inset-top, 0px));\n  transform: translateX(-50%);\n  width: min(420px, calc(100% - 24px));\n  pointer-events: none;\n}\n.pak-splash-card,\n.pak-install-hint-card {\n  pointer-events: auto;\n  box-shadow: 0 10px 28px rgba(0,0,0,0.18);\n}\n.pak-install-hint-overlay {\n  top: auto;\n  bottom: calc(16px + env(safe-area-inset-bottom, 0px));\n}\n/* Author CSS must not defeat the HTML hidden attribute (display:grid/flex overlays). */\n.pak-splash-overlay[hidden],\n.pak-install-hint-overlay[hidden],\n[data-install-hint][hidden],\n[data-first-run-splash][hidden] {\n  display: none !important;\n}\n\n/* Update toast: fixed overlay — never participates in document flow (no CLS). */\n.pak-update-toast {\n  position: fixed;\n  z-index: 80;\n  left: 50%;\n  bottom: calc(16px + env(safe-area-inset-bottom, 0px));\n  transform: translateX(-50%);\n  width: min(420px, calc(100% - 24px));\n  display: grid;\n  grid-template-columns: 1fr auto;\n  gap: var(--space-2) var(--space-3);\n  align-items: center;\n  margin: 0;\n  pointer-events: auto;\n  box-shadow: 0 10px 28px rgba(0,0,0,0.18);\n}\n.pak-update-toast .pak-toast-actions { margin-top: 0; flex-wrap: wrap; justify-content: flex-end; }\n.pak-update-toast p { margin: 2px 0 0; }\n\n/* Immediate press feedback without CLS — opacity/cursor only, no size change. */\n.pak-btn[data-pending=\"true\"],\n.pak-btn[aria-busy=\"true\"] {\n  opacity: 0.72;\n  cursor: progress;\n}\n\n/* Error alert — reserved in-flow slot; size stays stable (no late expansion tricks). */\n.pak-error {\n  border: 1px solid color-mix(in srgb, var(--bad) 45%, var(--line));\n  background: color-mix(in srgb, var(--bad) 8%, var(--surface));\n  border-radius: var(--radius);\n  padding: var(--space-3) var(--space-4);\n  display: flex;\n  flex-direction: column;\n  gap: var(--space-1);\n}\n.pak-error-title { font-size: 0.95rem; color: var(--bad); }\n.pak-error-message { margin: 0; }\n.pak-error-hint { margin: 0; font-size: 0.85rem; }\n\n.pak-hero {\n  display: grid;\n  justify-items: center;\n  gap: var(--space-3);\n  text-align: center;\n  padding: var(--space-5) var(--space-4);\n  background:\n    radial-gradient(ellipse at 50% 0%, color-mix(in srgb, var(--pak-stripe-cyan) 18%, transparent), transparent 55%);\n  border: none;\n  border-radius: var(--radius);\n}\n.pak-hero-logo {\n  width: min(220px, 55vw);\n  height: auto;\n  display: block;\n  background: transparent;\n}\n.pak-swatches-stripes { margin-top: var(--space-2); }\n",
	"public/sw.js": "/**\n * Package-app service worker shipped via kody.app.assets (`public/sw.js`).\n * Precache the fingerprinted client from GET <assetBase>/__version.json — no hashes in source.\n * Brand revision: kody-racer list/PWA mark (v4) — image/* cache guard + handoff-aware precache.\n */\n'use strict'\n\nvar HTML_PATHS = ['/', '/about', '/notes']\n\nfunction appBase() {\n\ttry {\n\t\treturn new URL(self.registration.scope).pathname.replace(/\\/$/, '')\n\t} catch (e) {\n\t\treturn self.location.pathname\n\t\t\t.replace(/\\/_assets\\/sw\\.js$/, '')\n\t\t\t.replace(/\\/sw\\.js$/, '')\n\t}\n}\n\nfunction assetBase(base) {\n\treturn base + '/_assets'\n}\n\nfunction sameOrigin(url) {\n\treturn url.origin === self.location.origin\n}\n\nfunction isApi(url, base) {\n\treturn url.pathname.indexOf(base + '/api/') === 0\n}\n\nfunction isManifest(url) {\n\treturn /manifest\\.webmanifest$/.test(url.pathname)\n}\n\nfunction isStatic(url) {\n\treturn !isManifest(url) && /\\.(png|svg|ico|webp|jpg|jpeg)$/.test(url.pathname)\n}\n\nfunction isClientAsset(url) {\n\treturn /\\/_assets\\/client\\.[^/]+\\.js$/.test(url.pathname)\n}\n\nfunction isImagePath(url) {\n\treturn /\\.(png|svg|ico|webp|jpg|jpeg)$/.test(url.pathname)\n}\n\nfunction contentType(response) {\n\treturn String(response && response.headers && response.headers.get('content-type') || '')\n\t\t.toLowerCase()\n\t\t.split(';')[0]\n\t\t.trim()\n}\n\nfunction isImageContentType(response) {\n\treturn contentType(response).indexOf('image/') === 0\n}\n\nfunction isHandoffRequired(response) {\n\tif (!response) return false\n\tif (response.status === 403) return true\n\ttry {\n\t\treturn String(response.headers.get('x-kody-handoff') || '').toLowerCase() === 'required'\n\t} catch (e) {\n\t\treturn false\n\t}\n}\n\nfunction relativePath(url, base) {\n\tvar path = url.pathname\n\tif (path === base || path === base + '/') return '/'\n\tif (path.indexOf(base + '/') === 0) {\n\t\tvar rel = path.slice(base.length)\n\t\tif (rel.length > 1 && rel.charAt(rel.length - 1) === '/') rel = rel.slice(0, -1)\n\t\treturn rel || '/'\n\t}\n\treturn path\n}\n\nfunction isHtmlPage(url, base) {\n\treturn HTML_PATHS.indexOf(relativePath(url, base)) !== -1\n}\n\nfunction cacheableResponse(response) {\n\treturn Boolean(response && response.ok && response.type !== 'opaque' && response.type !== 'error')\n}\n\n/** Never cache 403 handoff HTML (or non-image bodies) as static image assets. */\nfunction cacheableStaticResponse(url, response) {\n\tif (!cacheableResponse(response) || isHandoffRequired(response)) return false\n\tif (isImagePath(url)) return isImageContentType(response)\n\treturn true\n}\n\nfunction offlineHtml() {\n\treturn new Response(\n\t\t'<!doctype html><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>Offline</title><body style=\"font-family:system-ui;padding:24px\"><h1>Offline</h1><p>Re-open this app from Kody when you are back online.</p></body>',\n\t\t{ status: 503, headers: { 'content-type': 'text/html; charset=utf-8' } },\n\t)\n}\n\nfunction matchShell(cache, request) {\n\treturn cache.match(request).then(function (cached) {\n\t\tif (cached) return cached\n\t\tvar url = new URL(request.url)\n\t\tvar alt = url.pathname.endsWith('/')\n\t\t\t? url.origin + url.pathname.replace(/\\/$/, '') + url.search\n\t\t\t: url.origin + url.pathname + '/' + url.search\n\t\treturn cache.match(alt)\n\t})\n}\n\nfunction activeCacheName(base) {\n\treturn fetch(assetBase(base) + '/__version.json', {\n\t\tcache: 'no-store',\n\t\tcredentials: 'same-origin',\n\t})\n\t\t.then(function (res) {\n\t\t\tif (!res.ok) throw new Error('version ' + res.status)\n\t\t\treturn res.json()\n\t\t})\n\t\t.then(function (version) {\n\t\t\treturn {\n\t\t\t\tname: version.publishedCommit ? 'pak-' + version.publishedCommit : 'pak-fallback',\n\t\t\t\tversion: version,\n\t\t\t}\n\t\t})\n\t\t.catch(function () {\n\t\t\treturn { name: 'pak-fallback', version: null }\n\t\t})\n}\n\nfunction openActiveCache(base) {\n\treturn activeCacheName(base).then(function (info) {\n\t\treturn caches.open(info.name).then(function (cache) {\n\t\t\treturn { cache: cache, info: info }\n\t\t})\n\t})\n}\n\n/** Precache brand/icons only when the network returns a real image/* body. */\nfunction precacheImage(cache, url) {\n\treturn fetch(url, { credentials: 'same-origin', cache: 'no-store' }).then(function (response) {\n\t\tif (!cacheableStaticResponse(new URL(url, self.location.origin), response)) {\n\t\t\tconsole.warn('[pak-sw] skip precache (not image/* or handoff):', url, response && response.status)\n\t\t\treturn false\n\t\t}\n\t\treturn cache.put(url, response).then(function () {\n\t\t\treturn true\n\t\t})\n\t})\n}\n\nfunction precacheUrl(cache, url) {\n\tif (isImagePath(new URL(url, self.location.origin))) {\n\t\treturn precacheImage(cache, url)\n\t}\n\treturn cache.add(url).then(function () {\n\t\treturn true\n\t}).catch(function () {\n\t\treturn false\n\t})\n}\n\nself.addEventListener('install', function (event) {\n\tevent.waitUntil(\n\t\topenActiveCache(appBase()).then(function (opened) {\n\t\t\tvar base = appBase()\n\t\t\tvar version = opened.info.version\n\t\t\tvar urls = [\n\t\t\t\tbase + '/',\n\t\t\t\tbase + '/about',\n\t\t\t\tbase + '/notes',\n\t\t\t\tbase + '/manifest.webmanifest',\n\t\t\t\tbase + '/icons/icon-192.png?v=4',\n\t\t\t\tbase + '/icons/icon-512.png?v=4',\n\t\t\t\tassetBase(base) + '/sw.js',\n\t\t\t\tassetBase(base) + '/styles.css',\n\t\t\t]\n\t\t\tif (version && version.clientModuleUrl) urls.push(version.clientModuleUrl)\n\t\t\treturn Promise.all(\n\t\t\t\turls.map(function (url) {\n\t\t\t\t\treturn precacheUrl(opened.cache, url)\n\t\t\t\t}),\n\t\t\t).then(function () {\n\t\t\t\treturn self.skipWaiting()\n\t\t\t})\n\t\t}),\n\t)\n})\n\nself.addEventListener('message', function (event) {\n\tif (event.data === 'SKIP_WAITING') self.skipWaiting()\n})\n\nself.addEventListener('activate', function (event) {\n\tevent.waitUntil(\n\t\tactiveCacheName(appBase())\n\t\t\t.then(function (info) {\n\t\t\t\treturn caches.keys().then(function (keys) {\n\t\t\t\t\treturn Promise.all(\n\t\t\t\t\t\tkeys.map(function (key) {\n\t\t\t\t\t\t\tif (key === info.name) return null\n\t\t\t\t\t\t\tif (key.indexOf('pak-') === 0) return caches.delete(key)\n\t\t\t\t\t\t\tif (key.indexOf('package-app-kit-demo-') === 0) return caches.delete(key)\n\t\t\t\t\t\t\treturn null\n\t\t\t\t\t\t}),\n\t\t\t\t\t)\n\t\t\t\t})\n\t\t\t})\n\t\t\t.then(function () {\n\t\t\t\treturn self.clients.claim()\n\t\t\t}),\n\t)\n})\n\nself.addEventListener('fetch', function (event) {\n\tvar request = event.request\n\tif (request.method !== 'GET') return\n\tvar url = new URL(request.url)\n\tvar base = appBase()\n\tif (!sameOrigin(url) || isApi(url, base)) return\n\n\tif (isManifest(url)) {\n\t\tevent.respondWith(\n\t\t\tfetch(request, { credentials: 'same-origin', cache: 'no-store' })\n\t\t\t\t.then(function (response) {\n\t\t\t\t\tif (cacheableResponse(response) && !isHandoffRequired(response)) {\n\t\t\t\t\t\tvar copy = response.clone()\n\t\t\t\t\t\topenActiveCache(base).then(function (opened) {\n\t\t\t\t\t\t\topened.cache.put(request, copy)\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t\treturn response\n\t\t\t\t})\n\t\t\t\t.catch(function () {\n\t\t\t\t\treturn caches.match(request).then(function (cached) {\n\t\t\t\t\t\treturn cached && cached.ok ? cached : Response.error()\n\t\t\t\t\t})\n\t\t\t\t}),\n\t\t)\n\t\treturn\n\t}\n\n\tif (request.mode === 'navigate' || isHtmlPage(url, base)) {\n\t\tevent.respondWith(\n\t\t\topenActiveCache(base).then(function (opened) {\n\t\t\t\treturn matchShell(opened.cache, request).then(function (cached) {\n\t\t\t\t\tvar network = fetch(request, { credentials: 'same-origin' })\n\t\t\t\t\t\t.then(function (response) {\n\t\t\t\t\t\t\tif (cacheableResponse(response) && !isHandoffRequired(response)) {\n\t\t\t\t\t\t\t\topened.cache.put(request, response.clone())\n\t\t\t\t\t\t\t\tvar sibling = url.pathname.endsWith('/')\n\t\t\t\t\t\t\t\t\t? url.origin + url.pathname.replace(/\\/$/, '') + url.search\n\t\t\t\t\t\t\t\t\t: url.origin + url.pathname + '/' + url.search\n\t\t\t\t\t\t\t\topened.cache.put(sibling, response.clone())\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\treturn response\n\t\t\t\t\t\t})\n\t\t\t\t\t\t.catch(function () {\n\t\t\t\t\t\t\treturn cached || offlineHtml()\n\t\t\t\t\t\t})\n\t\t\t\t\treturn cached || network\n\t\t\t\t})\n\t\t\t}),\n\t\t)\n\t\treturn\n\t}\n\n\tif (isStatic(url) || isClientAsset(url)) {\n\t\tevent.respondWith(\n\t\t\tfetch(request, { credentials: 'same-origin' })\n\t\t\t\t.then(function (response) {\n\t\t\t\t\tif (cacheableStaticResponse(url, response)) {\n\t\t\t\t\t\tvar copy = response.clone()\n\t\t\t\t\t\topenActiveCache(base).then(function (opened) {\n\t\t\t\t\t\t\topened.cache.put(request, copy)\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t\treturn response\n\t\t\t\t})\n\t\t\t\t.catch(function () {\n\t\t\t\t\treturn caches.match(request).then(function (cached) {\n\t\t\t\t\t\tif (!cached || !cached.ok) return Response.error()\n\t\t\t\t\t\tif (isImagePath(url) && !isImageContentType(cached)) return Response.error()\n\t\t\t\t\t\treturn cached\n\t\t\t\t\t})\n\t\t\t\t}),\n\t\t)\n\t\treturn\n\t}\n\n\tevent.respondWith(\n\t\tfetch(request, { credentials: 'same-origin' }).catch(function () {\n\t\t\treturn caches.match(request).then(function (cached) {\n\t\t\t\treturn cached || Response.error()\n\t\t\t})\n\t\t}),\n\t)\n})\n",
	"src/index.ts": "/**\n * Package overview for the scaffolded Remix package app.\n *\n * @example\n * import overview from 'kody:__PACKAGE_NAME__'\n * await overview()\n */\nexport default async function overview() {\n\treturn {\n\t\tname: '__PACKAGE_NAME__',\n\t\tscaffold: '@kentcdodds/package-app-kit/starter-remix',\n\t}\n}\n",
	"src/record-version.ts": "import { getAppVersion, setAppVersion, type AppVersion } from './version.ts'\n\n/**\n * Persist publish metadata in this app's packageStorage for About + update checks.\n * Do not re-export the kit's ./record-version — that writes the kit's storage and\n * fails packageStorage provenance when called as kody:@scope/app/record-version.\n *\n * @param input.sha - Full git commit SHA (required)\n * @param input.message - First-line commit message\n * @param input.committedAt - Commit author/committer ISO time\n * @param input.publishedAt - Publish time (defaults to now)\n * @returns Normalized AppVersion stored in packageStorage\n *\n * @example\n * import recordVersion from 'kody:__PACKAGE_NAME__/record-version'\n * await recordVersion({ sha: 'abc123', message: 'Ship' })\n */\nexport default async function recordVersion(input: {\n\tsha: string\n\tmessage?: string\n\tcommittedAt?: string\n\tpublishedAt?: string\n}): Promise<AppVersion> {\n\tif (!input?.sha) throw new Error('sha is required')\n\treturn await setAppVersion(input)\n}\n\n/**\n * Read the recorded version without writing.\n * Prefer this (or a relative import) for About / update-check server routes.\n *\n * @example\n * import { readRecordedVersion } from '../../src/record-version.ts'\n * const version = await readRecordedVersion()\n */\nexport async function readRecordedVersion(): Promise<AppVersion> {\n\treturn await getAppVersion()\n}\n\nexport type { AppVersion }\n",
	"src/version.ts": "import { packageStorage } from 'kody:runtime'\n\n/** Published app version metadata stored in packageStorage for About + update checks. */\nexport type AppVersion = {\n\tsha: string\n\tshortSha: string\n\tmessage: string\n\tcommittedAt: string\n\tpublishedAt: string\n}\n\nexport const APP_VERSION_STORAGE_KEY = 'app_version'\n\nexport function emptyVersion(): AppVersion {\n\treturn {\n\t\tsha: '',\n\t\tshortSha: '',\n\t\tmessage: '',\n\t\tcommittedAt: '',\n\t\tpublishedAt: '',\n\t}\n}\n\nexport function normalizeVersion(\n\tinput: Partial<AppVersion> & { sha?: string },\n): AppVersion {\n\tconst sha = String(input.sha ?? '').trim()\n\treturn {\n\t\tsha,\n\t\tshortSha: sha.slice(0, 7),\n\t\tmessage: String(input.message ?? '').split('\\n')[0] ?? '',\n\t\tcommittedAt: String(input.committedAt ?? ''),\n\t\tpublishedAt: String(input.publishedAt ?? ''),\n\t}\n}\n\nexport async function getAppVersion(): Promise<AppVersion> {\n\ttry {\n\t\tconst raw = await packageStorage().get(APP_VERSION_STORAGE_KEY)\n\t\tif (raw == null) return emptyVersion()\n\t\tif (typeof raw === 'string') {\n\t\t\treturn normalizeVersion(JSON.parse(raw) as Partial<AppVersion>)\n\t\t}\n\t\tif (typeof raw === 'object') {\n\t\t\treturn normalizeVersion(raw as Partial<AppVersion>)\n\t\t}\n\t\treturn emptyVersion()\n\t} catch {\n\t\treturn emptyVersion()\n\t}\n}\n\nexport async function setAppVersion(\n\tinput: Partial<AppVersion> & { sha: string },\n): Promise<AppVersion> {\n\tconst next = normalizeVersion({\n\t\t...(await getAppVersion()),\n\t\t...input,\n\t\tpublishedAt: input.publishedAt || new Date().toISOString(),\n\t})\n\tawait packageStorage().set(APP_VERSION_STORAGE_KEY, next)\n\treturn next\n}\n",
	"tsconfig.json": "{\n\t\"compilerOptions\": {\n\t\t\"jsx\": \"react-jsx\",\n\t\t\"jsxImportSource\": \"remix/ui\",\n\t\t\"allowImportingTsExtensions\": true,\n\t\t\"strict\": true,\n\t\t\"noEmit\": true,\n\t\t\"module\": \"esnext\",\n\t\t\"moduleResolution\": \"bundler\",\n\t\t\"target\": \"es2022\"\n\t}\n}\n",
}

export const STARTER_TEMPLATE_RENAMES: Record<string, string> = {
	"package.template.json": "package.json",
	"README.template.md": "README.md",
	"AGENTS.template.md": "AGENTS.md",
}