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.

README.md

202 lines · 11.9 KB · Markdown

@kentcdodds/package-app-kit

Runtime design/PWA kit for Kody package apps.

Intent

Give Kent (and future @kody consumers) one lean private kit so every package app can share:

  • CSS custom-property design tokens (including spacing scale --pak-space-18 + pak-stack / pak-cluster / pak-inset) and useful vanilla UI (including a sonner-inspired toaster)
  • Press feedback / optimistic UI: immediate pending on tap; busy labels use spin-delay timing (no flash)
  • ErrorBanner: shared alert for validation / About / SSR failures (app/ui/error-banner.tsx + ./error-display)
  • Zero CLS preference: update-available is a fixed dismissible toast (not an in-flow banner); ephemeral UI stays out of document flow
  • Header install icon (Lucide download, reserved slot, no CLS) that triggers the native install prompt (beforeinstallpromptevent.prompt()), with iOS Add-to-Home-Screen only when the UA is actually iOS (fixed overlay). Tooltips for non-obvious / icon-only controls.
  • /about page with commit sha/message, commit/publish dates + relative ages, package code link, and manual update check — via the record-version pattern (not dumped on home)
  • Remix recipe live demo under app/ ({ fetch } remount wrapper + remix/ui islands) showing tokens, Notes, install, About, and update check
  • Update check on visit → refresh affordance
  • Double-check helpers for destructive ops (two-step + type-to-confirm; Notes uses NotesDemo + DoubleCheckButton with PE + optimistic UI)
  • packageStorage schema migrations (./storage-migrations) — stable kit subpath re-exporting @kentcdodds/package-storage-migrations (version key, ordered up steps, isolate-memoized runner; caller always passes storage)
  • Optional realtime notes sync (./realtime + ./realtime-client) — Worker broadcastNotesChanged / handleRealtimeEvent; browser connectPackageRealtime (see AGENTS L3)
  • Lucide SVG icons (ISC) — checked-in under app/icons/ via scripts/add-lucide-icon.mjs (no full Lucide runtime dep)
  • Client cache helpers (TanStack Query core + IndexedDB) and server cache (@epic-web/cachified + packageStorage)
  • Service worker helpers with zombie protections (versioned caches, cleanup, careful skipWaiting / clients.claim, unregister stale SWs)
  • Desktop + mobile friendly layout (fluid width, large tap targets, no mobile-only copy)

Starter lives in this repo at starter-remix/ (scaffolder embeds that directory into a new Artifacts package and renames placeholders). There is no separate @kentcdodds/package-app-starter package. This package’s own live demo (kody.appapp/router.ts) is Remix export-shape for the kit showcase and is not what the scaffolder copies. Archived fetch template: starter-fetch/. Archived pre-Remix kit demo: archive/fetch-demo/.

Private @kentcdodds for now; promote to @kody later.

Kit demo brand

The live kit demo uses race-driver Kody for in-app logos (bust header, full-body hero) and for the list/favicon/PWA mark (the racer bust — not the Remix R + rainbow stripes glyph). Design tokens default to Remix-dark (#0a0a0a / #121212) with cyan primary and stripe secondaries. Scaffolded apps still ship a placeholder .kody/icon — generate a distinctive mark per the After scaffolding checklist.

Private hosted apps: open via Open Package App from kody.codes. Re-open from the package page when you need a fresh session or service worker, then take Refresh on the update toast when one appears.

In-app logos: serve header/hero/favicon marks from app routes that return image/png (same pattern as iconPngResponse / /icons/…), and point markup at routes.…href(). Use public/ + _assets for CSS, sw.js, and the client bundle.

Stack note

Live kit demo is a Remix recipe under the framework-agnostic host contract (every entry is a fetch handler on the mount-stripped path):

"kody": {
  "app": {
    "entry": "./app/router.ts",
    "client": "./app/assets/entry.ts",
    "assets": "./public"
  }
}

Recipe boilerplate the demo/starter own: root tsconfig.json with "jsx": "react-jsx" + "jsxImportSource": "remix/ui", both per-file pragmas (/** @jsxRuntime automatic */ + /** @jsxImportSource remix/ui */) at the top of every SSR .tsx that uses JSX, remount + export default { fetch } in app/router.ts, and clientEntry('kody:app#Name', …) islands with a browser registry. No kody.app.runtime. Demo controllers import kit helpers with relative ../src/... paths (this package must not depend on itself via kody:@kentcdodds/package-app-kit).

Scaffolded apps copy starter-remix/ (same recipe) and import the kit with kody:@kentcdodds/package-app-kit/....

Library consumers still get Worker HTML helpers + optional dual-path client (packageContext.clientModuleUrl, interim ./client boot). Prefer platform remix/ui for Remix apps — do not add @remix-run/ui import maps or the fat remix meta-package as a runtime dependency.

Install / import

import themeCss from 'kody:@kentcdodds/package-app-kit/styles'
import { toastCss, toastHostHtml } from 'kody:@kentcdodds/package-app-kit/ui'
import {
  createDoubleCheck,
  createTypeToConfirm,
} from 'kody:@kentcdodds/package-app-kit/double-check'
import { installChromeHtml } from 'kody:@kentcdodds/package-app-kit/install'
import { formatAboutPageData, aboutPageHtml } from 'kody:@kentcdodds/package-app-kit/about'
import { kitServedManifestIcons, iconPngResponse } from 'kody:@kentcdodds/package-app-kit/icons'
import { appShellHtml } from 'kody:@kentcdodds/package-app-kit/app-shell'
import { clientBootResponse, clientModuleResponse } from 'kody:@kentcdodds/package-app-kit/client'
import recordVersion from 'kody:@kentcdodds/package-app-kit/record-version'
import checkForUpdate from 'kody:@kentcdodds/package-app-kit/update-check'
import { createPersistedQueryClient } from 'kody:@kentcdodds/package-app-kit/client-cache'
import { createServerCachified } from 'kody:@kentcdodds/package-app-kit/server-cache'
import { renderServiceWorker, buildWebManifest } from 'kody:@kentcdodds/package-app-kit/sw'
import {
  runPackageStorageMigrations,
  createMigrationRunner,
} from 'kody:@kentcdodds/package-app-kit/storage-migrations'
import {
  createNotesRealtimeHandler,
  broadcastNotesChanged,
} from 'kody:@kentcdodds/package-app-kit/realtime'
import { connectPackageRealtime } from 'kody:@kentcdodds/package-app-kit/realtime-client'

Always build in-app URLs from packageContext.appBasePath + packageContext.hostedUrl.

See AGENTS.md for ADOPTION LAYERS (CSS-only → fetch-handler PWA → Remix → optional realtime L3), token/class contracts, install client requirements, and the scaffolder playbook. Fetch-handler apps can use ./app-shell (appShellHtml) instead of hand-rolling the document.

After publish

For this kit (demo About / update checks), call the kit export:

import recordVersion from 'kody:@kentcdodds/package-app-kit/record-version'

await recordVersion({
  sha: '<published_commit>',
  message: '<commit subject>',
  committedAt: '<optional ISO>',
})

Scaffolded consumer apps ship their own ./record-version that writes that app's packageStorage (see starter-remix/src/record-version.ts). After publishing a scaffolded app, call kody:@scope/app/record-version — not the kit's export.

Icons + installability

List/identity mark (packages, repos, community cards): put .kody/icon.png (also .svg / .webp / .jpg / .jpeg). New packages use .kody/icon — do not scaffold root community-icon.*. Root icon.* / community-icon.* remain permanent platform aliases. Keep PWA icons (icons/icon-192.png, public/) out of .kody/. This kit ships .kody/icon.png; the scaffolder emits .kody/icon.svg (text) because packageSave cannot write binaries.

PWA install (Chromium / Brave URL-bar): requires a manifest with name/short_name, start_url, display, and PNG icons at 192 and 512 that actually 200. This kit embeds those PNGs and serves them through ./icons (iconPngResponse / kitServedManifestIcons) so apps do not need to commit binary PWA icon files. Designed/generated icons are still welcome for polish; empty icons: [] will not be promoted.

buildWebManifest defaults to display: "standalone" and accepts fullscreen, orientation, categories, and extra manifest fields — useful when iOS Safari gestures would otherwise fight the app.

start_url / scope / id stay under /packages/<kody.id>/. Every HTML page must <link rel="manifest" crossorigin="use-credentials">. Manifest responses use cache-control: no-store. The SW precaches home, /about, /notes, icons, and the manifest, and fetches the manifest network-first without caching 403s.

Private hosted apps need Open Package App handoff from kody.codes. An installed PWA can still show a cached HTML shell while brand/icon network fetches return 403 after the session cookie expires — reopen from the package page, then Refresh on the update toast.

Starter + scaffolder

See starter-remix/STARTER.md. After create-package-app, agents follow the After scaffolding checklist in AGENTS.md (and the copy that lands in the new package's AGENTS.md): distinctive .kody/icon list mark, PWA icons/icon-192.png + icons/icon-512.png, titles/description, local record-version, Remix recipe reminders, and manifest colors matching the mark.

Mint a new private Remix package app in one execute call:

import createPackageApp from 'kody:@kentcdodds/package-app-kit/create-package-app'

export default async function main() {
  return await createPackageApp({
    packageName: '@kentcdodds/my-notes', // or leaf `my-notes`
    title: 'My Notes',
  })
}

Lucide icons (checked-in SVG)

Default icon set: Lucide (repo) — ISC license, consistent 24×24 stroke icons, plain SVG. Browse names at https://lucide.dev/icons.

Add icons into the app (and starter twin) without bundling the full Lucide package:

node scripts/add-lucide-icon.mjs download trash-2 plus
# writes app/icons/{name}.svg + {name}.tsx and mirrors into starter-remix/app/icons/

Use the generated Remix Handle component (<IconDownload size={20} />) next to a visible label, or alone with data-tip + aria-label when icon-only:

import { IconDownload } from '../icons/download.tsx'
import { IconTrash2 } from '../icons/trash-2.tsx'

<button class="pak-btn pak-btn-icon" type="button" data-tip="Install App" aria-label="Install App">
  <IconDownload size={20} />
</button>

<button class="pak-btn pak-btn-danger" type="submit">
  <span class="pak-cluster pak-gap-2"><IconTrash2 size={18} /> Delete</span>
</button>

Attribution note ships as app/icons/ATTRIBUTION.md.

packageStorage migrations

Evolve durable packageStorage schemas with a version key + ordered migrations. The kit subpath re-exports @kentcdodds/package-storage-migrations; keep importing kody:@kentcdodds/package-app-kit/storage-migrations and always pass the caller's storage. Run once per Worker isolate (memoized) on boot or the first request that needs the data:

import { packageStorage } from 'kody:runtime'
import {
  createMigrationRunner,
} from 'kody:@kentcdodds/package-app-kit/storage-migrations'

const ensureSchema = createMigrationRunner({
  storage: packageStorage(),
  versionKey: 'my-app:schema-version',
  migrations: [
    {
      version: 1,
      name: 'notes-to-document',
      async up(storage) {
        const legacy = await storage.get('notes-v1')
        if (Array.isArray(legacy)) {
          await storage.set('notes', { items: legacy })
          await storage.delete?.('notes-v1')
        }
      },
    },
  ],
})

// in a loader / action:
await ensureSchema()

The live demo Notes storage (app/data/notes.ts) shows this pattern end-to-end.