@kentcdodds/package-app-kit
Design tokens, PWA install/update, About/version, cache helpers, and optional realtime notes sync for Kody package apps.
AppsTopics: app, pwa, design, cache, kit
- Version
- 0.1.0
- License
- Published
- October 16, 2026
- Pinned commit
c2d3a61- Rating
- ★ 5.0 (1)
- Forks
- 1
- Adaptation effort
- 2.0
README
Agent docs@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-1…8+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 (beforeinstallprompt→event.prompt()), with iOS Add-to-Home-Screen only when the UA is actually iOS (fixed overlay). Tooltips for non-obvious / icon-only controls. /aboutpage 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/componentislands) showing tokens, Notes, install, About, and update check - TanStack Router starter (
starter-tanstack//create-package-app-tanstack) — React +@tanstack/react-routerwith Workers{ fetch }remount (no Vite / TanStack Start) - Update check on visit → refresh affordance
- Double-check helpers for destructive ops (two-step + type-to-confirm; Notes uses
NotesDemo+DoubleCheckButtonwith PE + optimistic UI) packageStorageschema migrations (./storage-migrations) — stable kit subpath re-exporting@kentcdodds/package-storage-migrations(version key, orderedupsteps, isolate-memoized runner; caller always passes storage)- Optional realtime notes sync (
./realtime+./realtime-client) — WorkerbroadcastNotesChanged/handleRealtimeEvent; browserconnectPackageRealtime(see AGENTS L3) - Lucide SVG icons (ISC) — checked-in under
app/icons/viascripts/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)
Starters live in this repo — the kit supports both Remix and TanStack:
starter-remix/— default scaffolder (create-package-app)starter-tanstack/— TanStack Router + React scaffolder (create-package-app-tanstack; no TanStack Start / Vite)
There is no separate @kentcdodds/package-app-starter package. This package’s own live demo (kody.app → app/router.ts) is Remix export-shape for the kit showcase and is not what either 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 kit supports both Remix and TanStack starters, each with its own Kody brand:
| Starter | Full-size koala | Default favicon / list / PWA mark |
|---|---|---|
Remix (starter-remix/, live kit demo) | Race-driver | Race-driver bust (not the Remix R + rainbow stripes glyph) |
TanStack (starter-tanstack/) | Hawaiian-shirt koala | Square TanStack koala mark (tight head/shoulders crop) |
Remix — race-driver Kody (live kit app/ demo keeps these):

TanStack — Hawaiian-shirt Kody:

Square TanStack mark (favicon / .kody/icon / PWA defaults for create-package-app-tanstack):

Design tokens default to Remix-dark (#0a0a0a / #121212) with cyan primary and stripe secondaries. The Remix scaffolder still ships a placeholder .kody/icon until product-branded; the TanStack scaffolder defaults to the square TanStack koala mark for favicon, .kody/icon, and PWA icons — replace only when product-branding.
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. Brand PNGs for README / Artifacts live under public/brand/ (logo-*.png race-driver; tanstack-logo-*.png + tanstack-mark.png TanStack).
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/component", both per-file pragmas (/** @jsxRuntime automatic */ + /** @jsxImportSource remix/component */) 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/ (default) or starter-tanstack/ 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/component 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'
import { createPackageAppAuth } from 'kody:@kentcdodds/package-app-kit/auth'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 (default) and starter-tanstack/STARTER.md. After create-package-app or create-package-app-tanstack, 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, recipe reminders, and manifest colors matching the mark. TanStack scaffolds already include the square TanStack koala as the default mark — replace only for product branding; Remix scaffolds still start from a placeholder.
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',
})
}TanStack Router + React (second starter; Remix remains default):
import createPackageAppTanstack from 'kody:@kentcdodds/package-app-kit/create-package-app-tanstack'
export default async function main() {
return await createPackageAppTanstack({
packageName: '@kentcdodds/my-notes-tanstack',
title: 'My Notes',
dryRun: true, // prefer dry-run first
})
}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.
Auth
Sign-in for hosted package apps with no OAuth wiring. The platform owns these
reserved paths at the host root of every package-app host (*.kody.run and
verified custom domains) — not under /packages/{leaf}:
| Path | Behavior |
|---|---|
GET /_kody/auth/login?app={leaf}&return_to=…&scope=email | Starts PKCE + state, redirects to kody.codes authorize (scope optional; on app-origin / preview also pass org) |
GET /_kody/auth/callback | Platform exchanges the code, sets __Host-kody_pkg_session, redirects to return_to |
GET / POST /_kody/auth/logout?app={leaf}&return_to=… | Clears only that app's session |
GET /_kody/auth/me?app={leaf} | { userId, username, displayName, avatarUrl?, email?, role } or 401 (same org rule as login) |
createPackageAppAuth only builds those URLs and reads /me; it never calls
kody.codes OAuth endpoints and has no kody: imports. Pass
hostedUrl: packageContext?.hostedUrl so loginUrl / meUrl / redirectToLogin
attach app={leaf} (and org on path-based /@owner/packages/{leaf} mounts).
You can also set app / org / username explicitly.
import { packageContext } from 'kody:runtime'
import { createPackageAppAuth } from 'kody:@kentcdodds/package-app-kit/auth'
const auth = createPackageAppAuth({
mode: 'optional', // default; 'required' redirects anonymous users to login
scopes: ['email'], // optional; email needs user consent
hostedUrl: packageContext?.hostedUrl, // supplies origin + app/org identity
})
// loader / handler
export async function loader({ request }: { request: Request }) {
const user = await auth.resolveUser(request) // 'required' throws a 302 Response
return { user }
}
// or explicitly
const user = await auth.getUser(request) // forwards Cookie to /_kody/auth/me; null if anonymous
if (!user) throw auth.redirectToLogin(request) // returns to the current path + search
const me = await auth.requireUser(request) // same, but returns the user
// links (absolute when origin/hostedUrl/request is known, else host-root paths)
auth.loginUrl({ returnTo: '/notes' }) // …/login?app=leaf&return_to=%2Fnotes&scope=email
auth.logoutUrl({ returnTo: '/' }) // GET link or <form method="post" action=…>
auth.meUrl() // …/me?app=leafNotes: getUser skips the network when the request has no Cookie header and
throws PackageAppAuthError on non-401 failures from /me. Pass request
(or origin / hostedUrl) to loginUrl / logoutUrl for absolute URLs;
bare host-root paths also work in <a href> because they resolve against the
current host. A starter pattern lives in app/middleware/auth.ts; smoke with
node scripts/smoke-auth.mjs.
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.
Report this listing
Log in to report this listing.