@kentcdodds/package-app-kit
Design tokens, PWA install/update, About/version, cache helpers, and optional realtime notes sync for Kody package apps.
AGENTS.md
737 lines · 39.7 KB · Markdown@kentcdodds/package-app-kit — agent playbook
Human Intent and product framing live in README.md. This file is the agent runbook.
Positive defaults
- Prefer static
kody:@kentcdodds/package-app-kit/...imports fromexecuteand from scaffolded / consumer package apps. - After
create-package-app/starter-remix/, follow After scaffolding before treating the app as product-ready. - Inside this kit’s live demo (
app/), import library helpers with relative../src/...paths (neverkody:@kentcdodds/package-app-kitfrom the same package). - Kit demo brand: race-driver Kody (bust header / full-body hero and list/favicon/PWA mark). Do not use the Remix R + rainbow stripes glyph as
.kody/icon, favicon, or PWA icons. Remix-dark tokens (#0a0a0a, cyan accent). Scaffolded apps keep a placeholder.kody/iconuntil branded. - Keep the tree lean (no huge binaries). Chromium / Brave URL-bar Install needs PNG 192+512 — serve via kit
iconPngResponseroutes (kitServedManifestIcons); do not require consumer binary PNGs for tool-only apps. - In-app logos (header, hero, favicon): serve them the same way as PWA icons — Worker/app routes that return
image/png, with<img src={routes.…href()}>/<link href={…}>.public/+assetBasePath(…/_assets/…) is the home for CSS, the service worker, and the fingerprinted client module. - List/identity mark: put
.kody/icon.png(also.svg/.webp/.jpg/.jpeg) for package/repo/community list cards. Do not scaffold rootcommunity-icon.*for new packages. Do not move PWA icons (icons/icon-192.png,public/) into.kody/.packageSaveis text-only — binary marks arrive via Artifacts git / publish (scaffolder embeds.kody/icon.svg). - Remix package apps use platform
remix/*(remix/ui,remix/router, …) as a recipe under the fetch-handler host contract. Preferkody.appentry/client/assets— nokody.app.runtime. - Never put secrets in chat or in package source.
- Private/hosted package apps: open via Open Package App from kody.codes so the hosted session is established. Re-open from the package page when you need a fresh session or service worker.
- Desktop + mobile: fluid layout, ≥48px tap targets, no mobile-only copy or CSS assumptions.
- Separate graphs: Worker/router must not import the client entry except shared island modules that stay free of
kody:and DOM-only APIs where appropriate.
After scaffolding
After create-package-app (or copying starter-remix/), work through this checklist before treating the app as product-ready:
- Brand the list mark — Generate a distinctive square mark for this app and place it at
.kody/icon.png(also.svg/.webp/.jpg/.jpeg). Prefer PNG on the Artifacts git lane. This is package / repo / community list identity. - Brand PWA + in-app chrome marks — Place matching product install icons at
icons/icon-192.pngandicons/icon-512.png(add maskable variants when the manifest uses them). Serve those PNGs from app routes (iconPngResponseor handlers that returnimage/png), and point favicon /apple-touch-icon/ header<img>atroutes.…href()(mount-safe). For a larger hero or alternate bust, add the same kind of route (for example/brand/logo-full.png) with embedded or git-lane PNG bytes — same pattern as PWA icons. Keep list identity in.kody/; keeppublic/for CSS,sw.js, and other asset-host files. - Fill product identity — Set
__APP_TITLE__/ title, description, andpackageName(and related placeholders) to the real product names. - Keep local version writers — Use this package's
src/version.ts+src/record-version.tsagainst this app'spackageStorage(the scaffolder already emits them). Call this package's./record-version, not the kit'skody:@kentcdodds/package-app-kit/record-version. - Record each publish — After every successful publish, call this package's
./record-versionwith the published SHA (and message /committedAtwhen available). - Remix recipe (#2312) — When the scaffold is Remix: confirm root
tsconfig.jsonhas"jsx": "react-jsx"+"jsxImportSource": "remix/ui"; put both file pragmas at the top of every SSR.tsxthat uses JSX (/** @jsxRuntime automatic */and/** @jsxImportSource remix/ui */);app/router.tsremounts the Request andexport default { fetch }; islands useclientEntry('kody:app#…', …)with matching browser registry keys. - Match manifest colors to the mark — Set
themeColor/backgroundColorinbuildWebManifest(and the documenttheme-colormeta) so they harmonize with the mark. - Optional UI icons — Add Lucide glyphs via
node scripts/add-lucide-icon.mjs <names>intoapp/icons/when the UI needs icon-only or labeled controls.
Then smoke with packageAppFetch on /, /notes, /about, /manifest.webmanifest, /icons/icon-192.png, /icons/icon-512.png, and /health.
Design token + UI class contract
designTokensCss / uiPrimitivesCss signatures alone do not list emitted names. Import the machine-readable contract:
import {
designTokensCss,
uiPrimitivesCss,
TOKENS,
tokenNames,
UI_PRIMITIVE_CLASSES,
uiPrimitiveClassNames,
} from 'kody:@kentcdodds/package-app-kit/styles'
tokenNames() // ['--pak-bg0', …, '--ink', '--space-4', …]
uiPrimitiveClassNames() // ['pak-wrap', 'pak-btn', …]Custom properties (default prefix --pak): leaf names in TOKENS.leafNames / TOKEN_LEAF_NAMES — bg0, bg1, surface, surface-2, ink, muted, line, accent, accent-2, accent-ink, good, warn, bad, radius, radius-sm, tap, gap, space-1…space-8, font, shadow-out, shadow-in. Friendly aliases --ink, --space-*, etc. are always emitted (see TOKENS.aliasLeaves). Dark scheme overrides colors + shadows.
Primitive classes: UI_PRIMITIVE_CLASSES — layout (pak-wrap, pak-stack*, pak-cluster*, pak-inset*, pak-row, pak-mt-*, pak-gap-*), chrome (pak-card, pak-btn*, pak-nav, pak-muted, pak-eyebrow, pak-pill), about/install (pak-about*, pak-install-*, install-wrap, install-hint), lists/skeletons (pak-list*, pak-skeleton, pak-pending-label), swatches (pak-swatch*). Toast classes are from ./ui (toastCss), not uiPrimitivesCss.
ADOPTION LAYERS
Pick the thinnest layer that fits. Each example is self-contained (does not mention other layers).
L0 — CSS only (any app)
Style tag only. No PWA, no Remix.
import { kody } from 'kody:runtime'
import {
designTokensCss,
uiPrimitivesCss,
tokenNames,
uiPrimitiveClassNames,
} from 'kody:@kentcdodds/package-app-kit/styles'
export default async function main() {
const css = `${designTokensCss()}\n${uiPrimitivesCss()}`
return {
tokenCount: tokenNames().length,
classes: uiPrimitiveClassNames().slice(0, 8),
cssBytes: css.length,
}
}In an HTML response: <style>${designTokensCss()}\n${uiPrimitivesCss()}</style> then use classes like pak-wrap / pak-card / pak-btn.
L1 — Fetch-handler PWA (no Remix)
Complete non-Remix app: shell + SW + manifest + kit icons + version/about helpers.
import { packageContext } from 'kody:runtime'
import { appShellHtml } from 'kody:@kentcdodds/package-app-kit/app-shell'
import { renderServiceWorker, buildWebManifest } from 'kody:@kentcdodds/package-app-kit/sw'
import {
iconPngResponse,
kitServedManifestIcons,
} from 'kody:@kentcdodds/package-app-kit/icons'
import { aboutPanelHtml, formatAboutPageData } from 'kody:@kentcdodds/package-app-kit/about'
// Consumer-local: import { readRecordedVersion } from './record-version.ts'
export default {
async fetch(request: Request) {
const url = new URL(request.url)
const path = url.pathname.replace(/\/+$/, '') || '/'
const appBasePath = packageContext?.appBasePath ?? ''
if (path === '/icons/icon-192.png') return iconPngResponse(192)
if (path === '/icons/icon-512.png') return iconPngResponse(512)
if (path === '/manifest.webmanifest') {
return new Response(
JSON.stringify(
buildWebManifest({
appBasePath,
name: 'My Fetch App',
shortName: 'Fetch',
display: 'standalone', // or 'fullscreen' for gesture-heavy UIs
icons: kitServedManifestIcons(appBasePath),
}),
),
{ headers: { 'content-type': 'application/manifest+json; charset=utf-8', 'cache-control': 'no-store' } },
)
}
if (path === '/sw.js') {
return new Response(renderServiceWorker({ cacheName: 'my-fetch-app-v1' }), {
headers: {
'content-type': 'application/javascript; charset=utf-8',
'service-worker-allowed': appBasePath || '/',
},
})
}
if (path === '/' || path === '') {
// optional: const version = await readRecordedVersion()
const about = formatAboutPageData({ version: { sha: '' }, runningSha: '' })
return new Response(
appShellHtml({
title: 'My Fetch App',
packageContext,
pakConfig: { appName: 'My Fetch App', routes: ['/'] },
body: `<main class="pak-wrap"><div class="pak-card">${aboutPanelHtml(about)}</div></main>`,
stylesheet: false,
head: `<style>/* or link assetBasePath/styles.css */</style>`,
}),
{ headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } },
)
}
return new Response('Not found', { status: 404 })
},
}
// Update-check gate (client or Worker helper):
// shouldRunUpdateCheck({ visible: true, inFlight: false, lastStartedAt: null, now: Date.now() })After publish, call the app’s own ./record-version (not the kit’s) with the published SHA.
L2 — Remix package app
Use create-package-app / starter-remix/ plus kit client-cache + router recipe:
import createPackageApp from 'kody:@kentcdodds/package-app-kit/create-package-app'
export default async function main() {
return await createPackageApp({
packageName: 'my-notes',
title: 'My Notes',
dryRun: true, // flip false to save + publish
})
}Remix apps also import ./client-cache for IDB query persistence and follow the live demo / starter-remix export-shape (kody.app.entry + client + assets). See Live kit demo and Create a new package app below.
L3 — Realtime (package facet)
Optional live sync over the package app WebSocket. packageStorage stays the source of truth; realtime notifies peers; optimistic UI stays on the client. Worked example: kit Notes demo (app/controllers/notes.tsx + NotesDemo + ./realtime / ./realtime-client).
Browser connects to {appBasePath}/ws (or {appBasePath}/ws/{facet}). Worker entry exports handleRealtimeEvent (or default.onRealtimeEvent). Use public context.get(KodyRuntime).realtime only — do not reach into DO/facet internals.
Worker — broadcast after a durable write + export the hook:
import { KodyRuntime } from 'kody:runtime'
import {
createNotesRealtimeHandler,
broadcastNotesChanged,
} from 'kody:@kentcdodds/package-app-kit/realtime'
export const handleRealtimeEvent = createNotesRealtimeHandler()
// After a successful notes write:
await broadcastNotesChanged(context.get(KodyRuntime).realtime)Browser island — connect and refresh on notes.changed:
import { connectPackageRealtime } from 'kody:@kentcdodds/package-app-kit/realtime-client'
const conn = connectPackageRealtime({
appBasePath: document.documentElement.getAttribute('data-app-base') || '',
onEvent(data) {
if (data && typeof data === 'object' && data.type === 'notes.changed') {
void reloadNotesList() // same GET JSON path as the Notes demo
}
},
onStatus(status) {
// optional quiet "Live" / reconnect affordance (no CLS)
},
})
// conn.close() when the island tears downHelpers: NOTES_REALTIME_TOPIC, notesChangedEvent, broadcastNotesChanged, createNotesRealtimeHandler / handleRealtimeEvent from ./realtime; realtimeWsUrl, connectPackageRealtime from ./realtime-client (browser-safe, no kody:).
iOS display / gestures
- iOS Safari ignores
user-scalable=noas a way to lock the chrome. - Installed PWA
display: "standalone"or"fullscreen"is the reliable way to drop Safari UI chrome. - Safari edge-swipe back and pull-to-refresh fight gesture-driven apps (card stacks, canvas). Prefer
buildWebManifest({ display: "fullscreen", … })when those gestures matter; default remainsstandalone. buildWebManifestalso acceptsorientation,categories,displayOverride, andextrasfor any other manifest members.
Live kit demo (Remix recipe)
Remix is a recipe with normal boilerplate under the framework-agnostic host contract (mount-stripped paths, no graph sniff). Declare in package.json:
"kody": {
"app": {
"entry": "./app/router.ts",
"client": "./app/assets/entry.ts",
"assets": "./public"
}
}- tsconfig root
tsconfig.json—"jsx": "react-jsx","jsxImportSource": "remix/ui"(supporting config for editors / typecheck). - Per-file JSX pragmas (required on every SSR
.tsxthat uses JSX) — put both lines at the top of the file so host esbuild always emits remix/ui automatic JSX:
Keep root/** @jsxRuntime automatic */ /** @jsxImportSource remix/ui */jsxImportSourceas supporting config; stamp both pragmas on controllers, UI, icons, and any new Remix SSR.tsxthat contains JSX. - Router
app/router.ts—createRouter({ middleware: [requestId(), formData()] }), remount the Request when routes are prefixed withpackageContext.appBasePath, thenexport default { fetch(request) { return router.fetch(remountRequest(request)) } }(do not default-export the router object — Workerenvis notRequestInit). - Realtime — named
handleRealtimeEventfromapp/router.ts(kitcreateNotesRealtimeHandler); Notes mutations callbroadcastNotesChangedviaKodyRuntime.realtime;NotesDemoconnects with./realtime-client. - Routes
app/routes.ts—route(packageContext?.appBasePath ?? '', { … }). - Client
app/assets/entry.ts—run({ loadModule })registry of named islands. - Islands —
clientEntry('kody:app#Name', function Name…)plus the same name in the browser registry (workerd leavesimport.meta.urlempty). - Assets
public/—styles.css,sw.js(reads__version.json; no content hash in source). - Branding: titles Package App Kit / short App Kit; notes key
package-app-kit-demo-notes-v1;/healthdemosserverCachifiedfromsrc/server-cache.ts.
Form actions use context.get(FormData) with the global FormData constructor as the context key — do not import FormData from remix/middleware/form-data.
Library client + assets (consumer / dual-path)
Consumer apps that still use a Worker HTML shell may declare:
"kody": {
"app": {
"entry": "./src/app.ts",
"client": { "entry": "./src/client/index.ts" },
"assets": "./public"
}
}- Platform bundles the client to
/_assets/client.<hash>.jsand setspackageContext.clientModuleUrl. - Static files under
public/are served atpackageContext.assetBasePath(…/_assets/…).public/sw.jsis the preferred SW. - When
clientModuleUrlis null on older hosts, fall back to Worker-served/client/boot.jsvia this kit’s./clientexport (clientBootResponse). PreferclientModuleUrlwhenever non-null. - SW: register
${assetBasePath}/sw.jswith{ scope: appBasePath }when assets are live.public/sw.jsdiscovers the module viaGET ${assetBasePath}/__version.json.
Remix apps should prefer the export-shape recipe above (starter-remix/) rather than the dual-path Worker shell.
Optimistic UI + press feedback (spin-delay inspired)
Default: every tap that kicks off work gets immediate affordance (data-pending / aria-busy, disabled) so users never wonder if the tap registered. Prefer optimistic UI when the outcome is likely; always give feedback when waiting on the network or a reload.
Avoid flashes of loading state (inspiration: spin-delay):
- On press → mark pending synchronously (opacity /
aria-busy). That is not optional. - Wait ~150–300ms before swapping the label to “Loading…” / showing a spinner.
- Once a busy label/spinner is shown, keep it for a minimum duration (~300–500ms) so it does not flicker off.
- Fast successes often never show the busy label — only the press state — which is correct.
Helpers live in the Remix starter as app/ui/busy.ts (createBusyGate). Update toast Refresh and About Check for updates use this pattern.
Errors (ErrorBanner)
Default: show failures clearly without surprising layout shift.
- Ephemeral / recoverable (toast after a failed action) → fixed toast host (zero CLS).
- Replacing expected content (validation page, empty About failure, SSR catch) → in-flow
ErrorBanner(app/ui/error-banner.tsx) withrole="alert". - Fetch / Worker helpers →
kody:@kentcdodds/package-app-kit/error-display(toErrorMessage,errorBannerHtml).
Playbook:
- Title + short message; optional hint (recovery link) and
detailviadata-tip. - Wire validation failures, About update-check errors, and
render()SSR catch toErrorBanner. - Prefer reserved slots or overlays; do not inject a tall error card above scrolled content mid-session.
Prefer zero content layout shift (CLS)
Default: keep the document flow stable. Ephemeral UI (update available, toasts, transient errors, install prompts that appear after hydration) belongs in fixed overlays or reserved slots — not cards injected above content that push the page down.
Playbook:
- Update available → dismissible fixed toast (
UpdateBanner/.pak-update-toast). Never an in-flow banner. - Toasts / status →
data-toast-host(fixed). Sticky for “refresh when ready”; auto-dismiss for short confirmations. - Skeletons → reserve height for async islands so hydration does not jump.
- Install → reserved header slot (always); iOS hint as fixed overlay; hide entirely in
display-mode: standalone. - Errors → ephemeral → toast; content replacement →
ErrorBannerin a reserved region. - Fonts / images → size attributes or aspect-ratio so media does not reflow.
When reviewing a PR or scaffolding a new package app, treat in-flow insertion of late-arriving chrome as a bug unless the space was reserved up front.
Tooltips (data-tip) — tip when the tip adds information
Default: tip controls where the tip carries information the visible chrome does not already state.
Playbook (good tip targets):
- Icon-only header install —
data-tip="Install {app}"+ matchingaria-label. - Logo — package id / brand string when the mark alone is ambiguous.
- Truncated values — short SHA with full SHA tip; local datetime with ISO tip.
- Color tokens — show a visible text label beside each swatch (Accent, Ink, …).
- Labeled nav and action buttons — rely on the visible words (Home, Notes, About, Add, Check for updates, Up to date). Keep tips for the cases above.
Export map
| Subpath | When to use |
|---|---|
. | Named re-exports (tokens, icons, client serve helpers) — no describe overview |
./error-display | toErrorMessage + errorBannerHtml for Worker/fetch apps |
./styles | Design tokens + UI primitive CSS; TOKENS / tokenNames() / UI_PRIMITIVE_CLASSES / uiPrimitiveClassNames() |
./ui | Vanilla toaster (sonner-inspired) CSS/HTML |
./double-check | Two-step confirm + type-to-confirm for destructive ops (Worker helpers) |
./install | Install CTA HTML + standalone / first-run helpers |
./about | Format About data + /about page HTML (optional collapsed panel) |
./icons | Kit-served 192+512 PNG (iconPngResponse, kitServedManifestIcons) + UTF-8 SVG helpers — no local PNGs required |
./app-shell | appShellHtml full document shell for fetch-handler PWAs |
./router | clientRouterModulePath() (legacy path hint; prefer bundled client) |
./client | Interim Worker serve of bundled boot (clientBootResponse) when clientModuleUrl is null |
./record-version | Write/read publish metadata in packageStorage |
./update-check | Compare running vs latest (Worker helpers) |
./client-cache | Browser TanStack query-core + IDB persistence (not Worker-safe) |
./server-cache | cachified + packageStorage for Worker/exports |
./sw | SW body, buildWebManifest (display / orientation / categories / extras) |
./create-package-app | One-call scaffolder: copy starter-remix/ → new private package + publish |
./storage-migrations | Re-exports @kentcdodds/package-storage-migrations (stable kit subpath; caller passes storage) |
./realtime / ./realtime-client | Optional package realtime: Worker broadcast + handleRealtimeEvent; browser connectPackageRealtime |
Installability checklist (Chromium / Brave URL-bar Install)
MDN / Chromium will not promote install (no beforeinstallprompt, no URL-bar Install) unless all of these are true:
- HTTPS (Kody hosted apps already are).
- Web app manifest linked from every HTML page (
<link rel="manifest" href="{appBase}/manifest.webmanifest" crossorigin="use-credentials">). - Manifest has
name/short_name,start_url,display(standaloneorminimal-ui/fullscreen). - Manifest
iconsinclude PNG 192×192 and 512×512 that return 200image/png. - Service worker registered and controlling the page. Precache home,
/about,/notes(when present), icons, and the manifest.
start_url / scope / id must stay under the package mount (/packages/<kody.id>/, from packageContext.appBasePath).
Serve real PNG 192+512 (kit iconPngResponse + kitServedManifestIcons, or commit icons/*.png on the git lane). Empty icons: [] is not installable from the URL bar.
In-app Install CTA still captures beforeinstallprompt and calls event.prompt(). Browser URL-bar Install is a separate Chromium UI that appears once the criteria above are met.
Verify with packageAppFetch:
import { kody } from 'kody:runtime'
export default async function main() {
const paths = ['/', '/notes', '/about', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/icon-512.png', '/health', '/brand/logo-bust.png', '/brand/logo-full.png', '/_assets/styles.css', '/_assets/sw.js']
const results = []
for (const path of paths) {
const res = await kody.packageAppFetch({ kody_id: 'package-app-kit', path })
results.push({ path, status: res.status, type: res.headers?.['content-type'] || res.headers?.['Content-Type'] })
}
return results
}Icon URLs must be 200 and image/png.
Install client requirements (required)
Wire install UX in the shell (Remix InstallCta island or installChromeHtml + client boot). Behavior must be:
- Capture
beforeinstallprompt. Callevent.preventDefault()only when a custom header Install CTA is in the DOM andevent.promptis callable, then store that event. If we cannot show a custom CTA, do not preventDefault (let Chrome's native banner). Never leave a prevented event that is neverprompt()ed. - Header icon slot to the right of the site title: always reserve the same-size slot; reveal the icon (visibility /
data-available) only when BIP fires (Chromium) or iOS A2HS guidance applies. No large in-flow Install button (CLS). - On Install click call
storedEvent.prompt()on the captured event (native install UI) in the same user-gesture turn. Icon-only control usesdata-tip+aria-label(good tip use). - iOS / Safari only: A2HS instructions when the UA is iOS (fixed overlay from the header icon tap only — never auto-open on load). Dismiss writes
hintDismissKeyto localStorage and must hide the overlay; CSS for.pak-install-hint-overlaymust honor[hidden](display: none !important). Never render “On iPhone…” for desktop or non-iOS UAs. - Standalone / installed: hide Install CTA and iOS hint entirely.
- Desktop-friendly: when BIP never fires, keep the reserved slot empty (ready for BIP) rather than phone-only instructions.
- Icons: use checked-in Lucide SVG components (
app/icons/download.tsx) for the install glyph.
Home is a clean starter surface: tokens, one Notes CTA, optional labeled pattern islands — About stays on /about.
Routing (live demo)
- Home
/— brand header (logo + title + install slot), labeled color tokens, one Notes CTA with count pill, clearly labeled Remix island pattern example, nav under header. - Notes
/notes— Remix form action +NotesDemoisland: PE POST/redirect without JS; with JS optimistic add (input reset) + isolated per-row optimistic deletes (DoubleCheckButton). - About
/about— sha, message, dates + relative ages, package code link, update check. - Every HTML page includes
<link rel="manifest" … crossorigin="use-credentials">.
Spacing
--pak-space-1…8 (4/8/12/16/24/32/48/64) on :root, aliases --space-*. Helpers: pak-stack, pak-cluster, pak-inset, pak-mt-*, pak-gap-*.
Playbook for narrow phones (~360px):
- Page shell:
.pak-wrapwith comfortable padding (--space-5). - Section rhythm:
.pak-stackgap--space-5between cards. - Card inset:
.pak-cardpadding--space-5. - Header: logo + title + reserved install slot on one row; nav on the next.
List / identity mark (.kody/icon)
Taught path for new work: .kody/icon.png (also .svg, .webp, .jpg, .jpeg). Prefer a square mark that stays legible at ~56px. For a product/service package, use that product's official logo.
- Platform resolves (first existing wins; legacy aliases are permanent):
.kody/icon.*→ rooticon.*→ rootcommunity-icon.*→ package-appicons/icon-192.png→ monogram/swirl. - New scaffolds emit
.kody/icon.svgviacreate-package-app/packageSave(text-only). Prefer replacing with.kody/icon.pngon the git lane when you have a binary mark. - Do not scaffold root
community-icon.*for new packages. Mentioncommunity-icononly as a legacy alias still resolved by the platform. - Do not move app PWA icons (
icons/icon-192.png,public/) into.kody/— those stay product install assets.
Icons (tool-only friendly)
Kit repo source of truth: icons/icon-192.png + icons/icon-512.png, mirrored as bytes in src/icon-png-bytes.ts.
Consumer apps (especially tool-only / packageSave): do not commit binary PNGs. packageSave is UTF-8 text only. Serve kit bytes from routes:
import {
iconPngResponse,
kitServedManifestIcons,
} from 'kody:@kentcdodds/package-app-kit/icons'
import { buildWebManifest } from 'kody:@kentcdodds/package-app-kit/sw'
// GET /icons/icon-192.png → iconPngResponse(192)
// GET /icons/icon-512.png → iconPngResponse(512)
buildWebManifest({
appBasePath,
name: 'My App',
icons: kitServedManifestIcons(appBasePath), // same paths as defaultManifestIcons
})Optional UTF-8 SVG mark: kitIconSvg() / iconSvgResponse() / svgManifestIcon(assetBase + '/icon.svg') for favicon — Chromium URL-bar Install still needs the PNG 192+512 routes above. (@kentcdodds/swipe shipped SVG-only; pair SVG + kit PNG routes for installability.)
Manifest / icons / sw.js access
packageAppFetch is authenticated. Chromium/Brave default <link rel=manifest> fetch is CORS without cookies, so a session-gated MIME type 403s in the browser even when packageAppFetch returns 200.
- Keep
crossorigin="use-credentials"on the manifest link so a signed-in session is sent. - Serve
/manifest.webmanifest,/icons/icon-192.png,/icons/icon-512.png, and/sw.jswith the same public-access posture as HTML. - Manifest
cache-controlisno-storeso a 403 cannot stick for 24h. - SW treats the manifest as network-first (credentialed
same-origin), caches onlyok+ non-opaque responses, and never caches 403/error/opaque. BumpcacheName/ rely on__version.jsonwhen rules change.
Double-check (destructive)
import {
createDoubleCheck,
createTypeToConfirm,
} from 'kody:@kentcdodds/package-app-kit/double-check'createDoubleCheck()— Epic Web–style two-step: first click arms (preventDefault), second click runs the action; blur/Escape resets.createTypeToConfirm({ expected, onConfirm })— Kodyconfirm_namestyle;confirm()only runs when typed value matches.- Live demo Notes uses
app/ui/notes-demo.tsx+app/ui/double-check-button.tsx(clientEntryislands) for PE + optimistic add/delete.
Remix UI (platform recipe)
- Import platform
remix/ui,remix/ui/server,remix/router,remix/routes, … only. - Optional
devDependencies.remix@3.0.0-rc.2for editor types — not a Worker runtime dependency. - Set root
tsconfig.json"jsx": "react-jsx"+"jsxImportSource": "remix/ui", and put both per-file pragmas (@jsxRuntime automatic+@jsxImportSource remix/ui) at the top of every SSR.tsxthat uses JSX so host esbuild emits remix/ui automatic JSX. - Remount in the entry when the route contract includes
appBasePath; default-export{ fetch }. - Islands:
clientEntry('kody:app#Name', function Name…)+ browser registry — prefer this over@remix-run/ui+ esm.sh import maps.
Starter vs demo (important)
starter-remix/— Remix package-app template for the scaffolder (platformremix/*; export-shape dispatch). Copy + rename placeholders. Not a separate npm/Kody package.starter-fetch/— archived pre-#2286 fetch + dual-path client template (reference only; scaffolder does not copy it).app/(kody.app.entry) — lean Remix demo for this kit only. Do not copy it when scaffolding a new app (usestarter-remix//create-package-app).archive/fetch-demo/— retired fetch HTML demo (app.ts+demo-page.ts) kept for history.
Remix starter
Live scaffolder embeds starter-remix/ (Remix recipe: tsconfig JSX, remount + { fetch }, explicit kody:app# island ids; no kody.app.runtime). Regenerate embed with node scripts/embed-starter.mjs after template edits.
Create a new package app (one call)
ChatGPT / execute playbook — prefer dryRun first:
import createPackageApp from 'kody:@kentcdodds/package-app-kit/create-package-app'
export default async function main() {
// Optional: verify placeholders without creating a package
// return await createPackageApp({ packageName: 'my-notes', title: 'My Notes', dryRun: true })
return await createPackageApp({
packageName: '@kentcdodds/my-notes', // or bare leaf `my-notes` → @kentcdodds/my-notes
title: 'My Notes',
description: 'Optional kody.description (defaults from title)',
})
}Returns { ok, package_id, kody_id, hosted_app_url, published_commit } (or a dryRun file set). Copies embedded starter-remix/ only (never this kit’s live app/ demo). Replaces __PACKAGE_NAME__, __PACKAGE_ID__, __APP_TITLE__, __CACHE_NAME__, __KODY_DESCRIPTION__ and renames *.template.*. New packages are private. Emits .kody/icon.svg as the list/identity mark (no root community-icon.*). Scaffolded apps serve kit 192+512 PWA PNGs via iconPngResponse (required for Chromium promotion) — those stay under /icons/, not .kody/. For disposable smokes use a name like @kentcdodds/pak-scaffold-smoke then packageDelete with confirm_name.
Then run the After scaffolding checklist (list mark, PWA icons, titles, local record-version, Remix recipe, manifest colors).
How a new app should import the kit
import themeCss, { tokenNames, uiPrimitiveClassNames } from 'kody:@kentcdodds/package-app-kit/styles'
import { renderServiceWorker, buildWebManifest } from 'kody:@kentcdodds/package-app-kit/sw'
import { appShellHtml } from 'kody:@kentcdodds/package-app-kit/app-shell'
import { installChromeHtml } from 'kody:@kentcdodds/package-app-kit/install'
import { aboutPageHtml, formatAboutPageData } from 'kody:@kentcdodds/package-app-kit/about'
import { kitServedManifestIcons, iconPngResponse } from 'kody:@kentcdodds/package-app-kit/icons'
import { clientBootResponse, clientModuleResponse } from 'kody:@kentcdodds/package-app-kit/client'record-version: keep a local src/record-version.ts + src/version.ts that use this app's packageStorage() (scaffolder already emits them). Call kody:@scope/app/record-version after publish. Do not re-export kody:@kentcdodds/package-app-kit/record-version — that writes the kit's storage and fails provenance.
Put config in HTML (data-app-base, [data-pak-config] JSON) for dual-path clients. Remix starters use Document/data-pak-config from starter-remix/app/ui/document.tsx.
Lucide icons (checked-in SVG)
Set: Lucide — ISC, 24×24 stroke, plain SVG. Repo: https://github.com/lucide-icons/lucide
Add an icon (copy-paste):
node scripts/add-lucide-icon.mjs download trash-2 plusWrites app/icons/{name}.svg + {name}.tsx and mirrors into starter-remix/app/icons/. Browse names at https://lucide.dev/icons. Attribution: app/icons/ATTRIBUTION.md.
Use in Remix UI:
import { IconDownload } from '../icons/download.tsx'
import { IconTrash2 } from '../icons/trash-2.tsx'
// Icon-only → tip + aria-label
<button class="pak-btn pak-btn-icon" type="button" data-tip="Install App" aria-label="Install App">
<IconDownload size={20} />
</button>
// Labeled → icon + text, tip optional/omitted
<button class="pak-btn pak-btn-danger" type="button">
<span class="pak-cluster pak-gap-2"><IconTrash2 size={18} /> Delete</span>
</button>Prefer this one-shot fetch script over adding a Lucide runtime dependency to every scaffold.
packageStorage migrations
Implementation: @kentcdodds/package-storage-migrations (declared in
package.json#kody.dependencies). This kit keeps the stable consumer import
kody:@kentcdodds/package-app-kit/storage-migrations as a thin re-export.
Always pass the caller's packageStorage() — never call packageStorage()
inside the migrations package.
import { packageStorage } from 'kody:runtime'
import {
runPackageStorageMigrations,
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')
}
},
},
],
})
await ensureSchema() // idempotent; safe on every requestDemo usage: app/data/notes.ts. Local smoke: node scripts/smoke-migrations.mjs.
Smoke tests
storage-migrations (local)
node scripts/smoke-migrations.mjsstorage-migrations (execute, after publish)
import {
runPackageStorageMigrations,
createMigrationRunner,
} from 'kody:@kentcdodds/package-app-kit/storage-migrations'
export default async function main() {
const map = new Map()
const storage = {
get: async (k) => map.get(k),
set: async (k, v) => void map.set(k, v),
delete: async (k) => void map.delete(k),
}
await storage.set('notes-v1', [{ id: '1', text: 'hi', createdAt: '2026-01-01T00:00:00.000Z' }])
const migrations = [{
version: 1,
name: 'notes-to-document',
async up(s) {
const legacy = await s.get('notes-v1')
if (Array.isArray(legacy)) {
await s.set('notes', { items: legacy })
await s.delete('notes-v1')
}
},
}]
const first = await runPackageStorageMigrations({ storage, versionKey: 't:v', migrations })
const second = await runPackageStorageMigrations({ storage, versionKey: 't:v', migrations })
return { first, second, idempotent: second.applied.length === 0 }
}record-version write/read
import recordVersion, { readRecordedVersion } from 'kody:@kentcdodds/package-app-kit/record-version'
export default async function main() {
const written = await recordVersion({
sha: 'smoke-test-sha-0000001',
message: 'Smoke record-version',
})
const read = await readRecordedVersion()
return { written, read, match: written.sha === read.sha }
}create-package-app dryRun
import createPackageApp from 'kody:@kentcdodds/package-app-kit/create-package-app'
export default async function main() {
const result = await createPackageApp({
packageName: 'pak-scaffold-smoke',
title: 'PAK Scaffold Smoke',
dryRun: true,
})
const paths = (result.files || []).map((f) => f.path)
return {
ok: result.ok,
dryRun: result.dryRun,
fileCount: result.files?.length,
hasKodyIcon: paths.includes('.kody/icon.svg'),
hasCommunityIcon: paths.some((p) => p.startsWith('community-icon.')),
leftover: (result.files || []).flatMap((f) =>
[...f.content.matchAll(/__[A-Z0-9_]+__/g)].map((m) => m[0]),
),
}
}Demo app (Remix)
Browser WebSocket live-sync needs a real browser session (packageAppFetch cannot upgrade websocket). HTTP Notes add/delete and GET /notes with Accept: application/json remain smokeable via packageAppFetch.
import { kody } from 'kody:runtime'
export default async function main() {
const paths = ['/', '/notes', '/about', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/icon-512.png', '/health']
const out = []
for (const path of paths) {
const res = await kody.packageAppFetch({ kody_id: 'package-app-kit', path })
out.push({
path,
status: res.status,
type: res.headers?.['content-type'] || res.headers?.['Content-Type'],
snippet: String(res.body || '').slice(0, 120),
})
}
const html = async (path) => {
const res = await kody.packageAppFetch({ kody_id: 'package-app-kit', path })
return { path, status: res.status, body: String(res.body || '') }
}
const home = await html('/')
const notes = await html('/notes')
const about = await html('/about')
const signals = {
header: /pak-brand|pak-logo|pak-title/.test(home.body),
brandBust: /\/brand\/logo-bust\.png/.test(home.body),
brandHero: /\/brand\/logo-full\.png/.test(home.body),
brandBustOk: out.some((p) => p.path.includes('logo-bust') && p.status === 200 && String(p.type || '').includes('image/png')),
brandFullOk: out.some((p) => p.path.includes('logo-full') && p.status === 200 && String(p.type || '').includes('image/png')),
installSlot: /pak-install-slot|data-install-wrap/.test(home.body),
noWelcome: !/\bWelcome\b/.test(home.body) && !/Got it/.test(home.body),
accentLabel: /Accent/.test(home.body),
notesCta: /aria-label="Notes \(\d+\)"/.test(home.body) && (home.body.match(/class="pak-btn pak-btn-accent"/g) || []).length >= 1,
noDuplicateNotesCount: !/>\s*0 notes\s*</.test(home.body) && !/pak-muted[^>]*>\s*\d+ notes/.test(home.body),
notesPage: notes.status === 200,
aboutPage: about.status === 200 && /pak-brand/.test(about.body),
}
return { paths: out, signals, allGood: Object.values(signals).every(Boolean) }
}Edge cases
client-cacheneeds IndexedDB — only use from browser/client bundles.- Prefer
public/sw.js+__version.jsonfor cache naming; bump offline/precache paths when routes or brand routes change (include/brand/logo-*.png,/icons/icon-*.png, andstyles.cssin SW install precache). skipWaiting+clients.claimare on by default; pair with the fixed dismissible update toast → Refresh (reload) so users are not surprised mid-interaction. Do not use an in-flow update card.- Promote scope to
@kodylater; keep private@kentcdoddsuntil then.