@webjsdev/mcp 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/resources/AGENTS.md +169 -77
  4. package/resources/SKILL.md +226 -0
  5. package/resources/references/auth-and-sessions.md +220 -0
  6. package/resources/references/built-ins.md +200 -0
  7. package/resources/references/client-router-and-streaming.md +204 -0
  8. package/resources/references/components.md +167 -0
  9. package/resources/references/data-and-actions.md +187 -0
  10. package/resources/references/muscle-memory-gotchas.md +170 -0
  11. package/resources/references/optimistic-ui.md +128 -0
  12. package/resources/references/routing-and-pages.md +158 -0
  13. package/resources/references/runtime.md +80 -0
  14. package/resources/references/service-worker.md +78 -0
  15. package/resources/references/styling.md +123 -0
  16. package/resources/references/testing.md +125 -0
  17. package/resources/references/typescript.md +148 -0
  18. package/src/mcp-docs.js +27 -14
  19. package/src/mcp-source.js +12 -2
  20. package/src/mcp.js +4 -3
  21. package/resources/agent-docs/advanced.md +0 -1251
  22. package/resources/agent-docs/built-ins.md +0 -379
  23. package/resources/agent-docs/components.md +0 -712
  24. package/resources/agent-docs/configuration.md +0 -284
  25. package/resources/agent-docs/framework-dev.md +0 -65
  26. package/resources/agent-docs/lit-muscle-memory-gotchas.md +0 -470
  27. package/resources/agent-docs/metadata.md +0 -334
  28. package/resources/agent-docs/recipes.md +0 -528
  29. package/resources/agent-docs/runtime.md +0 -65
  30. package/resources/agent-docs/service-worker.md +0 -100
  31. package/resources/agent-docs/styling.md +0 -279
  32. package/resources/agent-docs/testing.md +0 -438
  33. package/resources/agent-docs/typescript.md +0 -402
@@ -1,34 +1,39 @@
1
- # AGENTS.md for webjs
1
+ # AGENTS.md for WebJs
2
2
 
3
- This file is the contract for **AI agents** (and humans) editing a webjs app.
3
+ This file is the contract for **AI agents** (and humans) editing a WebJs app.
4
4
  It describes file conventions, the public API, invariants to preserve, and
5
5
  recipes for common tasks. Keep it in sync whenever behaviour changes.
6
6
 
7
7
  **Detail docs**, loaded when relevant. They don't auto-load. This file stays
8
- lean on purpose; anything marked "see `agent-docs/<x>.md`" has the full
9
- reference there.
8
+ lean on purpose; anything marked "see the skill" has the full reference there.
10
9
 
11
- | File | Topic |
10
+ The full reference is the skill at `.agents/skills/webjs/SKILL.md`, which routes
11
+ to these `references/`:
12
+
13
+ | Reference | Topic |
12
14
  |---|---|
13
- | `agent-docs/metadata.md` | Full `metadata` / `generateMetadata` field reference |
14
- | `agent-docs/components.md` | WebComponent deep-dive (controllers, hooks, light/shadow DOM, slots) |
15
- | `agent-docs/styling.md` | Tailwind helpers + vanilla-CSS opt-out conventions |
16
- | `agent-docs/built-ins.md` | Auth, sessions, env vars, caching (cache(), HTTP cache, asset-hash, conditional GET), rate-limit, broadcast, file storage |
17
- | `agent-docs/configuration.md` | The `package.json` `"webjs"` block (security headers, CSP, redirects, trailing-slash, basePath, client-router opt-out, ingress caps, dev/start task orchestration) + observability |
18
- | `agent-docs/runtime.md` | Node vs Bun (commands, install, the listener/stripper/SQLite/WebSocket differences) + the future-Deno seam |
19
- | `agent-docs/advanced.md` | Suspense streaming, performance, bundling, client router (prefetch, frames, view transitions, stream actions), WebSockets |
20
- | `agent-docs/typescript.md` | TS at runtime + full-stack type safety |
21
- | `agent-docs/service-worker.md` | The opt-in progressive-enhancement service worker (`public/sw.js`) |
22
- | `agent-docs/testing.md` | Unit, browser, convention validation, the `handle()` test harness (`@webjsdev/server/testing`) |
23
- | `agent-docs/framework-dev.md` | Monorepo dev (only when editing webjs itself): commands, repo-health git config, changelog flow, dev error overlay |
24
- | `agent-docs/recipes.md` | Page / route / action / component recipes |
25
- | `agent-docs/lit-muscle-memory-gotchas.md` | **READ FIRST** when writing components. Lit patterns that break webjs SSR or reactivity, with the webjs-shaped fix for each |
15
+ | `references/routing-and-pages.md` | Pages, layouts, dynamic routes, route handlers, metadata, redirects, 404s |
16
+ | `references/components.md` | Writing components: reactive props, signals, lifecycle, light vs shadow DOM, inherited members not to shadow |
17
+ | `references/data-and-actions.md` | Server actions, mutations, queries, validation, the `ActionResult` envelope, Drizzle surface, where server-derived types live |
18
+ | `references/auth-and-sessions.md` | Sessions, login flows, route protection, `forbidden()` / `unauthorized()` |
19
+ | `references/styling.md` | Tailwind, light-DOM tag-prefix rule, tokens, fixed headers, no-reflow layout |
20
+ | `references/client-router-and-streaming.md` | Client router, prefetch, frames, view transitions, Suspense streaming |
21
+ | `references/optimistic-ui.md` | Optimistic UI for a user-facing mutation |
22
+ | `references/typescript.md` | TypeScript at runtime, erasable syntax, full-stack types |
23
+ | `references/testing.md` | Unit, browser, e2e tests, the `handle()` harness, Bun parity |
24
+ | `references/built-ins.md` | Auth, caching, env vars, rate limit, file storage, the `webjs` config block |
25
+ | `references/runtime.md` | Node vs Bun, running the app, deploying, runtime-specific differences |
26
+ | `references/service-worker.md` | Offline support, an asset cache, the opt-in service worker |
27
+ | `references/muscle-memory-gotchas.md` | **READ FIRST** when writing components or routes. Next.js / Lit patterns that break WebJs, with the webjs-shaped fix for each |
28
+
29
+ Repo-root `framework-dev.md` covers monorepo dev (only when editing WebJs
30
+ itself): commands, repo-health git config, changelog flow, dev error overlay.
26
31
 
27
32
  ---
28
33
 
29
34
  ## AI-driven development: guardrails for all agents
30
35
 
31
- **webjs is AI-first. These rules apply to ALL agents (Claude, Cursor, Copilot, Antigravity, Aider), enforced via per-agent config the scaffold ships** (`AGENTS.md` + `CONVENTIONS.md` + `CLAUDE.md`, `.claude/settings.json` hooks, `.cursorrules`, `.agents/rules/workflow.md`, `.github/copilot-instructions.md`, a PR template, `.editorconfig`), all carrying the same rules in each agent's format.
36
+ **WebJs is AI-first. These rules apply to ALL agents (Claude, Cursor, Copilot, Antigravity, Gemini, opencode) through a SINGLE cross-agent source the scaffold ships**: `AGENTS.md` (the open standard Cursor / opencode / Antigravity / the Copilot coding agent read natively) plus the skill at `.agents/skills/webjs/` and the workflow rules at `.agents/rules/workflow.md`. Tools that do not read `AGENTS.md` natively get a THIN bridge pointing at it (`CLAUDE.md` for Claude Code, `GEMINI.md` for Gemini CLI, `.github/copilot-instructions.md` for Copilot in VS Code), never a duplicated rule set. Claude Code additionally ships the protective enforcement hooks (`.claude/`, `.hooks/pre-commit`).
32
37
 
33
38
  ### Before starting ANY work: verify and sync the branch
34
39
 
@@ -40,7 +45,7 @@ Claude Code enforces step 1 via `.claude/hooks/guard-branch-context.sh`. Other a
40
45
 
41
46
  ### One task per git worktree when agents run concurrently
42
47
 
43
- webjs is worked by MULTIPLE agents at once. If more than one agent (or more than one in-flight task) shares ONE working checkout, they collide: a `git checkout` in one moves `HEAD` under the other, so the next commit lands on the WRONG branch. This has happened (a `chore: release` commit landed on an unrelated `feat/` branch, with a contaminated changelog). So give each task its own worktree:
48
+ WebJs is worked by MULTIPLE agents at once. If more than one agent (or more than one in-flight task) shares ONE working checkout, they collide: a `git checkout` in one moves `HEAD` under the other, so the next commit lands on the WRONG branch. This has happened (a `chore: release` commit landed on an unrelated `feat/` branch, with a contaminated changelog). So give each task its own worktree:
44
49
 
45
50
  ```sh
46
51
  git worktree add -b <prefix>/<slug> ../<repo>-<slug> origin/main
@@ -48,8 +53,12 @@ cd ../<repo>-<slug> # do ALL work for the task here
48
53
  # ... commit + push from this worktree; after merge: git worktree remove ../<repo>-<slug>
49
54
  ```
50
55
 
56
+ **A fresh worktree has NO `node_modules`** (git worktrees do not copy it), so running an app from one (`webjs dev` / `webjs start`, the test runner, a scaffolded app) fails to resolve `@webjsdev/*` until you install or link it. `webjs doctor` warns for this exact case (#954) and `webjs dev` / `webjs start` print the cause + remedy instead of a raw `ERR_MODULE_NOT_FOUND`. Fix by installing in the worktree (`npm install`) or symlinking the primary checkout's modules (`ln -s ../<primary-checkout>/node_modules node_modules`). When only a subset of `@webjsdev/*` packages was edited, link those from the worktree and the rest from the primary checkout so a built `dist/` (e.g. `@webjsdev/core`) still resolves.
57
+
51
58
  Git enforces one-branch-per-worktree, so separate worktrees make the collision impossible. Before any commit in a shared checkout, confirm `git branch --show-current` is still the branch you created; if it moved, you are colliding, switch to a worktree. A lone agent in a clean checkout may still use a plain branch. The repo's `.hooks/pre-commit` additionally BLOCKS a published-library (`core`/`server`/`cli`/`mcp`/`ui`/`intellisense`) version bump on any non-`chore/release-*` branch, the canonical wrong-branch-release symptom.
52
59
 
60
+ **Cleanup is automatic after a merge.** The `.claude/hooks/cleanup-merged-worktree.sh` PostToolUse hook fires after any `gh pr merge` and removes each linked worktree whose branch is merged AND whose tree is clean, so a merged branch's worktree never leaks (accumulated stale worktrees are exactly what it prevents). It is conservative: it KEEPS anything with uncommitted changes, an unmerged branch, or the worktree you ran the merge from (you cannot remove your current directory, so `cd` out and `git worktree remove` it yourself), and never touches the primary checkout. Disable with `WEBJS_NO_WORKTREE_CLEANUP=1`. Test: `test/hooks/cleanup-merged-worktree.test.mjs`.
61
+
53
62
  ### Skills are routed deterministically, never skipped
54
63
 
55
64
  A Skill is model-invoked, so it fires only when the model judges a match. The `.claude/hooks/route-skills.sh` `UserPromptSubmit` hook makes routing deterministic: it keyword-matches each prompt against every skill's documented triggers and injects a directive to invoke the matched skill before other work. Check the available skills and invoke a matching one before starting. The skills themselves are committed under `.claude/skills/` (alongside the hooks), so a fresh clone has both the router and the skills it routes to (no machine-local dependency). Tests in `test/hooks/route-skills.test.mjs`, which also asserts every skill the hook references is committed in-repo.
@@ -62,9 +71,10 @@ When interactive approval is disabled, never block on questions. Auto-decide: on
62
71
 
63
72
  Every code change MUST include, automatically:
64
73
 
65
- 1. **Tests, every applicable layer (not just unit).** Ship the tests that prove the change across EVERY layer it touches: **unit** (`packages/*/test/**`, `test/**`, including the counterfactual that fails when reverted), **browser** (`*/test/**/browser/*` via `npm run test:browser`, for hydration / DOM / slots / client router / custom-element upgrade), **e2e** (`test/e2e/*.test.mjs` via `WEBJS_E2E=1`, including network probes / navigation / streaming), and **smoke** (`test/examples/*/smoke/*`). A unit test is NECESSARY BUT NOT SUFFICIENT for any client-router / component / browser-facing change (the headline behaviour is a browser/e2e assertion). **Bun parity is part of the task, not an afterthought:** webjs runs on Node 24+ AND Bun (#508), so a change to a runtime-sensitive surface (the serializer, the node:http vs `Bun.serve` listener + request path, SSR / action / CSRF dispatch, streams, `node:crypto`, the TS stripper, auth / session / cors) MUST be proven on Bun (`node scripts/run-bun-tests.js` + the touched `test/bun/*.mjs` under `bun`) AND ship an added/updated `test/bun/<feature>.mjs` cross-runtime assertion. `npm test` does NOT run browser, e2e, or Bun; run them yourself and report the result. Never report work done with failing or missing tests. See `agent-docs/testing.md`. Enforced by `.claude/hooks/require-tests-with-src.sh` (the scaffold variant WARNS unless `WEBJS_TEST_GATE=block`) and `.claude/hooks/require-bun-parity-with-runtime-src.sh` (BLOCKS a commit that stages runtime-sensitive source with no `test/bun/**` test; escape hatch `WEBJS_BUN_VERIFIED=1`).
66
- 2. **Documentation, part of the definition of done (not optional).** A task is NOT done until EVERY doc surface its change touches is in sync: `AGENTS.md` + `agent-docs/*.md` for new API surface, `CONVENTIONS.md` (and per-package `AGENTS.md`) for new conventions, the docs site (`docs/app/docs/<topic>`), the marketing `website/`, the scaffold templates (`packages/cli/templates/` per-agent rule files), and `README.md` for a headline capability. Updating `AGENTS.md` alone reproduces the #488 gap (docs site left stale). Invoke the `webjs-doc-sync` skill to sync every applicable surface. Enforced by `.claude/hooks/require-docs-with-src.sh`, which BLOCKS a commit that stages public `packages/*/src` source with no doc surface alongside it (a genuinely internal refactor / CI / release / perf change with no behaviour change bypasses with `WEBJS_NO_DOC_GATE=1`).
67
- 3. **Convention validation.** Run `webjs check` and fix violations.
74
+ 1. **Tests, every applicable layer (not just unit).** Ship the tests that prove the change across EVERY layer it touches: **unit** (`packages/*/test/**`, `test/**`, including the counterfactual that fails when reverted), **browser** (`*/test/**/browser/*` via `npm run test:browser`, for hydration / DOM / slots / client router / custom-element upgrade), **e2e** (`test/e2e/*.test.mjs` via `WEBJS_E2E=1`, including network probes / navigation / streaming), and **smoke** (`test/examples/*/smoke/*`). A unit test is NECESSARY BUT NOT SUFFICIENT for any client-router / component / browser-facing change (the headline behaviour is a browser/e2e assertion). **Bun parity is part of the task, not an afterthought:** WebJs runs on Node 24+ AND Bun (#508), so a change to a runtime-sensitive surface (the serializer, the node:http vs `Bun.serve` listener + request path, SSR / action / CSRF dispatch, streams, `node:crypto`, the TS stripper, auth / session / cors) MUST be proven on Bun (`node scripts/run-bun-tests.js` + the touched `test/bun/*.mjs` under `bun`) AND ship an added/updated `test/bun/<feature>.mjs` cross-runtime assertion. `npm test` does NOT run browser, e2e, or Bun; run them yourself and report the result. Never report work done with failing or missing tests. See `references/testing.md`. Enforced by `.claude/hooks/require-tests-with-src.sh` (the scaffold variant WARNS unless `WEBJS_TEST_GATE=block`) and `.claude/hooks/require-bun-parity-with-runtime-src.sh` (BLOCKS a commit that stages runtime-sensitive source with no `test/bun/**` test; escape hatch `WEBJS_BUN_VERIFIED=1`).
75
+ 2. **Documentation, part of the definition of done (not optional).** A task is NOT done until EVERY doc surface its change touches is in sync: `AGENTS.md` + the skill at `.agents/skills/webjs/` (SKILL.md + references/) for new API surface, `CONVENTIONS.md` (and per-package `AGENTS.md`) for new conventions, the docs site (`docs/app/docs/<topic>`), the marketing `website/`, the scaffold templates (`packages/cli/templates/` per-agent rule files), and `README.md` for a headline capability. Updating `AGENTS.md` alone reproduces the #488 gap (docs site left stale). Invoke the `webjs-doc-sync` skill to sync every applicable surface. Enforced by `.claude/hooks/require-docs-with-src.sh`, which BLOCKS a commit that stages public `packages/*/src` source with no doc surface alongside it (a genuinely internal refactor / CI / release / perf change with no behaviour change bypasses with `WEBJS_NO_DOC_GATE=1`).
76
+ 3. **Scaffold + skill sync (when a feature changes what apps should do).** The scaffold `webjs create` emits is a gallery index home + a root layout + db wiring, a densely-commented feature gallery (`packages/cli/templates/gallery/**`, single-concept demos under `app/features/` plus the `app/examples/todo` app, shipped in every UI template) and the api backend-features showcase (`packages/cli/lib/api-gallery.js`), plus the one cross-agent skill at `packages/cli/templates/.agents/skills/webjs/` (SKILL.md + references). So when a WebJs feature is added or changed, ask: does the generator (`packages/cli/lib/{create,saas-template,api-gallery}.js`), a gallery demo (`packages/cli/templates/gallery/`), or the agent skill (`.agents/skills/webjs/SKILL.md` + its `references/`) need to move so a freshly scaffolded app and the skill teach the new reality? Verify by generating an app and running `generate + boot + webjs check` (the generators emit strings, so an escaping bug only shows in a freshly generated app). See `framework-dev.md`.
77
+ 4. **Convention validation.** Run `webjs check` and fix violations.
68
78
 
69
79
  ### Git workflow (mandatory)
70
80
 
@@ -78,48 +88,46 @@ Always work on a feature branch; commit + push freely there. The only gate is me
78
88
 
79
89
  ---
80
90
 
81
- ## Working in the webjs framework repo itself
91
+ ## Working in the WebJs framework repo itself
82
92
 
83
93
  When editing the framework monorepo (this repo, not a scaffolded app): **`packages/` is plain `.js` with JSDoc. Never add `.ts` files there** (the framework ships buildless). TypeScript is fine in `examples/`, `docs/`, `website/`.
84
94
 
85
- See `agent-docs/framework-dev.md` for monorepo commands, workspace layout, per-feature update checklists, the worktree-safe git config, the per-package auto-generated changelog flow, and the dev error overlay.
95
+ See `framework-dev.md` for monorepo commands, workspace layout, per-feature update checklists, the worktree-safe git config, the per-package auto-generated changelog flow, and the dev error overlay.
86
96
 
87
97
  ---
88
98
 
89
- ## What webjs is
99
+ ## What WebJs is
90
100
 
91
- An **AI-first, web-components-first** framework inspired by NextJs, Lit, and Rails. The component runtime API matches lit (reactive properties, the lit lifecycle hooks, ReactiveControllers, the `lit-html` directive set, `html` / `css` templates) so lit training data transfers directly, but webjs ships its own no-build implementation under `packages/core/src/`. Reactive properties are the one deliberate divergence (invariant 10): instead of lit's `@property()` decorator or a `static properties` block, webjs declares them through the declare-free base-class factory `extends WebComponent({ count: Number })`.
101
+ An **AI-first, web-components-first** framework inspired by NextJs, Lit, and Rails. The component runtime API matches lit (reactive properties, the lit lifecycle hooks, ReactiveControllers, the `lit-html` directive set, `html` / `css` templates) so lit training data transfers directly, but WebJs ships its own no-build implementation under `packages/core/src/`. Reactive properties are the one deliberate divergence (invariant 10): instead of lit's `@property()` decorator or a `static properties` block, WebJs declares them through the declare-free base-class factory `extends WebComponent({ count: Number })`.
92
102
 
93
- - **No build step.** Source files are served as native ES modules. JSDoc `.js` is default; `.ts` / `.mts` is stripped through a pluggable stripper (#508): Node 24+'s built-in `module.stripTypeScriptTypes`, or `amaro` on Bun (byte-identical, position-preserving) (invariant 10 + `agent-docs/typescript.md`). **Runs on Node 24+ or Bun** (run a Bun app with `bun --bun run dev` / `start`); the early `assertNodeVersion()` preflight enforces the Node floor and admits Bun. On Bun, `startServer` selects a native `Bun.serve` listener shell instead of the node:http one (skipping the compat bridge for ~1.9x more req/s on the listening path, at near-complete feature parity, the one node-only gap being 103 Early Hints since `Bun.serve` has no informational-response API), via a runtime-neutral seam that also sets up future `Deno.serve` / embedded adapters. Edge runtimes (no filesystem) are a separate, later target. See `agent-docs/runtime.md` for the Node vs Bun command + difference reference.
103
+ - **No build step.** Source files are served as native ES modules. JSDoc `.js` is default; `.ts` / `.mts` is stripped through a pluggable stripper (#508): Node 24+'s built-in `module.stripTypeScriptTypes`, or `amaro` on Bun (byte-identical, position-preserving) (invariant 10 + `references/typescript.md`). **Runs on Node 24+ or Bun** (run a Bun app with `bun --bun run dev` / `start`); the early `assertNodeVersion()` preflight enforces the Node floor and admits Bun. On Bun, `startServer` selects a native `Bun.serve` listener shell instead of the node:http one (skipping the compat bridge for ~1.9x more req/s on the listening path, at near-complete feature parity, the one node-only gap being 103 Early Hints since `Bun.serve` has no informational-response API), via a runtime-neutral seam that also sets up future `Deno.serve` / embedded adapters. Edge runtimes (no filesystem) are a separate, later target. See `references/runtime.md` for the Node vs Bun command + difference reference.
94
104
  - **SSR + CSR by default.** Pages are server-rendered HTML; components render light DOM by default, shadow DOM opt-in via `static shadow = true` with DSD SSR.
95
105
  - **Progressive enhancement is the default architecture.** Pages and components are SSR'd; with JS off, content reads, `<a>` navigates, `<form>` server actions submit, display-only elements render. JS is opt-in *per interactive behaviour* (`@click`, a reactive property assignment, a signal mutation). Never write a first paint that depends on hydration; never use `fetch` + JS where a `<form>` + server action would do.
96
- - **Display-only components are elided from the browser.** A component with no interactivity signal renders identical HTML with or without its JS, so the framework strips its import (and any vendor reachable only through it, importmap entry included) from the served source. Automatic, conservative, verified differentially. Disable with `"webjs": { "elide": false }` or `WEBJS_ELIDE=0`. See `agent-docs/components.md`.
106
+ - **Display-only components are elided from the browser.** A component with no interactivity signal renders identical HTML with or without its JS, so the framework strips its import (and any vendor reachable only through it, importmap entry included) from the served source. Automatic, conservative, verified differentially. Disable with `"webjs": { "elide": false }` or `WEBJS_ELIDE=0`. See `references/components.md`.
97
107
  - **Server actions with rich types.** A `*.server.{js,ts}` file with `'use server'` exports functions importable from the client (the import is rewritten to a typed RPC stub); the wire round-trips `Date` / `Map` / `Set` / `BigInt` / `Error` / typed arrays / `Blob` / `File` / `FormData` / Symbols / cycles. The source is never served to the browser (invariant 1).
98
- - **Only files reachable from a browser-bound entry are servable.** The dev server walks the static import graph from every page / layout / error / loading / not-found / component file (lazily on first request, re-derived after each `fs.watch` rebuild); that Set is the authorisation gate, so anything no client code imports returns 404.
99
- - **Sensible defaults, overridable.** Memory store in dev, Redis when configured. Built-in auth, sessions, caching, rate limiting, file storage (all pluggable). Tailwind is the default styling. See `agent-docs/built-ins.md`.
108
+ - **Only files reachable from a browser-bound entry are servable.** The dev server walks the import graph from every page / layout / error / loading / not-found / component file (lazily on first request, re-derived after each `fs.watch` rebuild); that Set is the authorisation gate, so anything no client code imports returns 404. The walk follows static `import` / `export … from` AND string-literal dynamic `import('./x.ts')` edges (#751), so a lazily-imported app module is servable (it is NOT eagerly preloaded, since a dynamic import is lazy by intent). A computed `import(expr)` cannot be resolved statically and 404s with a dev hint.
109
+ - **Sensible defaults, overridable.** Memory store in dev, Redis when configured. Built-in auth, sessions, caching, rate limiting, file storage (all pluggable). Tailwind is the default styling. See `references/built-ins.md`.
100
110
 
101
111
  ---
102
112
 
103
113
  ## Execution model (read this to avoid the RSC mental model)
104
114
 
105
- webjs has **no server/client component split.** There is no RSC render tree, no Flight protocol, no "use client" / "use server" component boundary.
115
+ WebJs has **no server/client component split.** There is no RSC render tree, no Flight protocol, no "use client" / "use server" component boundary.
106
116
 
107
117
  **Pages, layouts, and components are isomorphic modules** (same source both sides), but hydrate differently:
108
118
 
109
119
  - **Components hydrate.** The module loads in the browser, registers the custom element, the browser upgrades the SSR'd tag, and `render()` / lifecycle / `@event` / signals run client-side. Per-element, islands-style. **All interactivity lives here.**
110
120
  - **Pages and layouts do NOT hydrate.** Their function runs only on the server to produce HTML and is never re-invoked in the browser. So a page/layout cannot be interactive in its own markup (an `@click` in a page template is dropped at SSR; a signal read in a page body never re-renders). For interactivity, render a component's tag.
111
121
 
112
- A page/layout module still **loads** in the browser for its top-level side effects: registering imported components (so their tags upgrade). The client router is automatic and needs no import: it auto-enables when `@webjsdev/core` loads in the browser (the bundle every component pulls), so any page that ships a component gets client navigation for free (#620). That load is also how its imports reach the client (`import dayjs` at the top of a page fetches dayjs when the module loads, not via hydration). An inert page/layout is dead weight, which is exactly when elision drops it. A page/layout that is non-inert ONLY because it imports interactive components is **import-only** (#605): since it never hydrates, the boot emits its component modules directly and drops the page/layout module, so the browser fetches just the interactive leaves (it still ships whole when it has its own client side effect: a `client-router` import, a module-scope call, or a self-registering bare import).
122
+ A page/layout module still **loads** in the browser for its top-level side effects: registering imported components (so their tags upgrade). The client router is automatic and needs no import: it auto-enables when `@webjsdev/core` loads in the browser (the bundle every component pulls), so any page that ships a component gets client navigation for free (#620). That load is also how its imports reach the client (`import dayjs` at the top of a page fetches dayjs when the module loads, not via hydration). An inert page/layout is dead weight, which is exactly when elision drops it. A page/layout that is non-inert ONLY because it imports interactive components is **import-only** (#605): since it never hydrates, the boot emits its component modules directly and drops the page/layout module, so the browser fetches just the interactive leaves (it still ships whole when it has its own client side effect: a `client-router` import, a module-scope call, or a self-registering bare import). The verdict is path-aware (#963): client work reachable only THROUGH a shipping component (a module-scope signal bus the component imports, the invariant-5 idiom) does not pin the page, because the emitted component carries it; only a component-free path to client work ships the page whole.
113
123
 
114
- **Keep pages and layouts as pure carriers (#621), so their modules stay out of the network tab.** A page/layout drops from the boot (import-only / inert) only while its sole browser-relevant job is registering the components it imports. It starts SHIPPING its own module the moment its closure does any OTHER client work, which is invisible in `npm test` (an elision verdict, not a behaviour change) and easy to introduce by accident. Avoid, in a page/layout (and in any util it imports):
115
- - module-scope client work: a top-level call, a `new SomethingNonData()`, a browser-global (`window` / `document` / `customElements`) access, or a bare side-effect import of a non-component or `@webjsdev/core/client-router` (routing is automatic). A page-template `@event` / inline-`<script>` is fine (it is SSR output, never module client work, #623 / #634).
116
- - importing a client-effecting NON-component util. A helper that touches a client global or self-executes drags the whole page in. Put client-only behaviour inside a component; put server-only work in `.server.{js,ts}` (it never reaches the client closure); and if a util MIXES a pure helper with client-global code (the `cn.ts` shape, #619), split the client part into its own module so the pure helper does not pin every importer.
124
+ - importing a client-effecting NON-component util. A helper that touches a client global or self-executes drags the whole page in. Put client-only behaviour inside a component; put server-only work in `.server.{js,ts}` (it never reaches the client closure); and if a util MIXES a pure helper with client-global code (the `cn.ts` shape, #619), split the client part into its own module so the pure helper does not pin every importer. Note: if a page imports a client-effecting utility (like `cn.ts` which accesses client globals), the page module is marked as client-effecting and will be browser-shipped. When this happens, importing any bare server-only utility (like `*.server.ts` queries) inside that page will trigger a `no-server-import-in-browser-module` check violation. To fix this, convert the query to a `'use server'` action or fetch it through a route handler.
117
125
 
118
126
  Self-check: `page.ts` / `layout.ts` should NOT appear in the network tab or the boot `<script type="module">`; if one does, something in its closure does client work and is not a component.
119
127
 
120
- `route.{js,ts}` is the one routing file that is NOT isomorphic: a server-only HTTP handler (named `GET` / `POST` exports), the webjs equivalent of a Next route handler. It never ships to the client.
128
+ `route.{js,ts}` is the one routing file that is NOT isomorphic: a server-only HTTP handler (named `GET` / `POST` exports), the WebJs equivalent of a Next route handler. It never ships to the client.
121
129
 
122
- **`.server.{js,ts}` is the one server boundary, an RPC + source-protection mechanism, NOT an RSC component.** With `'use server'` exports are RPC-callable (the browser import becomes a stub POSTing to `/__webjs/action/<hash>/<fn>`); without it the file is a server-only utility whose browser import **throws at module load** (the DB driver, secrets, `node:*`, hashing). Consequence: **never import a no-`'use server'` util directly into a page, layout, or component** (it works at SSR but the client stub crashes on load); use it inside `'use server'` actions, `route.{js,ts}`, or `middleware`, and reach it from a page by importing a `'use server'` action (whose RPC stub loads safely client-side). This boundary, not a component annotation, is how a dependency is kept off the client (a date library used only during SSR belongs in `lib/format.server.ts`). See `agent-docs/components.md`.
130
+ **`.server.{js,ts}` is the one server boundary, an RPC + source-protection mechanism, NOT an RSC component.** With `'use server'` exports are RPC-callable (the browser import becomes a stub POSTing to `/__webjs/action/<hash>/<fn>`); without it the file is a server-only utility whose browser import **throws at module load** (the DB driver, secrets, `node:*`, hashing). Consequence: **never import a no-`'use server'` util directly into a page, layout, or component** (it works at SSR but the client stub crashes on load); use it inside `'use server'` actions, `route.{js,ts}`, or `middleware`, and reach it from a page by importing a `'use server'` action (whose RPC stub loads safely client-side). This boundary, not a component annotation, is how a dependency is kept off the client (a date library used only during SSR belongs in `lib/format.server.ts`). See `references/components.md`.
123
131
 
124
132
  ---
125
133
 
@@ -136,7 +144,11 @@ app/ ROUTING ONLY (thin adapters importing from modules/;
136
144
  layout.js root layout, wraps every page
137
145
  page.js /
138
146
  error.js nested error boundary
139
- not-found.js 404 page (only at app/ root; nested <segment>/not-found.js, nearest wins)
147
+ not-found.js 404 page (root or nested <segment>/not-found.js, nearest wins)
148
+ forbidden.js 403 boundary for forbidden() (nested, nearest wins)
149
+ unauthorized.js 401 boundary for unauthorized() (nested, nearest wins)
150
+ global-error.js root-only app-wide error boundary (renders its own <html>)
151
+ global-not-found.js root-only 404 for an unmatched-anywhere URL
140
152
  <segment>/page.js /<segment>
141
153
  [param]/page.js dynamic route (`params.param`)
142
154
  [...rest]/ [[...rest]]/ catch-all / optional catch-all
@@ -148,11 +160,13 @@ app/ ROUTING ONLY (thin adapters importing from modules/;
148
160
  middleware.js root middleware (every request)
149
161
  readiness.js optional /__webjs/ready check (return false/throw = 503)
150
162
  env.js optional boot-time env validation (schema or validator fn; fails fast)
163
+ instrumentation.js optional boot-time hook (register(); wire APM via setOnError, #848)
164
+ instrumentation-client.js optional client boot hook (runs first, before app modules, #848)
151
165
  sitemap.js robots.js manifest.js icon.js opengraph-image.js twitter-image.js apple-icon.js metadata routes
152
166
  lib/ app-wide code (lib/*.server.js infra, lib/utils/ browser-safe helpers)
153
167
  modules/<feature>/ feature-scoped: actions/ (mutations), queries/ (reads), components/, utils/, types.js
154
168
  components/*.js SHARED presentational primitives
155
- public/* static assets, served at /<name>
169
+ public/* static assets, served at /public/<name> (favicon, sw.js, offline.html serve at root)
156
170
  db/*.server.{js,ts} data layer (Drizzle: schema, columns, connection)
157
171
  ```
158
172
 
@@ -167,7 +181,7 @@ from '#components/ui/button.ts'`, `#lib/...`, `#modules/...`. It is Node's nativ
167
181
  "./*"`, so any top-level folder is aliased with no config change), resolved at
168
182
  runtime by Node 24+ AND Bun with **no build step and no tsconfig `paths`**. The
169
183
  sigil is `#` (not `@`) and there is **no slash after it** (`#lib/...`, not
170
- `#/lib/...`): a `#/`-prefixed key does not resolve on Bun. The webjs server
184
+ `#/lib/...`): a `#/`-prefixed key does not resolve on Bun. The WebJs server
171
185
  expands the same map for the import graph / auth gate / elision / browser
172
186
  importmap, so a `#`-aliased `.server.ts` still trips the server-only boundary.
173
187
  A same-directory import stays relative (`./sibling.ts`); only deep relatives
@@ -195,14 +209,15 @@ The bare `@webjsdev/core` specifier resolves to a BROWSER bundle dropping server
195
209
  | `register(tag, C)` | Tag binding. Auto-called by `Class.register('tag')`. |
196
210
  | `render(v, el)` | Client-side render into a DOM element. |
197
211
  | `renderToString` | Server-side async render to HTML with DSD (from `/server`). |
198
- | `notFound()` / `redirect(url[, status])` | Throw to return 404, or a redirect. No-status default is convention-picked at the catching site: 302 for a GET page-render gate, 307 (method-preserving) for a server-action redirect. Override with `redirect(url, 308)` or `redirect(url, { status })`. |
212
+ | `notFound()` / `redirect(url[, status])` | Throw to return 404, or a redirect. No-status default is convention-picked at the catching site: 302 for a GET page-render gate, 307 (method-preserving) for a server-action redirect. Override with `redirect(url, 308)` or `redirect(url, { status })`. **NEVER** throw `redirect()` inside API route handlers (`route.ts`), as they must return a standard `Response.redirect(url, 303)` response object instead. |
213
+ | `forbidden()` / `unauthorized()` | Throw to return 403 / 401 (#848, Next 15/16 parity). Renders the nearest `forbidden.{js,ts}` / `unauthorized.{js,ts}` boundary (innermost wins), else a default page, from a page/layout render OR a page `action` (the no-JS write path). `forbidden()` for an authenticated user lacking permission, `unauthorized()` for a request that is not authenticated. Same control-flow-throw model as `notFound()`: NOT for a `route.ts` handler, and inside a `'use server'` RPC action return an `ActionResult` for an auth failure instead (a raw throw there is a generic 500, like `notFound()`/`redirect()`). |
199
214
  | `Suspense({fallback, children})` | Page/region-level streaming boundary (a value in a hole). `repeat` keyed-list directive is also re-exported. |
200
215
  | `<webjs-suspense .fallback=${html\`…\`}>` | Component-level streaming boundary element (#471): wraps one or more components, flushes `.fallback` on the first byte, streams the resolved content in (concurrently across boundaries, progressively on soft nav). The renderer-recognized opt-in for SLOW async-render data. |
201
216
  | `connectWS(url, handlers)` / `richFetch<T>` | Client WebSocket (auto-reconnect, queued sends); content-negotiated rich-type fetch. |
202
217
  | `navigate(url, opts?)` / `revalidate(url?)` | Programmatic client-router nav; evict the BROWSER snapshot cache. |
203
- | `optimistic(signal, value, action)` | Set `signal` immediately, run `action`, roll back on error or `{ success: false }`. |
204
- | `renderStream(payload)` / `WebjsFrame` | `<webjs-stream>` element-level updates (#248); `<webjs-frame>` partial-swap regions (#253). See `agent-docs/advanced.md`. |
205
- | `Metadata` / `PageProps<R>` / `LayoutProps<R>` / `RouteHandlerContext<R>` / `WebjsConfig` (type-only) | Types for metadata, page/layout/route args (`R` narrows `params` against the `webjs types` route union), and the `webjs` config block. See `agent-docs/metadata.md` + `agent-docs/configuration.md`. |
218
+ | `optimistic(signal, value, action)` / `optimistic(host, { source, update })` | Imperative: set `signal` immediately, run `action`, roll back on error. Declarative (preferred): queue optimistic updates with auto-release via `.add(payload, promise?)`. See `references/client-router-and-streaming.md`. |
219
+ | `renderStream(payload)` / `WebjsFrame` | `<webjs-stream>` element-level updates (#248); `<webjs-frame>` partial-swap regions (#253). See `references/client-router-and-streaming.md`. |
220
+ | `Metadata` / `PageProps<R>` / `LayoutProps<R>` / `RouteHandlerContext<R>` / `WebjsConfig` (type-only) | Types for metadata, page/layout/route args (`R` narrows `params` against the `webjs types` route union), and the `webjs` config block. See `references/routing-and-pages.md` + `references/built-ins.md`. |
206
221
 
207
222
  ### Directives, from `@webjsdev/core/directives`
208
223
 
@@ -237,13 +252,13 @@ MyThing.register('my-thing');
237
252
 
238
253
  **Signals are the default state primitive.** Import `signal` / `computed` from `@webjsdev/core`, read with `signal.get()` inside `render()`, and the built-in `SignalWatcher` re-renders on change. Module-scope signals share state across components and survive navigations; instance signals (constructor) are component-local.
239
254
 
240
- **Reactive properties ride HTML attributes, reflect to them, or arrive via SSR hydration.** They are declared ONLY by passing the property shape into the base-class factory `WebComponent({ ... })`. There is no `static properties` block and no `declare` line. The bare form takes a type constructor (`count: Number`, `label: String`, `open: Boolean`); the `prop()` helper carries options (`count: prop(Number, { reflect: true })`, `name: prop(String, { attribute: 'show-close-button' })`, internal state `prop({ state: true })` with no type), and narrows the TS type (`student: prop<Student>(Object)`, `mode: prop<'a' | 'b'>(String, { reflect: true })`). The types flow automatically to `this.<prop>`. Set defaults in the constructor after `super()` (a declarative `default` option also exists for lit-parity, a function default running per instance for a fresh object / array, but the constructor is the recommended way). NEVER use a class-field initializer (e.g., `count = 0`), which clobbers the reactive accessor (caught by `reactive-props-no-class-field`). Declaring your own `static properties` THROWS at runtime and is flagged by `no-static-properties`. Declare an array-typed prop with the `Array` constructor, not `Object` (`items: prop<Tag[]>(Array)`): the two share one JSON converter so neither crashes, but `Array` states the prop's shape and `array-prop-uses-array-type` flags the `Object` form. Property options: `type` (default `String`), `reflect`, `state`, `hasChanged`, `converter`, `default`, `attribute`.
255
+ **Reactive properties ride HTML attributes, reflect to them, or arrive via SSR hydration.** They are declared ONLY by passing the property shape into the base-class factory `WebComponent({ ... })`. There is no `static properties` block and no `declare` line. The bare form takes a type constructor (`count: Number`, `label: String`, `open: Boolean`); the `prop()` helper carries options (`count: prop(Number, { reflect: true })`, `name: prop(String, { attribute: 'show-close-button' })`, internal state `prop({ state: true })` with no type), and narrows the TS type (`student: prop<Student>(Object)`, `mode: prop<'a' | 'b'>(String, { reflect: true })`). The types flow automatically to `this.<prop>`. Set defaults in the constructor after `super()` (a declarative `default` option also exists for lit-parity, a function default running per instance for a fresh object / array, but the constructor is the recommended way). NEVER use a class-field declaration OR initializer (e.g., `count = 0` or `todos!: Todo[]`), which compiles to define an own property on the instance and clobbers the reactive accessor (caught by `reactive-props-no-class-field`). Declaring your own `static properties` THROWS at runtime and is flagged by `no-static-properties`. Declare an array-typed prop with the `Array` constructor, not `Object` (`items: prop<Tag[]>(Array)`): the two share one JSON converter so neither crashes, but `Array` states the prop's shape and `array-prop-uses-array-type` flags the `Object` form. Property options: `type` (default `String`), `reflect`, `state`, `hasChanged`, `converter`, `default`, `attribute`.
241
256
 
242
257
  **Lifecycle (lit-aligned), in order:** `shouldUpdate`, `willUpdate`, controllers' `hostUpdate()`, `update` (calls `render()` + commits), controllers' `hostUpdated()`, `firstUpdated`, `updated`, `updateComplete`, each receiving a `changedProperties` Map. **SSR runs only the constructor, attribute application, the pre-render hooks (`willUpdate` / `hostUpdate`), `reflect: true` reflection, and `render()`; it does NOT call `connectedCallback`, `firstUpdated`, `updated`, or any browser-only hook.** So defaults for first paint go in the constructor; browser-only data (localStorage, viewport, `navigator.*`) goes in `connectedCallback` writing a signal; server-known data arrives via the page function. Never ship a placeholder first paint that fetches in `connectedCallback`. A browser-only global in the constructor/`render()` throws at SSR (flagged by `no-browser-globals-in-render`; attribute methods and `closest()` are shimmed).
243
258
 
244
- **Async render (`async render()`), bare-await data fetch (#469).** A component may write `async render() { const u = await getUser(this.id); return html\`<h3>${u.name}</h3>\`; }`. Writing `await` makes the function async by JS rule, and every render path awaits a promise-returning `render()` automatically (no flag). This co-locates the fetch in the leaf component (no prop-drilling). The model is decoupled into three separate concerns. (1) **SSR always blocks**, so the resolved DATA is in the first paint with no fallback markup (PE-safe, JS-off reads it). (2) **The client re-fetch default is stale-while-revalidate**: when a prop / dependency change re-runs `async render()`, the current content stays until the new render resolves (no blank, no flash). (3) **`renderFallback()` is the OPTIONAL re-fetch loading UI**, a prop-aware method shown ONLY during a client re-fetch, NEVER on the first paint, and it does NOT trigger SSR streaming. **Errors are isolated per component by default** (no user code): a thrown `await getData()` renders a component-scoped error state while siblings render, and `renderError()` optionally customizes it (dev surfaces the message, prod stays silent). `getData()` is already isomorphic (a `'use server'` action is the real function during SSR and an RPC stub on the client), so the same line works both sides. Use `async render()` for request-time-known SERVER data that should be in the first paint; keep `Task` / signals for genuinely client-only data (a `Task` shows its pending state at SSR, losing first-paint data). A **bare** async-render component (an `async render()` with no other client signal, light DOM) is **elided** like any display-only component (#474): its SSR'd HTML is the complete output, so the framework drops the module AND the redundant on-hydration re-fetch. It SHIPS only when it also carries an independent signal (an `@event`, a non-`state` reactive prop, a signal / reactive import, a lifecycle hook including `renderFallback()`, a `<slot>`, `static shadow = true`, `static refresh = true`, cross-module observation, or a transitively-reachable interactive child). Two carve-outs always ship: `static shadow = true` (Declarative Shadow DOM attaches only during HTML parsing, so a streamed or soft-navigated shadow component needs its module to re-run `attachShadow`) and `static refresh = true` (the explicit opt-in keeping the stale-while-revalidate on-load re-fetch that eliding drops, moot for request-stable data). **For SLOW data where blocking the first byte hurts, wrap the region in `<webjs-suspense .fallback=${html\`…\`}>` to STREAM it** (the fallback flushes on the first byte, the data streams in; multiple boundaries fetch concurrently). This is the only way to show a first-paint fallback, a deliberate choice for slow regions, and it streams progressively on soft navigation too. A throwing component inside a boundary is isolated (renders its error state, siblings stream). **The on-hydration re-fetch is itself eliminated by SSR action seeding (#472):** each `'use server'` action result invoked during a (non-streamed) SSR render is serialized into the page, and the generated RPC stub reads that seed on its first client call, so a shipping async component does NOT re-issue the RPC on hydration (a later refetch / arg-change still goes to the network). Keyed by action-hash + fn + serialized args, consume-once, fail-open (a miss degrades to a normal RPC, never wrong data). Captured via a transparent server-side `'use server'` facade (no source transform, no build step; the browser source tab and on-disk files are unchanged), default on, opt out with `"webjs": { "seed": false }` or `WEBJS_SEED=0`.
259
+ **Async render (`async render()`), bare-await data fetch (#469).** A component may write `async render() { const u = await getUser(this.id); return html\`<h3>${u.name}</h3>\`; }`. Writing `await` makes the function async by JS rule, and every render path awaits a promise-returning `render()` automatically (no flag). This co-locates the fetch in the leaf component (no prop-drilling). The model is decoupled into three separate concerns. (1) **SSR always blocks**, so the resolved DATA is in the first paint with no fallback markup (PE-safe, JS-off reads it). (2) **The client re-fetch default is stale-while-revalidate**: when a prop / dependency change re-runs `async render()`, the current content stays until the new render resolves (no blank, no flash). (3) **`renderFallback()` is the OPTIONAL re-fetch loading UI**, a prop-aware method shown ONLY during a client re-fetch, NEVER on the first paint, and it does NOT trigger SSR streaming. **Errors are isolated per component by default** (no user code): a thrown `await getData()` renders a component-scoped error state while siblings render, and `renderError()` optionally customizes it (dev surfaces the message, prod stays silent). `getData()` is already isomorphic (a `'use server'` action is the real function during SSR and an RPC stub on the client), so the same line works both sides. Use `async render()` for request-time-known SERVER data that should be in the first paint; keep `Task` / signals for genuinely client-only data (a `Task` shows its pending state at SSR, losing first-paint data). A **bare** async-render component (an `async render()` with no other client signal, light DOM) is **elided** like any display-only component (#474): its SSR'd HTML is the complete output, so the framework drops the module AND the redundant on-hydration re-fetch. It SHIPS only when it also carries an independent signal (an `@event`, a non-`state` reactive prop, a signal / reactive import, a lifecycle hook including `renderFallback()`, a `<slot>`, `static shadow = true`, `static interactive = true`, cross-module observation, or a transitively-reachable interactive child). Two carve-outs always ship: `static shadow = true` (Declarative Shadow DOM attaches only during HTML parsing, so a streamed or soft-navigated shadow component needs its module to re-run `attachShadow`) and `static interactive = true` (the explicit author override that forces a ship when the analyser cannot see a component's interactivity statically, for example a dynamically-computed tag string or a `:defined` rule in an external stylesheet outside the module graph). **For SLOW data where blocking the first byte hurts, wrap the region in `<webjs-suspense .fallback=${html\`…\`}>` to STREAM it** (the fallback flushes on the first byte, the data streams in; multiple boundaries fetch concurrently). This is the only way to show a first-paint fallback, a deliberate choice for slow regions, and it streams progressively on soft navigation too. A throwing component inside a boundary is isolated (renders its error state, siblings stream). **The on-hydration re-fetch is itself eliminated by SSR action seeding (#472):** each `'use server'` action result invoked during a (non-streamed) SSR render is serialized into the page, and the generated RPC stub reads that seed on its first client call, so a shipping async component does NOT re-issue the RPC on hydration (a later refetch / arg-change still goes to the network). Keyed by action-hash + fn + serialized args, consume-once, fail-open (a miss degrades to a normal RPC, never wrong data). Captured via a transparent server-side `'use server'` facade (no source transform, no build step; the browser source tab and on-disk files are unchanged), default on, opt out with `"webjs": { "seed": false }` or `WEBJS_SEED=0`.
245
260
 
246
- **Light DOM (default) vs Shadow DOM.** Light DOM applies global CSS and Tailwind directly (default; for Tailwind/global CSS + simple composition). Shadow DOM (`static shadow = true`) is for `static styles` scoped CSS and third-party isolation; `<slot>` works in either. A light-DOM component authoring custom CSS MUST prefix every class selector with its tag name (invariant 7); prefer Tailwind. Install the `webjs` VSCode extension (`packages/editors/vscode`, VS Marketplace + Open VSX; also covers Cursor / Antigravity / Windsurf) or `webjs.nvim` (`packages/editors/nvim`, via lazy.nvim) for template highlighting + editor intelligence with no Lit plugin, or add the standalone `@webjsdev/intellisense` to `tsconfig.json` `plugins` manually (JetBrains). Full deep-dive in `agent-docs/components.md` + `agent-docs/lit-muscle-memory-gotchas.md`.
261
+ **Light DOM (default) vs Shadow DOM.** Light DOM applies global CSS and Tailwind directly (default; for Tailwind/global CSS + simple composition). Shadow DOM (`static shadow = true`) is for `static styles` scoped CSS and third-party isolation; `<slot>` works in either. A light-DOM component authoring custom CSS MUST prefix every class selector with its tag name (invariant 7); prefer Tailwind. **Light-DOM component hosts default to `display: block`**: a custom element is `display: inline` in plain CSS, so the framework marks every LIGHT-DOM host `data-wj-host` and injects one head rule in a low-priority cascade layer, `@layer webjs-host { :where([data-wj-host]) { display: block } }`, so a container component does not collapse; the layer keeps it overridable by any author style INCLUDING Tailwind utilities (`class="flex"`/`grid`/`hidden` win, because their layer is ordered after `webjs-host`), a `[hidden]` carve-out keeps `?hidden` working, and an inline light component opts out with `my-tag { display: inline }`. **Shadow-DOM hosts are NOT marked** (a document rule would override the shadow tree's `:host`), so a shadow component sets its host display via `:host { display: block }` in `static styles` (respected because the host is unmarked; set it for a shadow block container). See the even-grid / no-reflow layout recipes in `references/styling.md`. **Never interpolate into a component's `<style>` or `<script>` body** (`html\`<style>${css}</style>\``): the server emits it but the client drops the raw-text hole, so it paints at SSR then wipes to empty on hydrate. Use `static styles` or Tailwind instead (flagged by `no-interpolation-in-raw-text-element`). A page/layout, which never hydrates, may interpolate a `css` result into `<style>`. Install the `webjs` VSCode extension (`packages/editors/vscode`, VS Marketplace + Open VSX; also covers Cursor / Antigravity / Windsurf) or `webjs.nvim` (`packages/editors/nvim`, via lazy.nvim) for template highlighting + editor intelligence with no Lit plugin, or add the standalone `@webjsdev/intellisense` to `tsconfig.json` `plugins` manually (JetBrains). Full deep-dive in `references/components.md` + `references/muscle-memory-gotchas.md`.
247
262
 
248
263
  ---
249
264
 
@@ -251,10 +266,10 @@ MyThing.register('my-thing');
251
266
 
252
267
  ### Pages (`app/**/page.{js,ts}`)
253
268
 
254
- - Default export is a possibly-async function receiving `{ params, searchParams, url, actionData }`. Runs **only on the server**. Throw `notFound()` / `redirect(url)` to short-circuit.
255
- - Named exports: `metadata` (static), `generateMetadata(ctx)` (async, takes precedence). Type both with `Metadata`. See `agent-docs/metadata.md`.
256
- - Optional `export const revalidate` (seconds) opts into the server HTML response cache (#241). SAFETY: only on a page identical for every visitor (no `cookies()` / session / per-user data); keyed by URL only. See `agent-docs/built-ins.md`.
257
- - Optional `export const action`: a fn `({ request, params, searchParams, url, formData })` handling a non-GET submission to the page's own URL (the no-JS write-path, #244), returning an `ActionResult`. Success is a `303` (PRG); failure re-renders the SAME page at `422` with the result on `ctx.actionData`. See `agent-docs/recipes.md`.
269
+ - Default export is a possibly-async function receiving `{ params, searchParams, url, actionData }`. Runs **only on the server**. Throw `notFound()` / `redirect(url)` to short-circuit. `params` / `searchParams` are awaitable AND synchronously readable (`params.id` and `await params` both work, Next 15/16 parity, #848).
270
+ - Named exports: `metadata` (static), `generateMetadata(ctx)` (async, takes precedence). Type both with `Metadata`. See `references/routing-and-pages.md`.
271
+ - Optional `export const revalidate` (seconds) opts into the server HTML response cache (#241). SAFETY: only on a page identical for every visitor (no `cookies()` / session / per-user data); keyed by URL only. See `references/built-ins.md`.
272
+ - Optional `export const action`: a fn `({ request, params, searchParams, url, formData })` handling a non-GET submission to the page's own URL (the no-JS write-path, #244), returning an `ActionResult`. Success is a `303` (PRG); failure re-renders the SAME page at `422` with the result on `ctx.actionData`. See `references/data-and-actions.md`.
258
273
  - Page modules also load on the client so imported components register; keep top-level imports browser-safe. **Server-only code goes only in `.server.{js,ts}`, `route.ts`, or `middleware.ts`. Never in pages, layouts, or components.**
259
274
 
260
275
  ### Layouts (`app/**/layout.{js,ts}`)
@@ -263,11 +278,11 @@ Default export receives `{ children, params, searchParams, url }`, must embed `c
263
278
 
264
279
  ### Error / loading / metadata routes
265
280
 
266
- `error.{js,ts}` default-exports `({ error, ...ctx }) => TemplateResult` (catches sibling-page / deeper render errors, innermost wins, prod sends only `error.message`). `loading.{js,ts}` wraps the sibling page in `Suspense` with an immediately-flushed fallback. Metadata routes (`sitemap`, `robots`, `manifest`, `icon`, `apple-icon`, `opengraph-image`, `twitter-image`) live at app root or static segments only and default-export a possibly-async function; `sitemap(entries)` / `sitemapIndex(sitemaps)` from `@webjsdev/server` serialize spec-valid XML.
281
+ `error.{js,ts}` default-exports `({ error, ...ctx }) => TemplateResult` (catches sibling-page / deeper render errors, innermost wins, prod sends only `error.message`). `loading.{js,ts}` wraps the sibling page in `Suspense` with an immediately-flushed fallback. `forbidden.{js,ts}` / `unauthorized.{js,ts}` render the nearest 403 / 401 boundary for a thrown `forbidden()` / `unauthorized()` (#848). Two **root-only** boundaries (`app/` root exactly): `global-error.{js,ts}` is the app-wide catch-all tried after the nested `error` boundaries are exhausted, and it renders its **own** `<!doctype><html><body>` document (returned verbatim, since a root-layout failure is when it fires). Because it is returned verbatim (no framework `<head>` splice), it ships **no importmap or boot script**, so keep it **static HTML with no components/hydration** (a last-resort page must not depend on the module system that may have just failed); under an opt-in CSP, an inline `<style>`/`<script>` in it must carry the nonce via `cspNonce()`. `global-not-found.{js,ts}` renders for an unmatched-anywhere URL when no `not-found` matches. `not-found` is nearest-wins from the throwing page's chain (#848 fixed the prior root-only behavior). Metadata routes (`sitemap`, `robots`, `manifest`, `icon`, `apple-icon`, `opengraph-image`, `twitter-image`) live at app root or static segments only and default-export a possibly-async function; `sitemap(entries)` / `sitemapIndex(sitemaps)` from `@webjsdev/server` serialize spec-valid XML.
267
282
 
268
283
  ### Route handlers (`app/**/route.{js,ts}`)
269
284
 
270
- Named async exports per method (`GET`/`POST`/`PUT`/`PATCH`/`DELETE`), each `(Request, { params }) => Response | value` (value auto-JSONs). A folder cannot have both `page.js` and `route.js`. Export `WS(ws, req, { params })` from the same file for a WebSocket endpoint (in dev re-imported per connection; shared state on `globalThis`). See `agent-docs/advanced.md`.
285
+ Named async exports per method (`GET`/`POST`/`PUT`/`PATCH`/`DELETE`), each `(Request, { params }) => Response | value` (value auto-JSONs). A folder cannot have both `page.js` and `route.js`. Export `WS(ws, req, { params })` from the same file for a WebSocket endpoint (in dev re-imported per connection; shared state on `globalThis`). See `references/client-router-and-streaming.md`.
271
286
 
272
287
  ### Middleware (`middleware.{js,ts}`)
273
288
 
@@ -286,15 +301,15 @@ Optional app-root file default-exporting a **schema object** (env-var names to a
286
301
  | Plain `.ts` | yes | **Lint violation** (`use-server-needs-extension`). Rename to add `.server.`. |
287
302
  | Plain `.ts` | no | Browser-safe; standard. |
288
303
 
289
- The server-only-utility row (`.server.ts`, no `'use server'`) is a runtime trap: its browser stub throws at module load, so a page / layout / component that ends up SHIPPING to the browser and transitively imports one crashes the moment the module loads, while `webjs typecheck` and the rest of `webjs check` pass. `webjs check`'s `no-server-import-in-browser-module` rule catches this statically by reusing the build's elision verdict (it only flags modules that genuinely ship; a display-only page the framework elides is fine, because the framework strips its server import). A `'use server'` action is exempt: its browser stub is a working RPC, which is the intended way to call the server from a shipping module.
304
+ The server-only-utility row (`.server.ts`, no `'use server'`) is a runtime trap: its browser stub throws at module load, so a page / layout / component that ends up SHIPPING to the browser and transitively imports one crashes the moment the module loads, while `webjs typecheck` and the rest of `webjs check` pass. `webjs check`'s `no-server-import-in-browser-module` rule catches this statically by reusing the build's elision verdict (it only flags modules that genuinely ship; a display-only page the framework elides is fine, because the framework strips its server import). A `'use server'` action is exempt: its browser stub is a working RPC, which is the intended way to call the server from a shipping module. A TYPE-ONLY `import type { Row } from './x.server.ts'` is exempt too, because the TypeScript stripper erases it before it can reach the browser, so sharing a derived row type from a `.server.ts` into a shipping component is safe and is not flagged (#805).
290
305
 
291
- Server actions export named async functions whose args + returns round-trip through the serializer. **Importing from a client component IS the API** (rewritten to an RPC stub; never hand-write `fetch()`). **REST over HTTP is a `route.ts`** that imports and calls the action (optionally via the `route(action, opts?)` adapter from `@webjsdev/server`, which merges query + route params + JSON body into one input object and JSON-responds). **Input validation (#245)** is declared via the `export const validate` config export (read on the RPC boundary); a `route()` endpoint passes the same validator as its `{ validate }` option. The framework only CALLS the validator (ships no validation library) and reads its return (`{ success: true, data? }` runs the action, `{ success: false, fieldErrors }` returns a `422` without running the body, a THROW is a sanitized error, any other value is transformed input). The validator stays server-side and receives the action's first argument. Full reference in `agent-docs/recipes.md`.
306
+ Server actions export named async functions whose args + returns round-trip through the serializer. **Importing from a client component IS the API** (rewritten to an RPC stub; never hand-write `fetch()`). **REST over HTTP is a `route.ts`** that imports and calls the action (optionally via the `route(action, opts?)` adapter from `@webjsdev/server`, which merges query + route params + JSON body into one input object and JSON-responds). **Input validation (#245)** is declared via the `export const validate` config export (read on the RPC boundary); a `route()` endpoint passes the same validator as its `{ validate }` option. The framework only CALLS the validator (ships no validation library) and reads its return (`{ success: true, data? }` runs the action, `{ success: false, fieldErrors }` returns a `422` without running the body, a THROW is a sanitized error, any other value is transformed input). The validator stays server-side and receives the action's first argument. Full reference in `references/data-and-actions.md`.
292
307
 
293
- **HTTP-verb actions via config exports (#488).** A `'use server'` action declares its HTTP semantics through RESERVED sibling exports the framework reads statically, the same way a page declares `export const revalidate`: `export const method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'` (absent = POST, so existing actions are unchanged), `export const cache = 60` (seconds, or `{ maxAge, swr, public }`, default `private`; **`public: true` SHARES the response across users keyed only by URL + args, so use it ONLY for data identical for every visitor, never for a session / per-user read, the same safety rule as a page's `export const revalidate`**), `export const tags = (id) => [\`user:${id}\`]` (a GET's cache tags), `export const invalidates = (id) => [...]` (a mutation's tags to evict), `export const validate = (input) => ...` (the boundary validator), and `export const middleware = [mw1, mw2]` (#490: per-action middleware, each `async (ctx, next) => result`, run around the action on BOTH the RPC and `route.ts` (including the `route()` adapter) boundaries; a middleware short-circuits by returning an `ActionResult` instead of calling `next()`, and accumulates context the action reads via `actionContext()` from `@webjsdev/server`, no signature change). The function stays a plain `export async function`; **one function per file** (a configured file with more than one callable function is a `webjs check` error). The call site never changes (`await getUser(7)`); the verb only changes the transport: a **GET** rides args in the URL (`?a=`, with a POST fallback over a 4KB cap), is CSRF-exempt, carries `Cache-Control` + a weak ETag (answering `If-None-Match` with a 304) and `X-Webjs-Tags`, and reads the SSR seed (#472) first; a **mutation** (POST/PUT/PATCH/DELETE) sends the rich body (DELETE rides the URL), is CSRF-protected, and on completion (the action did not throw) evicts its `invalidates` tags from the server `cache()` (`revalidateTags`) and reports them via `X-Webjs-Invalidate` so the client browser-cache coordinator revalidates a later read. A mismatched request method is a `405` + `Allow`. Why webjs needs this and Next does not: webjs has no RSC server/client split, so reads and writes both flow through the one action mechanism (Next's reads are Server Component fetches, so its actions stay POST-only). `validate` is a BOUNDARY concern (the RPC endpoint and a `route.ts`), not a direct server-to-server call. A public REST endpoint is a `route.ts` that imports and calls the action (optionally via the `route()` adapter). **Cancellation (#492):** an action reads the request's `AbortSignal` via `actionSignal()` (from `@webjsdev/server`) to stop work on a client disconnect / abort (a never-aborting signal outside an action keeps the line safe server-to-server); on the client, a superseded `async render()` automatically ABORTS the previous render's in-flight action fetch (not just drops it), via a per-render `AbortController` the stub binds each fetch to. **Streaming results (#489):** an action that RETURNS a `ReadableStream` / async iterable / async generator (any verb) streams its chunks over the single RPC response instead of buffering; the call site does `for await (const chunk of await streamTokens(8))` and each rich-serialized chunk arrives as it is yielded (back-pressure respected, the source generator cancelled on a client disconnect / superseded render). Detection is purely on the return value (no config export); a streamed result is never cached / ETagged / seeded (a mutation still emits `X-Webjs-Invalidate`). A mid-stream throw surfaces as an error from the iterable (the HTTP status is already 200), the author message in prod.
308
+ **HTTP-verb actions via config exports (#488).** A `'use server'` action declares its HTTP semantics through RESERVED sibling exports the framework reads statically, the same way a page declares `export const revalidate`: `export const method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'` (absent = POST, so existing actions are unchanged), `export const cache = 60` (seconds, or `{ maxAge, swr, public }`, default `private`; **`public: true` SHARES the response across users keyed only by URL + args, so use it ONLY for data identical for every visitor, never for a session / per-user read, the same safety rule as a page's `export const revalidate`**), `export const tags = (id) => [\`user:${id}\`]` (a GET's cache tags), `export const invalidates = (id) => [...]` (a mutation's tags to evict), `export const validate = (input) => ...` (the boundary validator), and `export const middleware = [mw1, mw2]` (#490: per-action middleware, each `async (ctx, next) => result`; a middleware short-circuits by returning an `ActionResult` instead of calling `next()`, and accumulates context the action reads via `actionContext()` from `@webjsdev/server`, no signature change). The declared middleware runs automatically on the RPC boundary. On the `route.ts` boundary it applies when the `route()` adapter is given the action's MODULE NAMESPACE (`import * as postActions from '...'; export const POST = route(postActions)`), which reads the declared `middleware` + `validate`; the bare-function form (importing just the function, `import { createPost }` then `route(createPost)`) runs only what you pass in `route(fn, { middleware })`, because a function reference cannot reach its sibling config exports (#876). The function stays a plain `export async function`; **one function per file** (a configured file with more than one callable function is a `webjs check` error). The call site never changes (`await getUser(7)`); the verb only changes the transport: a **GET** rides args in the URL (`?a=`, with a POST fallback over a 4KB cap), is CSRF-exempt, carries `Cache-Control` + a weak ETag (answering `If-None-Match` with a 304) and `X-Webjs-Tags`, and reads the SSR seed (#472) first; a **mutation** (POST/PUT/PATCH/DELETE) sends the rich body (DELETE rides the URL), is CSRF-protected, and on completion (the action did not throw) evicts its `invalidates` tags from the server `cache()` (`revalidateTags`) and reports them via `X-Webjs-Invalidate` so the client browser-cache coordinator revalidates a later read. A mismatched request method is a `405` + `Allow`. Why WebJs needs this and Next does not: WebJs has no RSC server/client split, so reads and writes both flow through the one action mechanism (Next's reads are Server Component fetches, so its actions stay POST-only). `validate` is a BOUNDARY concern (the RPC endpoint and a `route.ts`), not a direct server-to-server call. A public REST endpoint is a `route.ts` that imports and calls the action (optionally via the `route()` adapter). **Cancellation (#492):** an action reads the request's `AbortSignal` via `actionSignal()` (from `@webjsdev/server`) to stop work on a client disconnect / abort (a never-aborting signal outside an action keeps the line safe server-to-server); on the client, a superseded `async render()` automatically ABORTS the previous render's in-flight action fetch (not just drops it), via a per-render `AbortController` the stub binds each fetch to. **Streaming results (#489):** an action that RETURNS a `ReadableStream` / async iterable / async generator (any verb) streams its chunks over the single RPC response instead of buffering; the call site does `for await (const chunk of await streamTokens(8))` and each rich-serialized chunk arrives as it is yielded (back-pressure respected, the source generator cancelled on a client disconnect / superseded render). Detection is purely on the return value (no config export); a streamed result is never cached / ETagged / seeded (a mutation still emits `X-Webjs-Invalidate`). A mid-stream throw surfaces as an error from the iterable (the HTTP status is already 200), the author message in prod.
294
309
 
295
310
  ### RPC + REST endpoint security
296
311
 
297
- Action RPC CSRF is an **Origin / `Sec-Fetch-Site` check** (the Remix 3 / Go 1.25 model), NOT a token cookie: a state-changing verb passes only when `Sec-Fetch-Site` is `same-origin` / `none`, or (older browsers, no `Sec-Fetch-Site`) the `Origin` host matches the request host, or the source is in `webjs.allowedOrigins`; otherwise 403. **No `Set-Cookie` rides the SSR HTML**, which is why a page that opts into a public `Cache-Control` (via `metadata.cacheControl`, e.g. on a root layout for a whole visitor-identical app) is CDN-edge-cacheable. A safe GET action is CSRF-exempt. Prod errors are sanitized to `message` only. A `route.ts` REST endpoint (hand-written or via the `route()` adapter) is NOT covered by this check: authenticate every mutating endpoint, use `validate`, log without secrets, rate-limit. For CORS use the `cors()` middleware from `@webjsdev/server`; **`credentials: true` REQUIRES an explicit origin allowlist, never `'*'`.** See `agent-docs/advanced.md`.
312
+ Action RPC CSRF is an **Origin / `Sec-Fetch-Site` check** (the Remix 3 / Go 1.25 model), NOT a token cookie: a state-changing verb passes only when `Sec-Fetch-Site` is `same-origin` / `none`, or (older browsers, no `Sec-Fetch-Site`) the `Origin` host matches the request host, or the source is in `webjs.allowedOrigins`; otherwise 403. **No `Set-Cookie` rides the SSR HTML**, which is why a page that opts into a public `Cache-Control` (via `metadata.cacheControl`, e.g. on a root layout for a whole visitor-identical app) is CDN-edge-cacheable. A safe GET action is CSRF-exempt. **Prod action errors are sanitized to a GENERIC message plus a short `digest`** (#749): a thrown action never returns its raw message to the client (a DB-driver message, an internal IP, an fs path are not author-controlled), the full error is logged server-side keyed by that digest, and the streaming error frame is sanitized the same way; `redirect()` / `notFound()` control-flow throws pass through, and a user-facing message belongs on the `ActionResult` `{ success: false, error }` envelope, not on a throw. A `route.ts` REST endpoint (hand-written or via the `route()` adapter) is NOT covered by this check: authenticate every mutating endpoint, use `validate`, log without secrets, rate-limit. For CORS use the `cors()` middleware from `@webjsdev/server`; **`credentials: true` REQUIRES an explicit origin allowlist, never `'*'`.** See `references/client-router-and-streaming.md`.
298
313
 
299
314
  ### Components (`components/*.{js,ts}`)
300
315
 
@@ -318,13 +333,13 @@ type ActionResult<T> =
318
333
 
319
334
  `fieldErrors` / `values` / `redirect` are additive. **Failure detection is robust:** a result is a FAILURE when `result.success === false`, OR `result.fieldErrors` is present, OR `result.error` is present and `result.success !== true`. **`result.redirect` must be a same-site local path** (a single leading `/`); other values are ignored (open-redirect guard), so throw `redirect(absoluteUrl)` for a real external redirect.
320
335
 
321
- **Rules:** routes stay thin (extract >~20 lines into a module action); client components import server modules via the normal path; server-only imports reach the client only through `.server.{js,ts}`; one module, one feature. See `agent-docs/recipes.md`.
336
+ **Rules:** routes stay thin (extract >~20 lines into a module action); client components import server modules via the normal path; server-only imports reach the client only through `.server.{js,ts}`; one module, one feature. See `references/data-and-actions.md`.
322
337
 
323
338
  ---
324
339
 
325
340
  ## Styling: Tailwind-first
326
341
 
327
- **Tailwind is the strong default for pages AND light-DOM components.** The lit reflex to scope CSS in a shadow root with `static styles` is the habit to resist in light DOM. When a class bundle repeats, extract it into a `lib/utils/ui.ts` helper returning an `` html`...` `` fragment (SSR-time), NOT a CSS class (no `@apply`). Reserve raw CSS for what utilities cannot express (design tokens / `@theme`, `@property` + `@keyframes`, scrollbar, `prefers-reduced-motion`, complex `color-mix()` / gradients); in light DOM the tag-prefix invariant (#7) still holds, and shadow-DOM components legitimately use `static styles = css\`\``. **Pin a header with `position: fixed`, never `position: sticky`** (a sticky header flickers on iOS WebKit during a client-router nav, #610, because the preserved header plus the scroll-to-top trips a WebKit sticky-repaint bug that the GPU-promotion hacks do NOT fix, so reserve the header height on the content with a `--header-height` offset). See `agent-docs/styling.md`.
342
+ **Tailwind is the strong default for pages AND light-DOM components.** The lit reflex to scope CSS in a shadow root with `static styles` is the habit to resist in light DOM. When a class bundle repeats, extract it into a `lib/utils/ui.ts` helper returning an `` html`...` `` fragment (SSR-time), NOT a CSS class (no `@apply`). Reserve raw CSS for what utilities cannot express (design tokens / `@theme`, `@property` + `@keyframes`, scrollbar, `prefers-reduced-motion`, complex `color-mix()` / gradients); in light DOM the tag-prefix invariant (#7) still holds, and shadow-DOM components legitimately use `static styles = css\`\``. **Pin a header with `position: fixed`, never `position: sticky`** (a sticky header flickers on iOS WebKit during a client-router nav, #610, because the preserved header plus the scroll-to-top trips a WebKit sticky-repaint bug that the GPU-promotion hacks do NOT fix, so reserve the header height on the content with a `--header-height` offset). See `references/styling.md`.
328
343
 
329
344
  ---
330
345
 
@@ -332,13 +347,90 @@ type ActionResult<T> =
332
347
 
333
348
  The router auto-enables when `@webjsdev/core` loads (any page with a component), so there is nothing to opt INTO. An app that wants plain full-page (MPA) navigation can opt OUT app-wide with `{ "webjs": { "clientRouter": false } }` (#629), or per-moment at runtime with `disableClientRouter()`. Nested layouts auto-emit `<!--wj:children:<segment-path>-->` markers; the client router walks both DOMs and replaces only the deepest shared layout's children slot, preserving outer-layout DOM identity. Form submissions ride the same pipeline (`data-no-router` opts out). Wire bytes are minimized via the `X-Webjs-Have` header (the server returns only the divergent fragment); scroll is restored on back/forward. A non-GET `<form>` whose target page exports an `action` is the no-JS write-path (with JS the router applies the response in place: a `422` swaps without reload, a `303` is followed via fetch). A failed navigation recovers in place (a cancelable `webjs:navigation-error` event, else a minimal in-place alert), never a destructive full reload.
334
349
 
335
- The advanced client-router surface is in `agent-docs/advanced.md`: **link prefetch** (on by default, device-adaptive default: `intent` on a hover pointer, `viewport` (dwell-gated, cancel-on-scroll-out) on touch, per-link `data-prefetch` override), **`<webjs-frame>`** partial-swap regions, **View Transitions** (opt-in via `<meta name="view-transition">`, plus `data-webjs-permanent` to persist a live element), **stream actions** (`<webjs-stream>` element-level updates, #248), and the **opt-in nav-loading indicator** (`<html data-webjs-nav-progress>` exposes a `data-navigating` attribute during a nav so you can style a CSS-only progress affordance, off by default because toggling a root attribute re-resolves `oklch()` tokens to a one-frame repaint flash on iOS WebKit, #610). Production benefits from HTTP/2 at the edge; `npm run start` speaks plain HTTP/1.1 (put a reverse proxy in front for TLS + HTTP/2).
350
+ The advanced client-router surface is in `references/client-router-and-streaming.md`: **link prefetch** (on by default, device-adaptive default: `intent` on a hover pointer, `viewport` (dwell-gated, cancel-on-scroll-out) on touch, per-link `data-prefetch` override), **`<webjs-frame>`** partial-swap regions, **View Transitions** (opt-in via `<meta name="view-transition">`, plus `data-webjs-permanent` to persist a live element), **stream actions** (`<webjs-stream>` element-level updates, #248), and the **opt-in nav-loading indicator** (`<html data-webjs-nav-progress>` exposes a `data-navigating` attribute during a nav so you can style a CSS-only progress affordance, off by default because toggling a root attribute re-resolves `oklch()` tokens to a one-frame repaint flash on iOS WebKit, #610). Production benefits from HTTP/2 at the edge; `npm run start` speaks plain HTTP/1.1 (put a reverse proxy in front for TLS + HTTP/2).
351
+
352
+ ---
353
+
354
+ ## Mutations: default to optimistic UI
355
+
356
+ **Use `optimistic()` from `@webjsdev/core` for every user-facing mutation** (create, update, delete, like, toggle, reorder) where the client can predict the result. The UI should feel instant; roll back automatically on failure.
357
+
358
+ ### Preferred: declarative API
359
+
360
+ ```ts
361
+ import { WebComponent, prop, optimistic, html } from '@webjsdev/core';
362
+ import { createTodo } from '#modules/todos/actions/create-todo.server.ts';
363
+
364
+ class TodoList extends WebComponent({
365
+ todos: prop<Todo[]>(Array),
366
+ }) {
367
+ private optimisticTodos = optimistic(this, {
368
+ source: () => this.todos,
369
+ update: (state, title: string) => [
370
+ ...state,
371
+ { id: crypto.randomUUID() as any, title, completed: false, pending: true },
372
+ ],
373
+ });
374
+
375
+ async handleSubmit(e: SubmitEvent) {
376
+ e.preventDefault();
377
+ const title = new FormData(e.target as HTMLFormElement).get('title') as string;
378
+ if (!title) return;
379
+ (e.target as HTMLFormElement).reset();
380
+
381
+ const promise = createTodo({ title });
382
+ this.optimisticTodos.add(title, promise);
383
+
384
+ const result = await promise;
385
+ if (result.success && result.data) {
386
+ this.todos = [...this.todos.filter(t => t.pending), result.data, ...this.todos.filter(t => !t.pending)];
387
+ }
388
+ }
389
+
390
+ render() {
391
+ return html`<ul>${this.optimisticTodos.value.map(todo => html`
392
+ <li class=${todo.pending ? 'opacity-50' : ''}>${todo.title}</li>
393
+ `)}</ul>`;
394
+ }
395
+ }
396
+ TodoList.register('todo-list');
397
+ ```
398
+
399
+ `.add(payload, promise)` auto-releases when the promise settles (resolve or reject). No try-catch, no manual rollback, no temp-ID bookkeeping.
400
+
401
+ ### Simple flips: imperative API
402
+
403
+ For boolean toggles where the value itself is the mutation:
404
+
405
+ ```ts
406
+ import { signal, optimistic } from '@webjsdev/core';
407
+ import { likePost } from '#actions/like-post.server.ts';
408
+
409
+ const liked = signal(false);
410
+ // in an @click handler:
411
+ const result = await optimistic(liked, true, () => likePost(postId));
412
+ ```
413
+
414
+ ### When to skip optimistic UI
415
+
416
+ - The result is unpredictable (AI-generated content, server-computed values the client cannot guess).
417
+ - The mutation has side effects the user must wait for (payment processing, email sending, OAuth).
418
+ - The action validates against data that may have changed server-side (unique constraints, race conditions).
419
+ - The mutation is destructive and irreversible with no undo (confirm-first UX is better).
420
+
421
+ ### When optimistic UI is appropriate
422
+
423
+ - Todo items, comments, posts, likes, follows, toggles, reorders, renames, status changes.
424
+ - Any mutation where the client can construct the expected result from the input.
425
+ - CRUD operations where the server returns the same shape the client already has.
426
+
427
+ **Never write manual try-catch / cache-and-restore / temp-ID reconciliation** when `optimistic()` covers the pattern. See `references/client-router-and-streaming.md` for the full reference and `references/data-and-actions.md` for complete examples.
336
428
 
337
429
  ---
338
430
 
339
431
  ## Invariants (for both humans and agents)
340
432
 
341
- > Hit one of these as a runtime error? The [Troubleshooting page](https://docs.webjs.com/docs/troubleshooting) is keyed by symptom (the throw-at-load server import, the backtick-in-template 500, the TypeScript strip failure, the SSR browser-global crash, the missing-frame swap) and maps each back to the invariant and the `webjs check` rule below.
433
+ > Hit one of these as a runtime error? The [Troubleshooting page](https://docs.webjs.dev/docs/troubleshooting) is keyed by symptom (the throw-at-load server import, the backtick-in-template 500, the TypeScript strip failure, the SSR browser-global crash, the missing-frame swap) and maps each back to the invariant and the `webjs check` rule below.
342
434
 
343
435
  1. **Server-only code goes in `.server.{js,ts}` files, `route.ts` handlers, or `middleware.ts`. Never in pages, layouts, or components.** The `.server.{js,ts}` extension is the path-level boundary (the file router refuses to serve the source); a `'use server'` directive additionally makes exports RPC-callable, else the file is a server-only utility whose browser import is a throw-at-load stub. Importing a DB driver (`pg`), `node:*`, or any server-only dep from a component or an `app/**` page / layout / loading / error / not-found file crashes the browser at module load.
344
436
  2. **Every `*.server.{js,ts}` file with `'use server'` exports must be `async` functions returning serializer-safe values.** Args and results round-trip via webjs's wire. Files without `'use server'` (server-only utilities) can export anything, including singletons.
@@ -349,9 +441,9 @@ The advanced client-router surface is in `agent-docs/advanced.md`: **link prefet
349
441
  7. **Light-DOM components with custom CSS MUST prefix every class selector with their tag name.** Tailwind utilities are unique by construction, so prefer them.
350
442
  8. **Non-root layouts and pages MUST NOT** write `<!doctype>` / `<html>` / `<head>` / `<body>`. Only the root layout may.
351
443
  9. **No backtick characters inside `html\`...\`` template bodies**, even inside CSS / HTML comments. A nested backtick closes the literal at JS-parse time and 500s in prod.
352
- 10. **TypeScript must be erasable.** Set `compilerOptions.erasableSyntaxOnly: true`. No `enum`, no value `namespace`, no constructor parameter properties, no legacy decorators with `emitDecoratorMetadata`, no `import = require`. Types are stripped via Node 24+'s `module.stripTypeScriptTypes` (buildless, no bundler fallback); non-erasable syntax 500s at strip time. Enforced by `erasable-typescript-only` (tsconfig flag) and `no-non-erasable-typescript` (source scan). See `agent-docs/typescript.md`.
444
+ 10. **TypeScript must be erasable.** Set `compilerOptions.erasableSyntaxOnly: true`. No `enum`, no value `namespace`, no constructor parameter properties, no legacy decorators with `emitDecoratorMetadata`, no `import = require`. Types are stripped via Node 24+'s `module.stripTypeScriptTypes` (buildless, no bundler fallback); non-erasable syntax 500s at strip time. Enforced by `erasable-typescript-only` (tsconfig flag) and `no-non-erasable-typescript` (source scan). See `references/typescript.md`.
353
445
 
354
- 11. **No em-dashes (U+2014), no hyphen or semicolon used as pause-punctuation in prose, and no colon attached to a code-shaped LHS.** Banned as a pause: U+2014, a space-surrounded hyphen between words, a space-surrounded semicolon between words. Banned colon attachments: a colon-then-prose after `xyz()`, a `<my-tag>`, an `[expr]` subscript, or a `<code>foo()</code>` definition list (rephrase verb-led). Prefer a period, comma, a colon on a plain-noun LHS, parentheses, or a restructure. Plain hyphens stay fine in compound words, flags, filenames, ranges; semicolons and colons stay fine inside code / TS / JSON / CSS. Enforced via `.claude/hooks/block-prose-punctuation.sh`, which scans only NEW content (you can still edit an existing line to remove a glyph).
446
+ 11. **No em-dashes (U+2014), no hyphen or semicolon used as pause-punctuation in prose, and no colon attached to a code-shaped LHS.** Banned as a pause: U+2014, a space-surrounded hyphen between words, a space-surrounded semicolon between words. Banned colon attachments: a colon-then-prose after `xyz()`, a `<my-tag>`, an `[expr]` subscript, or a `<code>foo()</code>` definition list (rephrase verb-led). Prefer a period, comma, a colon on a plain-noun LHS, parentheses, or a restructure. Plain hyphens stay fine in compound words, flags, filenames, ranges; semicolons and colons stay fine inside code / TS / JSON / CSS. The same hook also enforces brand casing with one simple rule: `WebJs` is a proper noun, so write it capitalized wherever it NAMES the project in prose, at a sentence start AND mid-sentence (`WebJs ships`, `Most WebJs apps`, `the WebJs serializer`). It stays lowercase `webjs` ONLY as a literal code token: a `webjs <subcommand>` CLI command (`webjs dev`, `webjs db migrate`), a `webjs.dev` domain, an `@webjsdev` package, a `"webjs"` config key, a `WEBJS_*` env var, the `webjsdev/webjs` org path, or anything inside a `` `code` `` span or fenced block. If you mean the literal config key or command in prose, wrap it in backticks. Enforced via `.claude/hooks/block-prose-punctuation.sh`, which scans only NEW content (you can still edit an existing line to fix a glyph or casing).
355
447
 
356
448
  ---
357
449
 
@@ -359,7 +451,7 @@ The advanced client-router surface is in `agent-docs/advanced.md`: **link prefet
359
451
 
360
452
  Three scaffolds exist (do not invent template names): `webjs create <name>` (full-stack: layout, page, components, modules, Drizzle+SQLite), `webjs create <name> --template api` (backend-only routes + modules + Drizzle, no SSR), `webjs create <name> --template saas` (auth + login/signup + protected dashboard + User model). The `--db sqlite|postgres` flag (default sqlite) picks the dialect; the schema/queries/actions are identical across dialects (see #563). The `--runtime node|bun` flag (default node, #541) is ORTHOGONAL to the template and re-flavors any of the three for Bun (`bun --bun` dev/start scripts so the SERVER runs on Bun, `bun.lock`, a pure `oven/bun:1` Dockerfile (#595; safe since cli@0.10.20's npx-free `webjs db migrate` (#570) needs no Node in the image) + bun-install CI, bun-command agent docs; the test/db/check tooling stays on Node); `bun create webjs <name>` auto-detects it. Pick from the request: default for any product with UI (todo, blog, dashboard, marketplace, social, e-commerce), `api` for an HTTP/JSON API with no UI, `saas` for accounts/login/signup; default to full-stack when ambiguous.
361
453
 
362
- Rules: **always scaffold via `webjs create`** (never hand-roll). **Default to a real database (Drizzle + SQLite); NEVER use JSON files, in-memory arrays, or localStorage for persistence.** Update `db/schema.server.ts` to real models FIRST, then `webjs db generate` + `webjs db migrate`, then build pages/actions/queries. **Treat the scaffold as REFERENCE, not the final product:** replace the example page / `User` model / components and adapt `app/layout.ts` (brand, nav, content width; the default `<main class="max-w-[760px]">` reading column needs widening for a full-bleed app). ENFORCED: examples carry a `webjs-scaffold-placeholder` comment and `no-scaffold-placeholder` fails until the content is replaced and the marker deleted. Docs at https://docs.webjs.com.
454
+ Rules: **always scaffold via `webjs create`** (never hand-roll). **Default to a real database (Drizzle + SQLite); NEVER use JSON files, in-memory arrays, or localStorage for persistence.** Update `db/schema.server.ts` to real models FIRST, then `webjs db generate` + `webjs db migrate`, then build pages/actions/queries. **The scaffold ships a gallery index home, a neutral-palette root layout, db wiring, and a densely-commented feature gallery** (single-concept demos under `app/features/` plus the `app/examples/todo` app, with logic in `modules/`), alongside the one cross-agent skill at `.agents/skills/webjs/` (a routing `SKILL.md` + on-demand `references/`) that teaches how to build. Treat the gallery as browsable reference: read the demos to learn the idioms, then build the app the user asked for by growing the scaffold (add routes under `app/`, components under `components/`, features under `modules/<feature>/`, keep server-only code behind `.server.ts`) and prune the demos the app does not use (delete the `app/features/<x>` route AND its `modules/<x>`). Give a UI app its own palette by setting the token values in `app/layout.ts`. There is no design gate and no placeholder gate: taste is the agent's job, not the checker's. Docs at https://docs.webjs.dev.
363
455
 
364
456
  ---
365
457
 
@@ -371,7 +463,7 @@ webjs start [--port N] # prod server; source IS the runtime, plain H
371
463
  webjs test [--server] [--browser] [--watch]
372
464
  webjs check [--rules] [--json] # correctness validator (report-only, no autofix); --json for an agent loop
373
465
  webjs mcp # read-only MCP: routes, actions (RPC hashes), components, check
374
- webjs doctor # project-health checklist (incl. a page/layout elision advisory, #646); non-zero exit on a hard fail
466
+ webjs doctor # project-health checklist (incl. a framework-resolve check that warns when @webjsdev/core can't be resolved from the app dir, the fresh-worktree-without-node_modules trap #954; a page/layout elision advisory); non-zero exit on a hard fail
375
467
  webjs types # generate .webjs/routes.d.ts (typed Route union + per-route params, #258)
376
468
  webjs typecheck [tsc args...] # the project's own tsc --noEmit
377
469
  webjs create <name> [--template api|saas]
@@ -386,26 +478,26 @@ webjs vendor pin|unpin|list|audit|outdated|update [--from PROVIDER] # importma
386
478
 
387
479
  ## Environment, server config, caching, observability
388
480
 
389
- - **Env vars.** `process.env.X` reads are server-only; `WEBJS_PUBLIC_`-prefixed names are exposed in the browser via an inline `<script>` (no build); `NODE_ENV` is defined both sides. See `agent-docs/built-ins.md`.
390
- - **The `package.json` `"webjs"` block.** Security headers (on by default, per-path `webjs.headers` overrides), CSP (opt-in nonce, `webjs.csp`), declarative `webjs.redirects` (#254), `webjs.trailingSlash` (#255), `webjs.basePath` (#256), ingress caps (`maxBodyBytes` / `maxMultipartBytes` / server timeouts), and dev/start task orchestration (`webjs.dev.before` / `webjs.dev.parallel` / `webjs.start.before`, #550, the orchestration `webjs dev`/`start` run so they match `npm run dev`/`start`). Type it with `WebjsConfig` + the JSON Schema. See `agent-docs/configuration.md`.
391
- - **Caching + file storage** (`agent-docs/built-ins.md`). HTTP `Cache-Control`, the `cache()` query helper + `revalidateTag`, the server HTML response cache (`export const revalidate` + `revalidatePath`, #241), content-hash asset URLs (`?v=`, #243), conditional GET (ETag, #240), and `FileStore` + `diskStore` (streaming, traversal-safe, signed URLs, S3-pluggable, #247).
392
- - **Observability** (`agent-docs/configuration.md`). Access log, `requestId()` + `X-Request-Id`, the `onError` APM hook, `GET /__webjs/version` (#239).
481
+ - **Env vars.** `process.env.X` reads are server-only; `WEBJS_PUBLIC_`-prefixed names are exposed in the browser via an inline `<script>` (no build); `NODE_ENV` is defined both sides. See `references/built-ins.md`.
482
+ - **The `package.json` `"webjs"` block.** Security headers (on by default, per-path `webjs.headers` overrides), CSP (opt-in nonce, `webjs.csp`), declarative `webjs.redirects` (#254), `webjs.trailingSlash` (#255), `webjs.basePath` (#256), ingress caps (`maxBodyBytes` / `maxMultipartBytes` / server timeouts), and dev/start task orchestration (`webjs.dev.before` / `webjs.dev.parallel` / `webjs.dev.regenerate` / `webjs.start.before`, #550 + #967, the orchestration `webjs dev`/`start` run so they match `npm run dev`/`start`; `regenerate` recompiles a stale served build output like `public/tailwind.css` ON REQUEST in dev, replacing a fragile `--watch`). Type it with `WebjsConfig` + the JSON Schema. See `references/built-ins.md`.
483
+ - **Caching + file storage** (`references/built-ins.md`). HTTP `Cache-Control`, the `cache()` query helper + `revalidateTag`, the server HTML response cache (`export const revalidate` + `revalidatePath`, #241), content-hash asset URLs (`?v=`, #243), conditional GET (ETag, #240), and `FileStore` + `diskStore` (streaming, traversal-safe, signed URLs, S3-pluggable, #247).
484
+ - **Observability** (`references/built-ins.md`). Access log, `requestId()` + `X-Request-Id`, the `onError` APM hook, `GET /__webjs/version` (#239).
393
485
 
394
486
  ---
395
487
 
396
- ## CONVENTIONS.md and webjs check: two surfaces, split by nature
488
+ ## Conventions and webjs check: two surfaces, split by nature
397
489
 
398
- Every app ships a root `CONVENTIONS.md`; AI agents MUST read it before writing code. It is the source of truth for **project conventions** (modules layout, action placement, one-function-per-file, testing, styling, git workflow), which are guidance a reasonable project could do differently, customizable directly in the prose (`<!-- OVERRIDE -->` sections), not tool-enforced.
490
+ Every app ships the **conventions** as prose, in `.agents/skills/webjs/SKILL.md` (how to build) and `.agents/rules/workflow.md` (git, tests, review). These are guidance a reasonable project could do differently (modules layout, action placement, one-function-per-file, testing, styling, git workflow), followed by judgment, not tool-enforced.
399
491
 
400
- **`webjs check` is a separate, narrower tool: correctness checks only.** Every rule catches code that is wrong to ship (a crash, a security leak, a build/type-strip failure), plus the sentinel `no-scaffold-placeholder` (unreplaced scaffold content). They run unconditionally with no per-project disabling. The dividing line: could a sensible app legitimately want this to pass? If yes it is a convention (prose), if no it is a check (the tool). `webjs check --rules` lists them.
492
+ **`webjs check` is a separate, narrower tool: correctness checks only.** Every rule catches code that is wrong to ship (a crash, a security leak, a build/type-strip failure). They run unconditionally with no per-project disabling. The dividing line: could a sensible app legitimately want this to pass? If yes it is a convention (prose), if no it is a check (the tool). `webjs check --rules` lists them.
401
493
 
402
- So: read `CONVENTIONS.md` and follow it by judgment; run `webjs check` and fix every violation (correctness bugs, not style); change a convention by editing the prose.
494
+ So: read the skill and follow its conventions by judgment; run `webjs check` and fix every violation (correctness bugs, not style).
403
495
 
404
496
  ---
405
497
 
406
498
  ## Recipes: the essentials
407
499
 
408
- The full set lives in `agent-docs/recipes.md`. The most common patterns:
500
+ The full set lives in `references/data-and-actions.md`. The most common patterns:
409
501
 
410
502
  ### Add a page
411
503
 
@@ -443,7 +535,7 @@ export async function updateProfile(input: { name: string }) {
443
535
  }
444
536
  ```
445
537
 
446
- Call it from a client component via a normal import (rewritten to an RPC stub). A component is one custom element per file (`class X extends WebComponent { render() { return html\`…\`; } }` then `X.register('x-tag')`). Full recipes, including the no-JS page-action form, are in `agent-docs/recipes.md`.
538
+ Call it from a client component via a normal import (rewritten to an RPC stub). A component is one custom element per file (`class X extends WebComponent { render() { return html\`…\`; } }` then `X.register('x-tag')`). Full recipes, including the no-JS page-action form, are in `references/data-and-actions.md`.
447
539
 
448
540
  ---
449
541
 
@@ -451,7 +543,7 @@ Call it from a client component via a normal import (rewritten to an RPC stub).
451
543
 
452
544
  Not in v1. Do not implement as part of other tasks:
453
545
 
454
- - **Bundling and per-route code splitting.** webjs is **no-build** (the Rails 7 + importmap model); prod perf comes from HTTP/2 multiplex + `<link rel="modulepreload">` hints, not concatenation. **Do not propose a bundler or `webjs build`.**
546
+ - **Bundling and per-route code splitting.** WebJs is **no-build** (the Rails 7 + importmap model); prod perf comes from HTTP/2 multiplex + `<link rel="modulepreload">` hints, not concatenation. **Do not propose a bundler or `webjs build`.**
455
547
  - **Vite-grade HMR with state preservation.** Custom elements only `define` once, so full reload is necessary; data reloads are near-instant via `fs.watch` to SSE.
456
548
  - **React Server Components Flight.** Server actions + `Suspense` streaming cover the need.
457
549
  - **Edge-runtime bundling / full portability** (deployment guidance lives in the docs site at `/docs/deployment`), **i18n, image optimization** (layer libraries on top).