Repository navigation
Migration
0.6.0 moves the machinery out of the scaffold seams into owned files, so each
app migrates once — one PR per app, in that app's repo, with a deploy check.
Per-app notes (HamsterFlight, Hausverwaltung, Tonspur) are in
08-app-migrations.md.
-
Pull the owned files of the blocks the app already uses.
updatenever
writeswebBase.unmanagedfiles and never adopts a block:bunx github:daniel-rck/web-base#v0.6.0 check core --diff bunx github:daniel-rck/web-base#v0.6.0 update core --apply
-
Add the new owned files and dependencies.
addskips every file that
exists; delete the scaffold copies the app doesn't want afterwards (e.g. a
secondHomePage):for t in testing pwa router worker storage; do bunx github:daniel-rck/web-base#v0.6.0 add "$t" done
-
Rewrite the seams onto the owned files:
src/lib/ui/theme.cssbecomes the seam —@import "./tokens.css";, then
:root { --accent-h: <hue>; }, then only the app's own tokens. Delete
everythingtokens.cssnow defines.src/lib/db/db.ts:export const getDB = createDBOpener<AppSchema>({ name, version, upgrade })from./open.ts. Keep the app's existing
databasenameandversion— a new name starts every user with an
empty database.src/sw/index.ts:registerAppShell()from./base.ts, then only the
app's own handlers; noskipWaiting(),clientsClaim()or
precacheAndRoute()of its own. Invite.config.tsset
registerType: "prompt"; render<UpdatePrompt />next to
<RouterProvider>inmain.tsx.tsconfig.sw.jsonneeds
"allowImportingTsExtensions": true.- The router: the shell as the root layout route (
src/App.tsx),
ErrorBoundary: RouteError,HydrateFallback: RouteFallback, and
{ path: "*", Component: NotFound };main.tsxrenders
<RouterProvider>without anAppShellaround it. worker/index.ts:fetch: (request, env, ctx) => routeRequest(request, env, ctx, handleApi). Inwrangler.toml:not_found_handling = "single-page-application"under[assets], norun_worker_first,
nodejs_compatonly if the worker imports a Node built-in,
compatibility_date≥ 2025-04-01.tsconfig.worker.jsonneeds
"allowImportingTsExtensions": true. Review the CSP in the new
public/_headers.
-
Take the new hue and
theme_colorfrom the table in
04-layout-system.md:
--accent-hintheme.css,theme_colorinvite.config.tsand
<meta name="theme-color">inindex.html. Every app'stheme_color
changes, Pizzateig's and Tankzettel's too (accent-600is darker). -
Search the app's own code for what the new tokens replace — white text
on accents, removed focus outlines, semantic text on its own tint:grep -rnE 'text-white|outline-none|text-(success|warning|danger|info)\b' src --exclude-dir=libUse
text-fg-on-accent, the default focus outline, andtext-*-fg. -
Verify and pin.
bun run lint && bun run typecheck && bun run test && bun run build, thenweb-base check --strict(never in HamsterFlight),
web-base pins(useLiveQueryneeds React ≥ 19.2),bunx wrangler deploy --dry-run, and pin the caller toweb-base-check.yml@v0.6.0.
Changed
- Breaking: exit codes are 0 / 1 / 2.
0ok,1the app does not conform
(checkdrift or--strictfindings),2the command could not run (bad
usage, unknown option, malformedpackage.jsonor manifest, missing target
directory). Everything failed with1before; the reusable workflows only
test for non-zero, so they are unaffected. - Breaking: unknown options are rejected (exit 2). citty ignored them, so
check --strcitran as a plaincheckand passed. - Breaking: a malformed
package.jsonis an error, not "unstamped". The
readers swallowed parse errors, soupdatereported the app as unstamped
andchecksilently ignoredwebBase.unmanaged. A missing file is still
fine where it was before.webBase.versionandwebBase.unmanagedare
type-checked too;unmanagedentries are normalized (./, backslashes). - Breaking:
updatenever writeswebBase.unmanagedfiles, and
add --forcedoesn't either.update core --apply— the command
notify-apps.ymlsends every app — would have reverted Hausverwaltung's
useLiveQueryfork. - Breaking: expanding a meta-template never adopts a block.
update core --applyinstalled every missing owned file, including whole blocks an app
never used (layout and storage into HamsterFlight). A block with no owned
file present is now reported asnot adopted;update <block> --applyor
add <block>adopts it. - Breaking:
initnever overwrites an existingpackage.json, not even
with--force, which used to delete every app dependency and script. --force-scaffoldimplies--force. On its own it did nothing.- The CLI internals were rebuilt around one
package.jsondocument (loaded
once, saved once) and one install path (applyTemplates) shared byinit
andadd;pkg.ts,manifest.tsandcopy.tsare split into
lib/pkg/,lib/manifest/andlib/files/. web-base-check.ymlruns the CLI at the app's stamped version.ref
now defaults to"", which resolves tov<webBase.version>from the app's
package.json, or tomainwith a warning when that tag doesn't exist. A
change on web-basemainno longer turns every app red. An explicitref:
works as before.- The reusable workflows take Bun from the app.
bun-versiondefaults to
"", so setup-bun readspackageManager: "bun@x.y.z"; a warning appears
when neither is set. web-app-ci.ymlcaches~/.bun/install/cache(setup-bun only caches the
binary) and skips the separate typecheck when the build script already runs
tsc.notify-apps.ymlis called byrelease.ymlinstead of listening for
release: published, which never fires for a release created with
GITHUB_TOKEN. The issue lists the update steps pinned to the release tag
(checkfirst, thenupdate core --apply,add <template>for
package.json changes), and the previous version's issue is closed as not
planned ("Ersetzt durch #N").- All workflows are hardened.
permissions: {}with per-job grants,
timeouts,persist-credentials: false, job-level concurrency, actions pinned
by commit SHA, and inputs only viaenv:, validated. Callers must leave
contents: readto the called jobs (omitpermissions:or grant it;
permissions: {}fails at startup). Apps should pin@vX.Y.Zand let their
Dependabot bump it. - Breaking: the design tokens move into an owned
tokens.css.theme.css
was the whole token file and a scaffold seam, so no token fix ever reached
an existing app. It is now a five-line seam that importstokens.cssand sets
--accent-h;update layout --applykeeps the tokens current. Apps replace
theirtheme.cssonce (see Migration). - Breaking: accent hues are redistributed so every accent sits ≥25° from the
semantic hues and from every other app — the old table broke its own rule
three times. Tennisturnier 155 → 175, Minispiele 195 → 200, Zeiterfassung
230 → 255, Hausverwaltung 250 → 280, ErinnerMich 285 → 305, Tonspur 320 →
330; Pizzateig 50 and Tankzettel 110 stay. 355 is the free slot and the new
template default (was 250). - Breaking: darker accent and danger shades so every text pair reaches WCAG
AA (4.5:1) at every app hue: accent-500…900 one step darker (primary
buttons at L 0.49 instead of 0.55),danger0.60 → 0.55,fg-subtle0.60 →
0.54 (light) / 0.55 → 0.62 (dark). White on several accent buttons was
3.9:1, badge text on its tint as low as 1.9:1. - Breaking: one file per layout primitive,
refas a prop.primitives.tsx
(221 lines) is now a barrel overButton,Card(+SectionCard),Chip,
Badge,SpinnerandEmptyState;forwardRefis gone (React 19). Imports
fromprimitives.tsxkeep working. - Breaking:
PageHeaderis the page's<h1>; the header title is a
<span>. Every page outline used to open with the app name as<h1>, and
the page title sat at<h2>, level with its sections. useThemeis one store for the page. Each call kept its own state, so a
second consumer (a chart readingresolvedTheme) went stale when the toggle
changed; other tabs now follow via thestorageevent, and the hook no
longer throws withoutmatchMedia(jsdom).setThemeis exported on its own.- Breaking: a new service-worker version waits for the user.
sw.ts
calledskipWaiting()on install, so a deploy activated the new worker under
open pages and evicted the lazy chunks they still needed. The owned
src/sw/base.ts(registerAppShell()) activates only onSKIP_WAITING; the
newUpdatePrompt(„Update verfügbar – neu laden") anduseAppUpdate()hook
(useRegisterSW,registerType: "prompt", hourly re-check) drive it.
src/sw/index.tsshrinks to the app's own handlers. - Breaking: the router renders the shell as its root layout route. The
template shipssrc/App.tsx(AppShellaround<Outlet />), error and 404
pages, and theHomePageits lazy import always pointed to;main.tsx
renders<RouterProvider>alone. The old instructions said to wrap the app
in bothAppShellandRouterProvider, and the first reading throws. initbuilds a complete app. It applies the newapptemplate —core
plusindex.html,vite.config.ts, the tsconfigs,src/main.tsx,
src/index.cssand.gitignore— and fills the app name into every
<app-name>placeholder of the seams it creates. Next steps list only what a
fresh app still needs.vite.snippet.mdis gone; the VitePWA block lives in theapp
template'svite.config.tsand the skill'spwa.md.checkreports a
leftover copy as obsolete.- Breaking: the
synctemplate speaks protocol v2. v1 state, envelopes,
object ids and the/api/sync/<id>/data.jsonroute are gone without a
migration — no app used the template's sync code. A 128-bit root secret feeds
HKDF-SHA256 with separate labels for a non-extractable AES-GCM-256 key, an
80-bit object id and a 256-bit bearer token; envelopes are{ v: 2, iv, ct }
with the object id as AAD.sync()pushes against the ETag its own pull saw
(two tabs conflict instead of overwriting each other) and trusts a304only
for the document it last synced, so a result the app never got to save is
re-merged, never pushed back over newer remote data. Client and worker are
split into one-concern files underclient/andworker/;
src/lib/sync/index.tsanddocs/sync.mdare scaffold seams, everything
else is owned. - Breaking: the sync worker authenticates every request and no longer uses
KV.GET/PUT/DELETE /api/sync/<objectId>needAuthorization: Bearer <token>; the first write binds the object toSHA-256(token), so a leaked
object id gives neither read nor write. Creates needIf-None-Match: *,
updatesIf-Match, bodies are capped at 8 MiB while they are read (a
chunked upload can't get past it) and must be v2 envelopes,
responses areno-store. Rate limiting moves from a KV bucket keyed by raw
IPs to the optional Rate Limiting bindingSYNC_RATE_LIMIT, keyed by object
id — no IP addresses at rest. Apps bind R2 asSYNC(plus optional
[[ratelimits]]). tools-ci.ymlfollows the workflow rules too:permissions: {}with
per-job grants, a concurrency group, SHA-pinned actions,persist-credentials: false, and Bun frompackageManagerinstead of a hard-coded version.- Breaking:
useLiveQueryneeds React ≥ 19.2 and imports./mutations.ts.
It runs the query throughuseEffectEventinstead of writing a ref during
render, returns{ data: undefined, loading: true }while[storeName, ...deps]changes (it used to show the previous key's data as current), and
keeps the last good data on error.update storage --applybrings
mutations.tsalong. storage'sdb.tsis a thin seam over the new ownedopen.ts
(createDBOpener) andmutations.ts(mutationChannel,notifyMutation,
clearStores), with a per-app database name and anif (oldVersion < N)
migration ladder.worker/index.tsdelegates to the ownedworker/base.ts
(routeRequest,json);wrangler.tomlturns on SPA mode
(not_found_handling = "single-page-application"), dropsnodejs_compat
from the default, andtsconfig.worker.jsongains
allowImportingTsExtensions.coreextendstesting.
Fixed
-
dark:utilities respect a forced light theme. The custom variant's
:not([data-theme="light"])matched every element below<html>, so with a
dark OS and the toggle on light,dark:classes (the active nav pill) still
applied over the light tokens. Both alternatives are anchored to:rootnow;
update layout --applybrings the fix (it lives in the ownedtokens.css). -
init --dry-runno longer fails ("No package.json found") and writes
nothing at all — no directory, no.git. -
add corewithout apackage.jsonfails before the first write instead
of dying afterhygienewas copied. Files-only templates still work without one. -
check router|pwa|worker|hygienepass. Templates that ship only
scaffold seams always failed with "not on the base at all". -
addno longer stamps over owned files it kept. The stamp claimed the
app had pulled the current version, andupdatethen mislabeled those files
as local edits that--applywould revert. -
Manifests are validated, and template paths can't leave their roots. A
toof../xor/etc/x, afromoutside the template, a name that
doesn't match its directory, an unknown key or a missingextendstarget is
an error before anything is written;add /abs/dirno longer loads
/abs/dir/manifest.json. One broken manifest no longer hides the template list.
No write goes through a symlink either: a symlinked destination, or a
symlinked directory that leads out of the app, is exit 2 —update --apply
could otherwise overwrite whatever the link points to. -
Line endings don't count as drift. A checkout with
core.autocrlf=true
made every owned file differ. -
Patching
package.jsonkeeps its shape. A dependency the app lists in the
other section is updated in place instead of duplicated, sorted sections stay
sorted, and indentation and CRLF line endings survive. -
initdetects an enclosing Git work tree (no nested.gitin a
monorepo), creates a missing target directory, validates the app name (it
becomes the npm name and the workers.dev label) and names "fill in
src/features/" as a next step. -
update --applywithout apackage.jsonwrites the files and warns that
it couldn't stamp, instead of failing after writing. -
The build no longer needs GNU sed. The shebang comes from
bun build --banner;sed -i '1i…'brokebun install(viaprepare) on
macOS and stacked a second shebang when run twice.tools-ci.ymlnow fails
on untracked files incli/disttoo (git status --porcelain). -
Tests stub
WEB_BASE_TEMPLATES_DIRwithvi.stubEnv. Assigning
undefinedtoprocess.envstores the string"undefined". -
web-base-check.ymlputinputs.refandinputs.templatestraight into
the shell. They now go throughenv:and are validated against a pattern. -
The
strictinput description was wrong. It fails on drift, blocks not
adopted, partial adoption and obsolete leftovers. -
notify-apps.ymlhad no timeout, and its dedupe relied on the lagging
search index. -
The CI docs named tags that don't exist (
@v0.3.0,ref: v0.2.1) and
contradicted each other on@mainversus tags. -
Keyboard focus survives forced-colors mode. Focus was a box-shadow ring
withoutline-none, which Windows high contrast removes; it also drew a
white halo in dark mode. All controls use a real outline now. -
Disabled secondary and ghost buttons looked enabled; hover no longer
applies to disabled controls. -
Text on fills used
text-whiteinstead of--color-fg-on-accent, and
badge text sat on its own semantic tint at 1.9–3.5:1. -
The spinner wasn't announced:
role="status"carried only an
aria-label; it now contains visually hidden text. -
ThemeTogglenamed only the current state; it now says what a click does. -
iPadOS 13+ never saw the install button (it reports a Macintosh user
agent); abeforeinstallpromptfired before the shell mounted was lost; a
failingprompt()was an unhandled rejection. The iOS dialog is labelled by
its heading. -
The sticky sidebar ignored the notch inset the header absorbs.
-
initproduced a scaffold that did not build (no entry files, no
React/Vite/TypeScript dependencies, a lazy import of a page no template
shipped). The deferred item in08-app-migrations.mdis resolved. -
The sync client no longer overwrites or wedges remote data. The first push
overwrote the remote object; a pull404kept a stale ETag so every later push
failed with412;push/pullthrew after a reload unlessisEnabled()ran
first;enable()replaced an existing secret;304and404looked the same;
a blockedlocalStoragecrashed the app; unvalidated JSON reached the caller. -
The sync docs described OTP pairing routes that never existed and could not
have been secure (the server could unwrap the secret, and 10⁶ codes are
guessable), and the postInstall referenced asyncClientthat did not exist. -
A failed IndexedDB open was cached forever (VersionError, quota, private
mode); the nextgetDB()now retries. An open tab no longer blocks a newer
tab's schema upgrade — it closes its connection and reloads — and a
connection the browser dropped (Safari) is reopened.clearAll()on a
database without stores no longer throws. -
The storage postInstall showed a call form that doesn't exist
(useLiveQuery(db.<store>, …)). -
Reloading a client route 404'd on Workers Assets without SPA mode (new
apps; existing apps add onewrangler.tomlline). A throwing API handler
returns 500{ "error": "internal" }instead of Cloudflare's exception
page, and a stale hashed asset gets a 404 instead ofindex.html
served as JavaScript.
Added
web-base pinscompares an app'spackage.jsonagainst the fleet's pin
table and exits 1 on any mismatch (behind/ahead/different, and a
missingpackageManager); packages the app doesn't use are ignored.
--applyrewrites mismatched ranges in place and adds nothing;--json
for CI. The table moved intocli/templates/pins.json, the single source
that07-conventions.md, the skill'stech-stack.md(which was missing
fake-indexeddb) and every manifest are now tested against in both
directions.workbox-routing,-strategiesand-expirationjoin it.check --diffandupdate --diffprint a unified diff (local →
template,git apply -p1-compatible) for every differing file;update
includes scaffold seams, so upstream seam changes can be ported by hand.check --jsonprints a machine-readable result (schemaVersion: 1,
documented in02-cli.md) on stdout, keeping warnings on stderr; errors
become a{ ok: false, exitCode: 2, error }envelope.- The CI smoke tests run locally. The shell steps in
tools-ci.yml
(add hygiene,check --stricton a fresh scaffold, the
webBase.unmanagedexemption, a leftoverbiome.json,update core
restoring files,--forcekeepingwrangler.toml) are a vitest e2e suite
against the built bundle, plus checks for the shebang,--versionand exit
codes across the process boundary.bun run testrebuildscli/distfirst. - Root
vitest.config.tsscopes the repo's tests tocli/srcand
cli/test, so test files shipped inside templates are never collected here. - A
SessionStarthook (.claude/) installs dependencies in Claude Code
cloud sessions so thetypecheck/lint/testgatekeepers can run. - Releases are cut from the version in
package.json.release.ymlruns
on every push tomain; when that version has no release yet it tags
vX.Y.Z(lightweight) on the commit and publishes a GitHub release whose
notes are the version's CHANGELOG section, then notifies the apps. Pushes
that don't bump are a no-op; a missing CHANGELOG section or a tag that
already points elsewhere fails the run.workflow_dispatchwith asha
backfills an oldermaincommit (06-workflows.mdlists the SHAs for
0.3.0–0.5.0, which were never tagged). web-base-check.ymltakespinsandbun-versioninputs.pins: true
also runsweb-base pins(needs a ref of v0.6.0 or later). Every check now
passes--diff, so a failing run shows what drifted.- Dependabot keeps the pinned actions current: one grouped
chore(deps)
PR a week, for releases at least seven days old. - Text-on-tint tokens
--color-{success,warning,danger,info}-fg(light and
dark) and--color-danger-strongfor the danger hover. - Motion and radius tokens promoted from Pizzateig:
--animate-fade-in,
--animate-slide-up(with keyframes),--radius-2xl, and a
prefers-reduced-motionreset that spares only the spinner. - The theme is tested: every text pair at every hue in the 04 table, light
and dark, both gamut-mapping modes; the hue spacing rule; the two dark blocks
staying identical; and the spec showingtokens.css/theme.cssverbatim. OfflineIndicatorin the header (anduseOnlineStatus()): a warning
badge while offline, announced through an always-mounted status region.- A skip link („Zum Inhalt springen") to
<main id="main">. buttonClassName()styles a router<Link>as a button;Badgehas an
infovariant;EmptyStatetakestitleAs.- Offline deep links: the service worker serves
index.htmlfor every
navigation (except/apiand/healthz), so reloading/mieter/123
offline opens the app. - German error pages (
RouteError: a missing lazy chunk after a deploy
offers „Neu laden"),NotFoundfor*,RouteFallbackwhile the first route
loads, anduseDocumentTitle()— React Router showed its English developer
screen before. import/no-unassigned-importallows CSS imports in the shared oxlint
config (every app imports its stylesheet).- Devices pair by QR link or typed code; the server never sees the key.
syncClient.pairingCode()shows a checksummed 31-character Crockford code
(forgiving input),pairingUrl()puts the same string in a#sync=fragment
for a QR code,importPairingCode()adopts it (already_enabledunless
{ replace: true }), andconsumePairingFragment()removes it from the
address bar inmain.tsx, keepinghistory.state. No QR library ships;
docs/sync.mdshowsuqr. syncClient.sync(local, merge),disable({ deleteRemote })and typed
errors. Pull → merge → push with retry on conflict;SyncErrorcarries a
stablecode,status,retryAfterand a German message
(syncErrorMessage()); requests time out after 30 s and honour anAbortSignal.- The sync template is typechecked and tested in this repo.
tsconfig.templates.jsonruns as part ofbun run typecheck;cli/test/sync/
covers encodings, pinned key-schedule vectors, pairing, storage, the client
state machine, the worker and an end-to-end run against a fake R2 bucket. tools-ci.ymlbuilds what it ships. New jobs:scaffoldrunsinit,
addsbackupandsync, and lints, typechecks, tests (with the template
tests incli/template-tests/) and builds the result, then runscheck --strict,pinsandwrangler deploy --dry-runon it;bunx-installruns
bunx github:<repo>#<sha> --version;workflow-lintruns actionlint (with
shellcheck) and zizmor. Typecheck and build of a scaffold were left out
before as "slow and flaky", which is how aninitthat didn't build went
unnoticed.testingtemplate:vitest.config.ts(scaffold, merges
vite.config.ts), ownedsrc/test/setup.ts(fake-indexeddb, jest-dom
matchers, cleanup, amatchMediastub) andsrc/test/environment.test.tsx;
pins Vitest, jsdom, fake-indexeddb and Testing Library, including the new
@testing-library/dom.public/_headers(worker, scaffold): CSPscript-src 'self', HSTS,
X-Frame-Options,Referrer-Policy,Permissions-Policy, COOP, and
immutable caching for/assets/*. Worker responses getnosniffand
no-storefrombase.ts(Cloudflare doesn't apply_headersto them).backupextra: export, import and wipe of the whole IndexedDB as a
versioned JSON file, with a codec that keeps Dates, Maps, Sets, Blobs,
binary data andundefined; an atomic restore; persistent-storage helpers;
a GermanBackupCardfor the settings page.