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.

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 from execute and 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 (never kody:@kentcdodds/package-app-kit from 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/icon until branded.
  • Keep the tree lean (no huge binaries). Chromium / Brave URL-bar Install needs PNG 192+512 — serve via kit iconPngResponse routes (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 root community-icon.* for new packages. Do not move PWA icons (icons/icon-192.png, public/) into .kody/. packageSave is 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. Prefer kody.app entry/client/assets — no kody.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:

  1. 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.
  2. 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 those PNGs from app routes (iconPngResponse or handlers that return image/png), and point favicon / apple-touch-icon / header <img> at routes.…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/; keep public/ for CSS, sw.js, and other asset-host files.
  3. Fill product identity — Set __APP_TITLE__ / title, description, and packageName (and related placeholders) to the real product names.
  4. Keep local version writers — Use this package's src/version.ts + src/record-version.ts against this app's packageStorage (the scaffolder already emits them). Call this package's ./record-version, not the kit's kody:@kentcdodds/package-app-kit/record-version.
  5. Record each publish — After every successful publish, call this package's ./record-version with the published SHA (and message / committedAt when available).
  6. Remix recipe (#2312) — When the scaffold is Remix: 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.
  7. Match manifest colors to the mark — Set themeColor / backgroundColor in buildWebManifest (and the document theme-color meta) so they harmonize with the mark.
  8. 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.

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_NAMESbg0, bg1, surface, surface-2, ink, muted, line, accent, accent-2, accent-ink, good, warn, bad, radius, radius-sm, tap, gap, space-1space-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 down

Helpers: 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=no as 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 remains standalone.
  • buildWebManifest also accepts orientation, categories, displayOverride, and extras for 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 .tsx that uses JSX) — put both lines at the top of the file so host esbuild always emits remix/ui automatic JSX:
    /** @jsxRuntime automatic */
    /** @jsxImportSource remix/ui */
    Keep root jsxImportSource as supporting config; stamp both pragmas on controllers, UI, icons, and any new Remix SSR .tsx that contains JSX.
  • Router app/router.tscreateRouter({ middleware: [requestId(), formData()] }), remount the Request when routes are prefixed with packageContext.appBasePath, then export default { fetch(request) { return router.fetch(remountRequest(request)) } } (do not default-export the router object — Worker env is not RequestInit).
  • Realtime — named handleRealtimeEvent from app/router.ts (kit createNotesRealtimeHandler); Notes mutations call broadcastNotesChanged via KodyRuntime.realtime; NotesDemo connects with ./realtime-client.
  • Routes app/routes.tsroute(packageContext?.appBasePath ?? '', { … }).
  • Client app/assets/entry.tsrun({ loadModule }) registry of named islands.
  • IslandsclientEntry('kody:app#Name', function Name…) plus the same name in the browser registry (workerd leaves import.meta.url empty).
  • 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; /health demos serverCachified from src/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>.js and sets packageContext.clientModuleUrl.
  • Static files under public/ are served at packageContext.assetBasePath (…/_assets/…). public/sw.js is the preferred SW.
  • When clientModuleUrl is null on older hosts, fall back to Worker-served /client/boot.js via this kit’s ./client export (clientBootResponse). Prefer clientModuleUrl whenever non-null.
  • SW: register ${assetBasePath}/sw.js with { scope: appBasePath } when assets are live. public/sw.js discovers the module via GET ${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):

  1. On press → mark pending synchronously (opacity / aria-busy). That is not optional.
  2. Wait ~150–300ms before swapping the label to “Loading…” / showing a spinner.
  3. Once a busy label/spinner is shown, keep it for a minimum duration (~300–500ms) so it does not flicker off.
  4. 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.

  1. Ephemeral / recoverable (toast after a failed action) → fixed toast host (zero CLS).
  2. Replacing expected content (validation page, empty About failure, SSR catch) → in-flow ErrorBanner (app/ui/error-banner.tsx) with role="alert".
  3. Fetch / Worker helperskody:@kentcdodds/package-app-kit/error-display (toErrorMessage, errorBannerHtml).

Playbook:

  • Title + short message; optional hint (recovery link) and detail via data-tip.
  • Wire validation failures, About update-check errors, and render() SSR catch to ErrorBanner.
  • 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:

  1. Update available → dismissible fixed toast (UpdateBanner / .pak-update-toast). Never an in-flow banner.
  2. Toasts / statusdata-toast-host (fixed). Sticky for “refresh when ready”; auto-dismiss for short confirmations.
  3. Skeletons → reserve height for async islands so hydration does not jump.
  4. Install → reserved header slot (always); iOS hint as fixed overlay; hide entirely in display-mode: standalone.
  5. Errors → ephemeral → toast; content replacement → ErrorBanner in a reserved region.
  6. 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):

  1. Icon-only header installdata-tip="Install {app}" + matching aria-label.
  2. Logo — package id / brand string when the mark alone is ambiguous.
  3. Truncated values — short SHA with full SHA tip; local datetime with ISO tip.
  4. Color tokens — show a visible text label beside each swatch (Accent, Ink, …).
  5. 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

SubpathWhen to use
.Named re-exports (tokens, icons, client serve helpers) — no describe overview
./error-displaytoErrorMessage + errorBannerHtml for Worker/fetch apps
./stylesDesign tokens + UI primitive CSS; TOKENS / tokenNames() / UI_PRIMITIVE_CLASSES / uiPrimitiveClassNames()
./uiVanilla toaster (sonner-inspired) CSS/HTML
./double-checkTwo-step confirm + type-to-confirm for destructive ops (Worker helpers)
./installInstall CTA HTML + standalone / first-run helpers
./aboutFormat About data + /about page HTML (optional collapsed panel)
./iconsKit-served 192+512 PNG (iconPngResponse, kitServedManifestIcons) + UTF-8 SVG helpers — no local PNGs required
./app-shellappShellHtml full document shell for fetch-handler PWAs
./routerclientRouterModulePath() (legacy path hint; prefer bundled client)
./clientInterim Worker serve of bundled boot (clientBootResponse) when clientModuleUrl is null
./record-versionWrite/read publish metadata in packageStorage
./update-checkCompare running vs latest (Worker helpers)
./client-cacheBrowser TanStack query-core + IDB persistence (not Worker-safe)
./server-cachecachified + packageStorage for Worker/exports
./swSW body, buildWebManifest (display / orientation / categories / extras)
./create-package-appOne-call scaffolder: copy starter-remix/ → new private package + publish
./storage-migrationsRe-exports @kentcdodds/package-storage-migrations (stable kit subpath; caller passes storage)
./realtime / ./realtime-clientOptional 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:

  1. HTTPS (Kody hosted apps already are).
  2. Web app manifest linked from every HTML page (<link rel="manifest" href="{appBase}/manifest.webmanifest" crossorigin="use-credentials">).
  3. Manifest has name / short_name, start_url, display (standalone or minimal-ui / fullscreen).
  4. Manifest icons include PNG 192×192 and 512×512 that return 200 image/png.
  5. 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:

  1. Capture beforeinstallprompt. Call event.preventDefault() only when a custom header Install CTA is in the DOM and event.prompt is 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 never prompt()ed.
  2. 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).
  3. On Install click call storedEvent.prompt() on the captured event (native install UI) in the same user-gesture turn. Icon-only control uses data-tip + aria-label (good tip use).
  4. 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 hintDismissKey to localStorage and must hide the overlay; CSS for .pak-install-hint-overlay must honor [hidden] (display: none !important). Never render “On iPhone…” for desktop or non-iOS UAs.
  5. Standalone / installed: hide Install CTA and iOS hint entirely.
  6. Desktop-friendly: when BIP never fires, keep the reserved slot empty (ready for BIP) rather than phone-only instructions.
  7. 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 + NotesDemo island: 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-18 (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):

  1. Page shell: .pak-wrap with comfortable padding (--space-5).
  2. Section rhythm: .pak-stack gap --space-5 between cards.
  3. Card inset: .pak-card padding --space-5.
  4. 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.* → root icon.* → root community-icon.* → package-app icons/icon-192.png → monogram/swirl.
  • New scaffolds emit .kody/icon.svg via create-package-app / packageSave (text-only). Prefer replacing with .kody/icon.png on the git lane when you have a binary mark.
  • Do not scaffold root community-icon.* for new packages. Mention community-icon only 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.js with the same public-access posture as HTML.
  • Manifest cache-control is no-store so a 403 cannot stick for 24h.
  • SW treats the manifest as network-first (credentialed same-origin), caches only ok + non-opaque responses, and never caches 403/error/opaque. Bump cacheName / rely on __version.json when 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 }) — Kody confirm_name style; confirm() only runs when typed value matches.
  • Live demo Notes uses app/ui/notes-demo.tsx + app/ui/double-check-button.tsx (clientEntry islands) 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.2 for 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 .tsx that 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 (platform remix/*; 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 (use starter-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 plus

Writes 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 request

Demo usage: app/data/notes.ts. Local smoke: node scripts/smoke-migrations.mjs.

Smoke tests

storage-migrations (local)
node scripts/smoke-migrations.mjs
storage-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-cache needs IndexedDB — only use from browser/client bundles.
  • Prefer public/sw.js + __version.json for cache naming; bump offline/precache paths when routes or brand routes change (include /brand/logo-*.png, /icons/icon-*.png, and styles.css in SW install precache).
  • skipWaiting + clients.claim are 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 @kody later; keep private @kentcdodds until then.