@webjsdev/mcp 0.1.10 → 0.1.12

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.
@@ -4,10 +4,10 @@
4
4
 
5
5
  - Pages, layouts, and where the HTML shell comes from
6
6
  - Dynamic (`[param]`), catch-all (`[...rest]`), and optional catch-all (`[[...rest]]`) segments, route groups, private folders
7
- - `route.ts` HTTP handlers and `middleware.ts`
8
- - `metadata` and `generateMetadata` (folded in here)
7
+ - `route.ts` HTTP handlers and `middleware.ts`, the route-handler toolkit (`json` / `readBody` / `clientIp` / the no-arg accessors), and calling one from the client with `richFetch`
8
+ - `metadata` and `generateMetadata` (folded in here), including image metadata routes that return a `Response`
9
9
  - Control-flow throws: `notFound()`, `redirect()`, `forbidden()`, `unauthorized()`
10
- - The no-JS page `action` write path
10
+ - The no-JS write path: a `<form>` bound to a `'use server'` action
11
11
  - Boundaries: `error.ts`, `loading.ts`, `not-found.ts`, `forbidden.ts`, `unauthorized.ts`, and the two root-only ones
12
12
 
13
13
  Read this when a task touches the route contract, a URL, a `<head>` tag, a redirect, a 404, or a form POST that a page owns. Sibling refs: `components.md` (anything interactive), `data-and-actions.md` (server actions, queries, validation, the `ActionResult` envelope), `auth-and-sessions.md` (`forbidden()` / `unauthorized()` flows).
@@ -32,7 +32,7 @@ export default function About() {
32
32
 
33
33
  `params` and `searchParams` are awaitable AND synchronously readable (`params.id` and `await params` both work, Next.js 15/16 parity). Throw `notFound()` or `redirect(url)` to short-circuit. Reach data through a `.server.ts` query; never import the DB driver into a page.
34
34
 
35
- Optional named exports: `metadata` / `generateMetadata` (below), `export const revalidate` (seconds, opts into the HTML response cache, only for a page identical for every visitor), and `export const action` (the write path, below).
35
+ Optional named exports: `metadata` / `generateMetadata` (below) and `export const revalidate` (seconds, opts into the HTML response cache, only for a page identical for every visitor). There is no `action` export; the write path is a form bound to a `'use server'` action (below).
36
36
 
37
37
  ## Layouts (`app/**/layout.ts`)
38
38
 
@@ -41,7 +41,8 @@ The default export receives `{ children, params, searchParams, url }` and must e
41
41
  ```ts
42
42
  // app/layout.ts (root)
43
43
  import { html } from '@webjsdev/core';
44
- export default function RootLayout({ children }: { children: unknown }) {
44
+ import type { LayoutProps } from '@webjsdev/core';
45
+ export default function RootLayout({ children }: LayoutProps) {
45
46
  return html`<html lang="en"><head></head><body>${children}</body></html>`;
46
47
  }
47
48
  ```
@@ -78,10 +79,24 @@ export async function GET() {
78
79
 
79
80
  **NEVER throw `redirect()` / `notFound()` / `forbidden()` inside a `route.ts` handler** (an uncaught throw is a generic 500). Return a real response instead: `return Response.redirect(url, 303)` for a redirect, `return new Response('Not Found', { status: 404 })` for a 404. A `route.ts` is also NOT covered by the action CSRF check, so authenticate every mutating endpoint, validate, and rate-limit. Export `WS(ws, req, { params })` from the same file for a WebSocket endpoint.
80
81
 
82
+ **The route-handler toolkit** (from `@webjsdev/server`). Two accessors take the request explicitly: `readBody(req)` decodes a rich request body (the inverse of `json()`, round-tripping `Date` / `Map` / `Set` / `BigInt` / `Blob`), and `clientIp(req)` reads the caller IP. To respond with rich types, `json(value)` serializes with the same wire (so a `Date` survives), versus a plain object return that auto-JSONs. The context accessors take NO argument because they read the in-flight request from context: `headers()`, `cookies()`, `requestId()`, `cspNonce()`.
83
+
84
+ ```ts
85
+ import { json, readBody, clientIp } from '@webjsdev/server';
86
+ export async function POST(req: Request) {
87
+ const body = await readBody(req); // rich types decoded
88
+ return json({ at: new Date(), ip: clientIp(req) }); // Date survives the wire
89
+ }
90
+ ```
91
+
92
+ **Calling your own `route.ts` from the client:** use `richFetch<T>(url, opts?)` from `@webjsdev/core`, a drop-in `fetch` that sends `Accept: application/vnd.webjs+json`, encodes a plain-object `body` with the rich wire, and decodes the `json()` response (so `data.at` is a real `Date`). This is the ONE legitimate hand-fetch (importing a `'use server'` action is the way to call the server otherwise, never a hand-written `fetch` to an action).
93
+
81
94
  ## Middleware (`middleware.ts`)
82
95
 
83
96
  Optional root-level plus per-segment. The default export is `async (req, next) => Response`. Return a Response to short-circuit, or call `next()` and post-process. Per-segment middleware applies to its subtree, outermost to innermost.
84
97
 
98
+ The root file sits beside `app/`, not inside it, and may be `middleware.ts` / `.js` / `.mts` / `.mjs` (`.ts` wins if more than one exists). A per-segment `app/<segment>/middleware.*` takes any of the same extensions.
99
+
85
100
  ## Metadata and `generateMetadata`
86
101
 
87
102
  A page exports `metadata` (static) or `generateMetadata(ctx)` (request-scoped, takes precedence). Values flow into `<head>` at SSR and merge across nested layouts (deeper wins). Type both with `Metadata`; `MetadataContext` types the argument. The surface is Next.js-compatible.
@@ -96,28 +111,30 @@ export async function generateMetadata(ctx: MetadataContext): Promise<Metadata>
96
111
  }
97
112
  ```
98
113
 
99
- Common fields: `title` (string or `{ template, default, absolute }`), `description`, `keywords`, `metadataBase` (resolves relative URLs in `openGraph` / `twitter` / `alternates` / `icons`), `openGraph`, `twitter`, `robots`, `alternates.canonical`, `icons`, `manifest`, and `jsonLd` (schema.org structured data, single object or array, HTML-safe-escaped automatically). `viewport`, `themeColor`, and `colorScheme` may also be set via a split `export const viewport = { ... }`. `cacheControl` is emitted as a response HEADER (not a `<meta>`); pages default to `no-store`, and a `public` value enables conditional GET (a weak `ETag` + `304`). See https://docs.webjs.dev for the full field list.
114
+ Common fields: `title` (string or `{ template, default, absolute }`), `description`, `keywords`, `metadataBase` (resolves relative URLs in `openGraph` / `twitter` / `alternates` / `icons`), `openGraph`, `twitter`, `robots`, `alternates.canonical`, `icons`, `manifest`, and `jsonLd` (schema.org structured data, single object or array, HTML-safe-escaped automatically). `viewport`, `themeColor`, and `colorScheme` may also be set via a split `export const viewport = { ... }`. `cacheControl` is emitted as a response HEADER (not a `<meta>`); pages default to `no-store`, and any other value enables conditional GET (a weak `ETag` + `304`), `private` included. See https://webjs.dev/docs for the full field list.
100
115
 
101
116
  ## Control-flow throws
102
117
 
103
- From `@webjsdev/core`: throw to short-circuit a page / layout render or a page `action`.
118
+ From `@webjsdev/core`: throw to short-circuit a page / layout render or a form-bound action.
104
119
 
105
120
  - `notFound()` renders the nearest `not-found.ts` (nearest wins from the throwing chain).
106
- - `redirect(url[, status])`. The no-status default is convention-picked at the catch site: `302` for a GET page render, `307` (method-preserving) for a page action. Override with `redirect(url, 308)` or `redirect(url, { status })`.
121
+ - `redirect(url[, status])`. The no-status default is convention-picked at the catch site: `302` for a GET page render, `307` (method-preserving) for a form-bound action. Override with `redirect(url, 308)` or `redirect(url, { status })`.
107
122
  - `forbidden()` renders the nearest `forbidden.ts` (authenticated user lacking permission); `unauthorized()` renders the nearest `unauthorized.ts` (request not authenticated).
108
123
 
109
124
  None of these belong in a `route.ts` (return a `Response` there). Inside a `'use server'` RPC action, return an `ActionResult` for an auth failure rather than throwing (`data-and-actions.md`).
110
125
 
111
- ## The no-JS write path (a page `action`)
126
+ ## The no-JS write path (a form-bound action)
112
127
 
113
- A `page.ts` may export an `action` beside its default render function. A non-GET/HEAD submission to the page's own URL runs it, wrapped in the page's segment middleware. It works with JS off; with JS on the client router applies the response in place.
128
+ A page imports a `'use server'` action and binds it into the form: `<form action=${sendMessage}>`. There is no page `action` export; the binding IS the wiring. The renderer omits the `action` attribute (so the form posts to the page's own url), supplies `method="post"` and an enctype, and emits a hidden `__webjs_action` field carrying the action's `<hash>/<fn>` identity. A non-GET/HEAD submission carrying that identity runs the action, wrapped in the segment middleware of the URL it was submitted to AND the action's own declared `middleware`. It works with JS off; with JS on the client router posts the same body to the same url and applies the response in place.
114
129
 
115
- ```ts
116
- // app/contact/page.ts
117
- import { html } from '@webjsdev/core';
118
- import { sendMessage } from '#modules/contact/actions/send-message.server.ts';
130
+ **Do not treat the page's segment middleware as the action's authorization gate.** An identity names the action, not the page that rendered it, so the same action is reachable by submitting to any page path (and, being a `'use server'` export, at its RPC endpoint too, where no segment middleware runs at all). That is the same reachability every server action has always had, and the removed page `action` export was the one shape where the URL really did scope the handler. Authorize the action itself: an `export const middleware = [requireAdmin]` on the action, or a check in its body, both of which run on either transport.
119
131
 
120
- export async function action({ formData }: { formData: FormData }) {
132
+ A form-bound action always receives the `FormData` as its first argument, which is where it differs from the same action called over RPC. `validate` is the typing seam: it receives that `FormData`, and its transform-return becomes the action's typed input.
133
+
134
+ ```ts
135
+ // modules/contact/actions/send-message.server.ts
136
+ 'use server';
137
+ export async function sendMessage(formData: FormData) {
121
138
  const email = String(formData.get('email') || '').trim();
122
139
  const body = String(formData.get('body') || '').trim();
123
140
  const values = { email, body };
@@ -125,9 +142,15 @@ export async function action({ formData }: { formData: FormData }) {
125
142
  if (!email.includes('@')) fieldErrors.email = 'Enter a valid email';
126
143
  if (body.length < 10) fieldErrors.body = 'Message is too short';
127
144
  if (Object.keys(fieldErrors).length) return { success: false, fieldErrors, values, status: 422 };
128
- await sendMessage({ email, body });
145
+ await deliver({ email, body });
129
146
  return { success: true, redirect: '/contact/thanks' };
130
147
  }
148
+ ```
149
+
150
+ ```ts
151
+ // app/contact/page.ts
152
+ import { html } from '@webjsdev/core';
153
+ import { sendMessage } from '#modules/contact/actions/send-message.server.ts';
131
154
 
132
155
  export default function Contact({ actionData }: {
133
156
  actionData?: { fieldErrors?: Record<string, string>; values?: Record<string, string> };
@@ -135,7 +158,7 @@ export default function Contact({ actionData }: {
135
158
  const errors = actionData?.fieldErrors || {};
136
159
  const values = actionData?.values || {};
137
160
  return html`
138
- <form method="POST" class="flex flex-col gap-3">
161
+ <form action=${sendMessage} class="flex flex-col gap-3">
139
162
  <input name="email" type="email" value=${values.email || ''} required>
140
163
  ${errors.email ? html`<p class="text-sm text-red-600">${errors.email}</p>` : ''}
141
164
  <textarea name="body" required>${values.body || ''}</textarea>
@@ -146,7 +169,17 @@ export default function Contact({ actionData }: {
146
169
  }
147
170
  ```
148
171
 
149
- How the result is read (server side): a success PRG-redirects with `303` (to a same-site `redirect` path if present, else the page's own URL); a failure re-SSRs the SAME page with `status` (default `422`) and the result on `ctx.actionData`. Failure is detected robustly (`success === false`, OR `fieldErrors` present, OR `error` present with `success !== true`), so an error is never swallowed. `result.redirect` must be a same-site local path (a single leading `/`); for a real external redirect, throw `redirect(absoluteUrl)` instead. On a plain GET render `actionData` is `undefined`. Prefer a `<form>` + page action over `fetch` in a `@click` for any write a form can express.
172
+ How the result is read (server side): a success PRG-redirects with `303` (to a same-site `redirect` path if present, else the page's own URL); a failure re-SSRs the SAME page with `status` (default `422`) and the result on `ctx.actionData`. Failure is detected robustly (`success === false`, OR `fieldErrors` present, OR `error` present with `success !== true`), so an error is never swallowed. `result.redirect` must be a same-site local path (a single leading `/`); for a real external redirect, throw `redirect(absoluteUrl)` instead. On a plain GET render `actionData` is `undefined`. Prefer a bound `<form>` over `fetch` in a `@click` for any write a form can express.
173
+
174
+ Three responses that are not the happy path:
175
+
176
+ - **A submission carrying no identity is a `405` + `Allow: GET, HEAD`.** A bare `<form method="post">` binds nothing, and the page path exists but only renders, so the method is what is wrong rather than the url.
177
+ - **An identity whose hash no longer resolves re-renders at `422`** with a resubmit message and the submitted values on `actionData`. That is a form held open across a deploy; a 404 would discard what was typed and a silent no-op would show success for a write that never happened.
178
+ - **A bound action declaring `export const method = 'GET'` is a `405`.** A GET action rides its args in the url and is CSRF-exempt, so it cannot answer a form POST. `webjs check`'s `form-action-not-a-get-action` catches it at edit time.
179
+
180
+ The submission is Origin-verified (the same `Sec-Fetch-Site` / `Origin` check the RPC endpoint applies), so a no-JS form needs no CSRF token field.
181
+
182
+ Refusals worth knowing: `formaction=${fn}` is supported on a `<button>` anywhere, bound form or not (#1307: the renderer gives the button its own `formmethod` and `formenctype`), and that button may not carry `name`, `value`, `form`, or a static `formaction` attribute (`<input type="submit">` is refused, because the identity needs its `value`, which is also its label). A bound form may not declare `method="get"`, and a function bound to `action=` that is not a `'use server'` export throws at render rather than producing a form that posts nowhere. See `muscle-memory-gotchas.md` for the full table.
150
183
 
151
184
  ## Error, loading, and 404 boundaries
152
185
 
@@ -156,3 +189,14 @@ How the result is read (server side): a success PRG-redirects with `303` (to a s
156
189
  - Root-only (in `app/` exactly): `global-error.ts` is the app-wide catch-all after nested `error` boundaries are exhausted and renders its OWN `<!doctype><html><body>` (returned verbatim, so keep it static HTML with no components or hydration). `global-not-found.ts` renders for an unmatched-anywhere URL when no `not-found` matches.
157
190
 
158
191
  Metadata routes (`sitemap.ts`, `robots.ts`, `manifest.ts`, `icon.ts`, `apple-icon.ts`, `opengraph-image.ts`, `twitter-image.ts`) live at app root or static segments and default-export a possibly-async function; `sitemap()` / `sitemapIndex()` from `@webjsdev/server` serialize spec-valid XML.
192
+
193
+ The IMAGE metadata routes (`icon`, `apple-icon`, `opengraph-image`, `twitter-image`) default-export a function returning a `Response` with an explicit `content-type`, so an inline SVG needs no asset file (buildless). Then point `metadata` at the route via `openGraph.images` / `twitter.images` / `icons` (or drop a static file in `public/` instead).
194
+
195
+ ```ts
196
+ // app/opengraph-image.ts (OG is 1200x630; apple-icon 180x180)
197
+ export default function OgImage() {
198
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630">...</svg>`;
199
+ return new Response(svg, { headers: { 'content-type': 'image/svg+xml' } });
200
+ }
201
+ // app/page.ts -> export const metadata = { openGraph: { images: ['/opengraph-image'] } };
202
+ ```
@@ -35,9 +35,18 @@ Three seams pick a runtime-specific implementation, all inside the framework, no
35
35
  | Hot reload | `node --watch` | `bun --hot` |
36
36
  | WebSocket | the `ws` library | native `Bun.serve` + a bridge adapter |
37
37
  | 103 Early Hints | yes | no (`Bun.serve` has no informational-response API) |
38
+ | Reverse-proxy headers | `X-Forwarded-Proto` / `X-Forwarded-Host` honored | same |
38
39
 
39
40
  The 103 Early Hints gap costs only a small first-load latency edge where an edge proxy forwards the 103, never correctness. The `modulepreload` hints still ship in the document head on both runtimes.
40
41
 
42
+ Behind a TLS-terminating proxy (Railway, Fly, Render, Cloudflare, nginx), both shells rewrite the request URL from `X-Forwarded-Proto` / `X-Forwarded-Host`, so `ctx.url` in a page, `req.url` in a `route.{js,ts}` handler, and every absolute URL you build from either carry the ORIGINAL scheme and host rather than the internal `http://container` hop. A comma-separated chain (a CDN in front of a load balancer) takes the value closest to the client, only `http` and `https` are accepted as a scheme, and a malformed host is ignored rather than failing the request. This was Bun-only broken before #1090, which shipped an `http://` `og:image` on an HTTPS site.
43
+
44
+ `WEBJS_NO_TRUST_PROXY=1` is the global switch for "nothing trusted sits in front of this container, stop believing these headers". It is read in ONE place and is ABSOLUTE across every forwarded-header read: the URL rewrite, the HSTS scheme check, the CSRF host resolution, and `clientIp` (`x-forwarded-for` / `cf-connecting-ip` / `x-real-ip`) alike. So `rateLimit({ trustProxy: true })` is inert while the flag is set, falling back to the framework-stamped peer address (its default with no option) and warning once per process. The two CAN be set in contradiction, and the direction rule is what resolves it: the flag only ever SUBTRACTS trust and the per-call option can only add it, so the flag wins. Setting both is a misconfiguration that buckets every visitor behind the proxy onto one key, which is what the warning is for. It used to miss the CSRF host, which read `X-Forwarded-Host` regardless; that gap closed in #1104. Setting it on a genuinely proxied deploy is a misconfiguration, and it now shows up as one, since the legacy no-`Sec-Fetch-Site` CSRF fallback compares `Origin` against the raw `Host` and rejects a legitimate cross-host request. The primary `Sec-Fetch-Site` path, which nearly every real browser request takes, never consults the host at all and is unaffected either way.
45
+
46
+ One limit worth knowing: the rewrite belongs to `startServer`. An app embedded through `createRequestHandler` gets the `Request` its host adapter built, so that adapter owns the correction, the same boundary that already applies to the trusted client IP.
47
+
48
+ **Anything SHARED that you derive from `ctx.url` must be keyed by the origin.** Neither Cloudflare nor Railway strips a client-supplied `X-Forwarded-Host`, they forward it, so a hostile value reaches the container through the proxy rather than around it. That is fine for a per-request response (the attacker only poisons their own), and a real problem the moment a response is shared. WebJs handles the cache it owns: the server HTML response cache (`export const revalidate`) folds `url.origin` into every cache key (#1097), so a poisoned body is unreachable from any origin the attacker does not already control. Apply the same rule to anything you cache yourself off `ctx.url`, and note the rule reaches caches WebJs does not own either. A page served `Cache-Control: public` is stored by a CDN under the CDN's OWN key, which does not include `X-Forwarded-Host`, so keying the server cache cannot protect that copy. Derive nothing origin-dependent into a page you make publicly cacheable, or pin the origin from config rather than from `ctx.url`.
49
+
41
50
  ## Scaffolding a Bun app
42
51
 
43
52
  `webjs create <name>` defaults to Node. Add `--runtime bun` for a Bun-flavored app (or run `bun create webjs <name>`, which auto-detects Bun from the invoking package manager):
@@ -46,7 +55,7 @@ The 103 Early Hints gap costs only a small first-load latency edge where an edge
46
55
  webjs create my-app --runtime bun
47
56
  ```
48
57
 
49
- `--runtime` is orthogonal to `--template`, so it re-flavors any of full-stack, saas, or api. A Bun scaffold emits a `bun.lock`, a pure `oven/bun:1` Dockerfile plus a bun-install CI, and bun-command agent docs. The test, db, and check tooling still runs on Node.
58
+ `--runtime` is orthogonal to `--template`, so it re-flavors either full-stack or api. A Bun scaffold emits a `bun.lock`, a pure `oven/bun:1` Dockerfile plus a bun-install CI, and bun-command agent docs. The test, db, and check tooling still runs on Node.
50
59
 
51
60
  ## Running on Bun
52
61
 
@@ -11,7 +11,7 @@ Read this when you want an offline experience or an asset cache in a WebJs app,
11
11
 
12
12
  ## What ships and why it is safe
13
13
 
14
- WebJs's UI scaffolds (full-stack and saas, not the api template) ship a hand-authored service worker at `public/sw.js` and an offline fallback at `public/offline.html`. Both ship **dormant**: they do nothing until the app registers the worker, and the worker only ever registers from JavaScript. So with JS off no worker exists, and pages, links, and forms behave exactly as before. It is opt-in and adds an offline experience plus an asset cache without changing the no-JS baseline.
14
+ WebJs's UI scaffold (full-stack, not the api template) ships a hand-authored service worker at `public/sw.js` and an offline fallback at `public/offline.html`. Both ship **dormant**: they do nothing until the app registers the worker, and the worker only ever registers from JavaScript. So with JS off no worker exists, and pages, links, and forms behave exactly as before. It is opt-in and adds an offline experience plus an asset cache without changing the no-JS baseline.
15
15
 
16
16
  This is a thin, hand-readable worker built directly on the native Service Worker and Cache Storage APIs. There is no Workbox, no precache framework, and no bundler step, matching WebJs's no-build, close-to-web-standards posture. The file is yours to edit, not a framework internal.
17
17
 
@@ -40,7 +40,9 @@ With WebJs's CSP enabled (`webjs.csp` in package.json), stamp the nonce on the s
40
40
  Registered from `/sw.js`, the worker's scope is the site root (`/`), so it sees every navigation and same-origin asset request. Your worker file lives at `public/sw.js`, and although most `public/*` assets serve at `/public/<name>`, the framework serves this one (and `public/offline.html`) at the SITE ROOT with a `Service-Worker-Allowed: /` header, so `register('/sw.js')` resolves to a 200 and the worker controls the whole origin.
41
41
 
42
42
  - **Navigations are network-first.** The worker tries the network first, so the user sees fresh server-rendered HTML, and it caches each successful page (the SSR shell). When the network fails, it serves the cached page if you have visited it, otherwise `/offline.html`. Network-first means the cache never makes a page go stale; it is purely an offline safety net.
43
- - **Static assets are stale-while-revalidate.** Same-origin modules (the per-file ESM the no-build runtime serves), the framework runtime under `/__webjs/core/`, vendor bundles under `/__webjs/vendor/`, and `public/` assets are served from cache when present and refreshed in the background. In production these URLs carry a `?v=<hash>` content fingerprint, so a changed file gets a new URL and the cache can never serve stale bytes.
43
+ - **Static assets are stale-while-revalidate.** Same-origin modules (the per-file ESM the no-build runtime serves), the framework runtime under `/__webjs/core/`, vendor bundles under `/__webjs/vendor/`, and `public/` assets are served from cache when present and refreshed in the background. In production the framework-emitted URLs (modules, the core runtime, vendor bundles) carry a `?v=<hash>` content fingerprint automatically, so a changed file gets a new URL and the cache cannot serve stale bytes for those.
44
+
45
+ A `public/` asset is the exception, and it matters most here. Its URL is fingerprinted only when you mark it with `asset()` (`href=${asset('/public/app.css')}`, see `built-ins.md`). An un-marked `public/` URL is stable across deploys, so the worker keeps serving the CACHED copy and revalidates in the background, which means the first load after a deploy renders the OLD bytes. Do not rely on the worker's cache version to save you: it is derived from the deploy build id, which is a deploy fingerprint rather than a per-file content hash, so a deploy that only changes a `public/` file need not change it. Mark any `public/` asset whose bytes change with `asset()`.
44
46
 
45
47
  **Never cached:** non-GET requests (writes), cross-origin requests, the action RPC endpoint (`/__webjs/action/`), and the dev-only `/__webjs/events` (SSE) and `/__webjs/reload.js`. Keeping writes and RPC off the cache means the worker can never serve a stale mutation result or replay a POST, so correctness is unaffected whether the worker is active or not.
46
48
 
@@ -75,4 +77,4 @@ Or rely on the worker's own update lifecycle to phase it out.
75
77
 
76
78
  After adding the registration snippet, load the app, then open the browser devtools Application panel and confirm a worker is registered and activated for the origin. To exercise the offline path, visit a page (so it caches), then toggle offline in devtools and reload: a previously visited page should serve from cache, and an unvisited URL should render `public/offline.html`. Confirm the JS-off baseline is unchanged by disabling JavaScript and checking that no worker registers and navigation still works as a plain server-rendered app.
77
79
 
78
- Do not register the worker until the offline experience is something you actually want, because a registered worker keeps serving cached shells to returning visitors until its cache is evicted by a new deploy build id.
80
+ Do not register the worker until the offline experience is something you actually want. Once registered, a worker changes caching for returning visitors: navigations stay network-first (an online visitor gets fresh server HTML), assets are served stale-while-revalidate, and the cached page shell is served only when the visitor is offline, until a new deploy build id evicts the cache.
@@ -8,6 +8,7 @@
8
8
  - Design tokens: `:root` / `@theme` in the root layout
9
9
  - Light-DOM host `display: block` behaviour (and shadow hosts via `:host`)
10
10
  - When to use `static styles` (shadow DOM)
11
+ - Accessible native controls (label association, `aria-pressed`, `aria-label`)
11
12
  - `position: fixed`, not `sticky`, for a pinned header (the iOS WebKit flicker)
12
13
  - Even-grid / no-reflow layout tips
13
14
 
@@ -67,9 +68,82 @@ export default function Post({ params }) {
67
68
 
68
69
  Avoid `@apply`: it hides which utilities a class uses and creates a second source of truth. A JS helper keeps the bundle visible at the definition site, composes with conditional classes and active states, and runs at SSR time.
69
70
 
70
- ## Design tokens
71
+ ### A design system for repeated PRIMITIVES: class helpers built on `@webjsdev/ui`
71
72
 
72
- The default stack is a static compiled Tailwind stylesheet (`css:build` compiles `public/input.css` to the linked `public/tailwind.css`, so it works with JS off) plus `@theme` design tokens (palette, fonts, fluid type, motion durations) declared once in the root layout. Consume them as utility classes (`text-foreground`, `bg-card`, `font-serif`, `duration-fast`). If you wire your own theme switch, drive BOTH signals on `<html>` (the `data-theme` attribute for the app palette blocks AND the `.dark` class for the `@webjsdev/ui` kit), or half the UI renders stale tokens. Verify dark mode in a real browser, light mode passing proves nothing about dark.
73
+ An `html`-fragment helper is right for a repeated CHUNK of markup (the rubric above). For a repeated UI PRIMITIVE (button, input, card, badge) that needs variants and sizes, use a class helper instead: a function that returns a Tailwind class STRING you spread onto a native element. That is exactly what `@webjsdev/ui` ships (`buttonClass({ variant, size })`, `cardClass()`, `inputClass()`, `badgeClass({ variant })`), and it is what the scaffold gallery uses in `components/ui/`. To style a ONE-OFF that a variant does not cover (a circular icon button, a pill), compose the helper and override the bespoke bits with `cn()`: `cn(buttonClass({ variant: 'secondary', size: 'none' }), 'w-9 h-9 rounded-full')`. `cn` resolves Tailwind conflicts so a later class wins, including a shorthand over the axis it subsumes (`p-0` beats an earlier `px-4 py-2`), so an override just works. Conflicts are keyed on the CSS PROPERTY wherever `cn` can tell the properties apart, rather than on the shared class prefix, so the common prefix collisions do NOT evict: `cn('border-2', 'border-primary')` keeps both (a width and a colour), `cn('flex', 'flex-1')` keeps both (a `display` and a `flex-grow`, the shape an element that is both a flex container and a flex child needs), and an arbitrary value carrying a type hint is read as the property the hint names (`cn('shadow-lg', 'shadow-[color:red]')` keeps both). It is a small hand-rolled merger, not `tailwind-merge`, so some prefixes are still grouped coarsely and a less common pair can collide (`bg-clip-text` against `bg-primary`, `shadow-lg` against `shadow-red-500`). When an override has to win and you are unsure, pass the one class rather than layering, or install `clsx` + `tailwind-merge` and replace the helper (its header comment shows the swap). For an icon button prefer `size: 'none'` (it states "I supply my own box" by dropping the helper's padding + radius) over layering a `p-0` on top of the default size.
74
+
75
+ ```ts
76
+ // components/ui/button.ts (npx webjsdev ui add button, themed to your app)
77
+ import { cn } from '#lib/utils/cn.ts';
78
+ const BASE = 'inline-flex cursor-pointer items-center justify-center ...';
79
+ const VARIANTS = { default: 'bg-primary text-primary-foreground ...', secondary: '...' } as const;
80
+ const SIZES = { default: 'px-4 py-2 rounded-xl', sm: '...' } as const;
81
+ export function buttonClass(o: { variant?: keyof typeof VARIANTS; size?: keyof typeof SIZES } = {}) {
82
+ return cn(BASE, VARIANTS[o.variant ?? 'default'], SIZES[o.size ?? 'default']);
83
+ }
84
+ ```
85
+
86
+ ```ts
87
+ // a page or component
88
+ import { buttonClass } from '#components/ui/button.ts';
89
+ html`<button class=${buttonClass({ variant: 'secondary', size: 'sm' })} @click=${...}>Reset</button>`;
90
+ ```
91
+
92
+ Why a class helper (not a `<ui-button>` wrapper): it adds NO indirection, so the element stays native (`@click`, `?disabled`, form submission, focus, a11y all just work) and the markup stays readable, while every button shares one source of truth (so no button can forget `cursor-pointer` or drift). Put the affordance every variant needs (like `cursor-pointer`) on the shared BASE.
93
+
94
+ **Default: `npx webjsdev ui add`, then modify. Do not hand-write a primitive from scratch.** For a repeated primitive with variants, run `npx webjsdev ui add <name>` then adapt the copied source (add, remove, restructure, or theme it as your app needs). The scaffold already ships the `cn` prerequisite at `lib/utils/cn.ts`, so `add` works out of the box (a non-scaffold app runs `npx webjsdev ui init` once first to write `components.json`, the `cn` util, and the design tokens). The kit is shadcn-style, so `add` COPIES the helper's source INTO your `components/ui/` and you own and edit it exactly as freely as code you typed yourself. That is the key point: `add`-then-modify and hand-writing end at the SAME place (owned, editable class-helper source), so the difference is only the STARTING POINT. `add` starts you from vetted, variant-complete source you then adapt (and the copied header spells out the primitive's accessibility obligations), where hand-writing starts from a blank file and re-derives all of it for no benefit. You own the copied source and can add, remove, restructure, or theme it however your app needs: change the class values so the helper produces YOUR look (rather than bending your app to the kit's defaults), keep only the parts you use (the gallery's `cardClass` is surface-only, since its panels vary their own padding and layout), and add variants the kit does not ship. Hand-author a primitive yourself ONLY for a one-off the kit does not cover, or a deliberate opt-out of the kit. Reserve `lib/utils/ui.ts` `html`-fragment helpers for repeated markup chunks; reserve `components/ui/*` class helpers for themed primitives with variants.
95
+
96
+ ## Accessible native controls
97
+
98
+ Even with the kit, an app hand-authors SOME markup (a one-off primitive the kit does not cover, or the native element you wrap a class helper around), and there accessibility is your job (the `@webjsdev/ui` primitives carry their own, but a raw `<button>` / `<input>` does not). Three habits keep hand-authored interactive markup accessible on BOTH the JS and no-JS paths:
99
+
100
+ - **Associate a label with its control.** `<label for="email">` paired with `<input id="email">` (or wrap the control in the `<label>`), so a click on the label focuses the field and a screen reader announces it.
101
+ - **State a toggle's pressed state.** A button that toggles carries `aria-pressed=${on}` so assistive tech announces on/off, not just "button".
102
+ - **Name an icon-only button.** A button whose only content is an icon has no accessible name, so give it `aria-label="Delete task"`.
103
+
104
+ Native `<button>` / `<a>` / `<input>` already have correct focus + keyboard behaviour, which is the main reason to prefer them (and the `buttonClass()` / `inputClass()` class helpers) over a custom `<div role>` element.
105
+
106
+ ## Design tokens and theming
107
+
108
+ The default stack is a static compiled Tailwind stylesheet (`css:build` compiles `public/input.css` to the linked `public/tailwind.css`, so it works with JS off) plus `@theme` design tokens declared once in the root layout. You consume them as utility classes (`bg-background`, `text-foreground`, `bg-card`, `border-border`, `font-serif`).
109
+
110
+ **Two halves.** (1) `public/input.css` MAPS token names into Tailwind with `@theme inline` (`--color-background: var(--background)`), so `bg-background` resolves to `var(--background)`. That is infrastructure; leave it. (2) The root layout (`app/layout.ts`) DEFINES the values as plain CSS custom properties in a `<style>` block. That is your palette; make it your own. A freshly cleared app (after `npm run gallery:clear`) ships only the OS system-colour base (`Canvas` / `CanvasText`) with NO tokens, so building this palette is your first styling step.
111
+
112
+ **Light and dark, defined once (DRY).** Write each colour token ONE time with the native CSS `light-dark(LIGHT, DARK)` function and let `color-scheme` pick the side. The default `color-scheme: light dark` follows the OS; a `[data-theme]` attribute forces one. No duplicated light/dark blocks:
113
+
114
+ ```html
115
+ <style>
116
+ :root {
117
+ --font-sans: ui-sans-serif, system-ui, sans-serif;
118
+ color-scheme: light dark; /* follow the OS by default */
119
+ --background: light-dark(#ffffff, #1e2226);
120
+ --foreground: light-dark(#191c20, #dee2e6);
121
+ --card: light-dark(#f7f8fa, #313539);
122
+ --primary: light-dark(#1e2226, #dee2e6);
123
+ --secondary: light-dark(#eef0f3, #3a3f45);
124
+ --muted: light-dark(#f1f3f5, #2a2e33);
125
+ --muted-foreground: light-dark(#565c64, #94989c);
126
+ --accent: light-dark(#e9ecef, #383d43);
127
+ --border: light-dark(#e2e5e9, #3d434b);
128
+ --ring: light-dark(#9aa1a9, #6c737b);
129
+ --destructive: light-dark(#b3261e, #f2b8b5);
130
+ /* a derived token tracks BOTH themes for free via var(--primary) */
131
+ --primary-tint: color-mix(in srgb, var(--primary) 22%, transparent);
132
+ }
133
+ :root[data-theme='light'] { color-scheme: light; } /* the toggle forces a scheme */
134
+ :root[data-theme='dark'] { color-scheme: dark; }
135
+ </style>
136
+ ```
137
+
138
+ `light-dark()` is a native CSS function (CSS Color 5, Baseline 2024), not a library, so nothing to import. A single-theme app drops the `[data-theme]` rules and gives each token one colour.
139
+
140
+ **A manual theme toggle** writes `data-theme` on `<html>` (`light` / `dark`, or removes it for "follow the OS"). If you use `@webjsdev/ui` components, ALSO keep the `.dark` class in sync (the ui kit keys its own tokens off `.dark`), and apply the saved choice in a tiny inline `<script>` in the layout head so there is no first-paint flash. Verify dark mode in a real browser. Light mode passing proves nothing about dark.
141
+
142
+ **Edge cases.** `light-dark()` is COLOUR-only. A colour needed in just one theme sets the unused side to a no-op (`light-dark(#fff, transparent)`). A derived token that references a `light-dark()` one (like `--primary-tint` above) tracks both themes automatically. A NON-colour token that must differ per theme (a shadow's geometry, a gradient, a size, an image) cannot use `light-dark()`; give it a `:root[data-theme='dark']` override plus an `@media (prefers-color-scheme: dark) { :root:not([data-theme]) { ... } }` rule for the OS default.
143
+
144
+ **The ui class helpers build on these tokens.** `buttonClass()` / `cardClass()` / `inputClass()` / `badgeClass()` emit Tailwind utilities that reference the same tokens (`bg-primary`, `border-border`), so theming the tokens re-skins every helper at once.
145
+
146
+ **Focus rings.** The design system applies ONE themed, keyboard-only focus ring globally in the theme CSS: `@layer base { * { @apply border-border outline-ring/50 } }` themes the outline COLOUR to `--ring/50`, and a `:focus-visible { outline: 2px solid color-mix(in oklab, var(--color-ring) 50%, transparent); outline-offset: 2px }` forces a SOLID outline. That second rule matters: `outline-ring/50` alone leaves `outline-style: auto`, so the browser draws its OWN focus ring (which can look thick and white and ignore the colour). So every focusable element (button, link, input) shares one `--ring`-coloured ring with no per-element styling (a native `<button>` renders it a touch wider than a link, a Chromium form-control quirk, but the colour is the same). Do NOT re-add a focus style on a light-DOM element (`buttonClass` deliberately carries none), and NEVER remove it (`outline: none` with no replacement fails WCAG 2.4.7). `:focus-visible` already limits the ring to keyboard / programmatic focus, not a mouse click. A SHADOW-DOM component is the ONE exception: a document rule cannot cross the shadow boundary, so it styles its own focus in `static styles`, matching the global ring EXACTLY (`--ring` at 50%, the same as `outline-ring/50`): `button:focus-visible { outline: 2px solid color-mix(in oklab, var(--color-ring) 50%, transparent); outline-offset: 2px }`. Without it, its controls fall back to the raw browser outline (thick, light on a dark theme, and shown on window-refocus).
73
147
 
74
148
  ## Light-DOM host display, and shadow hosts
75
149
 
@@ -83,6 +157,17 @@ static styles = css`:host { display: block }`; // a shadow host with no :host
83
157
 
84
158
  **Size the HOST, not just an inner wrapper.** The host custom element is the box the parent lays out. A host that is a flex/grid item in a centering parent (`flex justify-center`, `grid place-items-center`) is sized to its content unless it carries width itself. Put the sizing classes on the host (`w-full max-w-[400px]`), not only on an inner `<div>`. Symptom: a board or card renders tiny even though its inner grid says `w-full max-w-[400px]`. Fix: move the sizing onto the host.
85
159
 
160
+ ## Section rhythm: one gap, defined once
161
+
162
+ Per-element margins can never give consistent vertical spacing: each element controls only one side, so a block's gap-above (the previous element's margin) drifts from its gap-below (its own). For a content column (a docs page, a demo page, an article), make the COLUMN own the spacing with a flex stack, and zero the children's own margins so only the one gap applies:
163
+
164
+ ```css
165
+ .stack { --section-gap: 1.5rem; display: flex; flex-direction: column; gap: var(--section-gap); }
166
+ .stack > * { margin: 0; }
167
+ ```
168
+
169
+ Every top-level child (heading, paragraph, component, list) is then equally spaced from ONE variable, and a spacing change is a one-line edit. Flex `gap` beats forcing `display: block` + margins on children: a `grid`/`flex` child keeps its own layout (a blanket `display: block` clobbers it), an inline shadow-DOM host is blockified as a flex item so it honours the gap, a `display: contents` element (a streaming `<webjs-suspense>`) is replaced by its children which become the flex items, and a `display: none` node (a streaming `<script>`/`<template>`) is not an item at all, so it gets no phantom gap. A group that must stay tight (a caption directly above its code block) wraps in one child `<div>` and keeps its own inner spacing. The gallery's `/features` layout (`demo-stack`) is the worked example.
170
+
86
171
  ## Even grids, no reflow
87
172
 
88
173
  The reflow bug (a cell grows when it gets content while the others shrink) comes from `auto`-sized grid rows. Size the tracks explicitly so every cell is an equal fraction regardless of content:
@@ -121,3 +206,32 @@ new ResizeObserver(apply).observe(hdr);
121
206
  ```
122
207
 
123
208
  For a dashboard, an alternative is an app-shell scroll container (a non-scrolling `100dvh` flex column with `<main>` as the internal scroller), which needs no offset but changes the scroll model.
209
+
210
+ ### A fixed header and a modal that locks scroll
211
+
212
+ Anything that locks page scroll (a modal, a drawer, an off-canvas menu) hides the page scrollbar, and a classic scrollbar takes real layout width, so hiding it widens the viewport. The usual compensation is padding the body, which holds in-flow content still. **It does nothing for a fixed header**, because a fixed box lays out against the initial containing block, never against the body's padding box, so the header widens with the viewport and its centred content slides right by half the scrollbar width.
213
+
214
+ `@webjsdev/ui`'s `<ui-dialog>` / `<ui-alert-dialog>` handle this for you (#1144). Their scroll lock reserves the scrollbar gutter for its duration, so the viewport width never changes and nothing moves. It leaves the gutter alone if your page already declared its own `scrollbar-gutter`, on the assumption that a page which made that choice meant it.
215
+
216
+ Engines differ, and the lock does not need to know which one it is on. Where the gutter is honoured (measured on Chromium) nothing moves and the lock does nothing else. Where it is ignored (measured on WebKit) the viewport does widen, so the lock measures how much, pads `<html>` by it to hold in-flow content still (added to any padding you already had, and restored on close), and publishes the amount as `--wj-scrollbar-compensation` so a fixed element can opt in with one line:
217
+
218
+ ```css
219
+ header { position: fixed; inset-inline: 0; top: 0; border-right: var(--wj-scrollbar-compensation, 0px) solid transparent; }
220
+ ```
221
+
222
+ A transparent border rather than `padding-right`, for two reasons. It composes with whatever padding the element already has, where a padding form has to restate that base value and restate it again per responsive variant. And a background still paints across a border, so a header that carries its own background stays full bleed instead of ending short of the edge. Declare it after your Tailwind link if you use the `border-*` utilities, since those set `border-right-color` too.
223
+
224
+ The property is only set while a lock is active AND the viewport actually widened, so the `0px` fallback covers every other moment and the two mechanisms never double-compensate. If you find inline `padding-right` on your `<html>` while a modal is open, that is this, and it comes off on close.
225
+
226
+ **Put it on the element that is both viewport-width and painting.** Both halves are load-bearing, and getting either wrong fails quietly.
227
+
228
+ - **Viewport-width**, or a left-aligned child still moves. Insetting a `max-width` container holds its CENTRED children still but not its leading ones, because the container's own box is not what widened. This is easy to miss: measure a left-aligned child, not just a centred one.
229
+ - **Painting**, or the background stops short of the widened edge. Insetting a wrapper that paints nothing insets the child that does, which leaves an unpainted strip.
230
+
231
+ In this repo `examples/blog` has one element that is both (the fixed header paints its own chrome, so the border goes straight on it), and `website` has a non-painting fixed wrapper around a painting header around a centring bar, where the header is the one that qualifies.
232
+
233
+ Rolling your own scroll lock? Three things the kit learned the hard way, and the first is that almost every implementation of this (including Next's own dev overlay and Radix) gets the fixed case wrong by design.
234
+
235
+ 1. Reserve the gutter rather than relying on padding alone, or a fixed element will jump however carefully you compensate the body.
236
+ 2. When you do pad, pad `<html>`, not `<body>`. A `max-width` body does not widen when the viewport does, so padding it misses the shift entirely, while padding the root holds in-flow content whatever the body's width is.
237
+ 3. Measure the root's own border box to decide the amount. `documentElement.clientWidth` can grow while nothing actually moved (Chromium reports exactly that under a reserved gutter), and a `position: fixed` probe reads its pre-lock box on WebKit until the next rendering update, so both mislead.
@@ -4,7 +4,7 @@
4
4
 
5
5
  - The four test layers (unit, browser, e2e, smoke) and where each file lives.
6
6
  - The `handle()` harness from `@webjsdev/server/testing` for driving the real request pipeline against a native `Response`.
7
- - `webjs test` and `webjs test --browser`, plus when a browser or e2e test is REQUIRED (hydration, client router, slots, custom-element upgrade).
7
+ - `npm run test` and `npm run test:browser`, plus when a browser or e2e test is REQUIRED (hydration, client router, slots, custom-element upgrade).
8
8
  - Bun cross-runtime parity for runtime-sensitive code.
9
9
  - Rendering the app and LOOKING for visual defects a static check cannot catch (a collapsed or reflowing layout).
10
10
  - Convention validation with `webjs check`.
@@ -24,15 +24,44 @@ Feature folders are primary, and the test kind is a subfolder inside the feature
24
24
 
25
25
  Assert only on what the layer needs. A block that inspects only the HTTP response, the SSR HTML string, headers, or the importmap does NOT need a browser. Keep in the browser suite only blocks that genuinely need a DOM (live state via `page.evaluate`, hydration, client-router nav, slots, view transitions, streaming into the DOM, custom-element upgrade).
26
26
 
27
+ ### A browser test that clicks a real link or submits a real form MUST cancel the default
28
+
29
+ web-test-runner aborts the whole SESSION, not one file, when the page navigates. So a test that clicks a real `<a href>` or submits a real `<form>` is a single point of failure for every browser test file you have: whenever the client router loses the race to intercept, the browser performs the real navigation and the run dies reporting `0 failed` and then exiting non-zero, which reads as an infrastructure blip rather than a test problem. Cancel the default so an interception gap fails ONE test on its own assertion.
30
+
31
+ Register the canceling listener on `window` in the BUBBLE phase. That is the last step of the propagation path, so it runs after the router's own document-level listeners, and `preventDefault()` still cancels the default action because that action runs only once dispatch completes.
32
+
33
+ **Never use the capture phase.** Capture sets `defaultPrevented` before the router ever sees the event, and the router returns immediately on that flag (the same guard that lets a component's `@click` opt out). Every guarded router test then passes while testing nothing. Capture is correct only when suppressing the router is the actual goal, for a test that exercises a menu or a drawer rather than navigation; say so in a comment when you do it, because it looks identical to the mistake. Do not reach for `stopPropagation` either, which hides the click from the router and turns the assertion into a tautology.
34
+
35
+ Cancel a form on its `submit` event, not on the submit control's `click`. The form's default action fires on submit, so canceling the click stops the form from ever submitting and the router never sees it.
36
+
37
+ Resolve the anchor from `e.composedPath()`, not `e.target.closest('a[href]')`. The listener is on `window`, so a click inside a shadow root (a `static shadow = true` component) arrives retargeted to the host, and `closest()` walks only light-tree ancestors and never finds the link, which fails open exactly where the router itself handles the case. A pure-fragment `href="#x"` link needs no guard at all, since it never navigates the page away.
38
+
39
+ There is a SECOND channel a click guard cannot reach: the router assigns `location.href` when it degrades a soft navigation, and `preventDefault` does not cancel a script assignment. Do not try to intercept `location.href` itself, which is non-configurable on Chromium, Firefox, and WebKit alike, so its setter cannot be redefined on any of them. Listen for `webjs:navigation-fallback` on `document` and assert none fired; its `cause` is the diagnosis.
40
+
41
+ It is a few lines, so write it in your suite's setup and detach it in teardown:
42
+
43
+ ```js
44
+ const onClick = (e) => {
45
+ for (const el of e.composedPath()) {
46
+ if (el instanceof HTMLAnchorElement && el.hasAttribute('href')) { e.preventDefault(); return; }
47
+ }
48
+ };
49
+ const onSubmit = (e) => e.preventDefault();
50
+ window.addEventListener('click', onClick); // bubble, never capture
51
+ window.addEventListener('submit', onSubmit);
52
+ ```
53
+
54
+ (The WebJs framework repo keeps its own copy of exactly this in one shared module, `test/browser-nav-guard.js`, whose `installNavGuard()` returns `{ fallbacks, hardNavigations, remove }`. That module is framework-repo infrastructure and is not part of a scaffolded app.)
55
+
27
56
  ## App runners (`webjs test`)
28
57
 
29
58
  ```sh
30
- webjs test # runtime test runner over everything not under browser/ or e2e/
31
- webjs test --browser # web-test-runner against test/**/browser/**
32
- WEBJS_E2E=1 webjs test # adds the e2e layer
59
+ npm run test # unit + browser tests (both layers; e2e only with WEBJS_E2E=1)
60
+ npm run test:browser # web-test-runner against test/**/browser/**
61
+ WEBJS_E2E=1 npm run test # adds the e2e layer
33
62
  ```
34
63
 
35
- `webjs test` dispatches on the runtime (`node --test` on Node, `bun test` on Bun). The scaffold's `web-test-runner.config.js` globs `test/**/browser/**/*.test.js` and is already wired, so you do not set it up.
64
+ `npm run test` dispatches on the runtime (`node --test` on Node, `bun test` on Bun). The scaffold's `web-test-runner.config.js` globs `test/**/browser/**/*.test.js` and is already wired, so you do not set it up.
36
65
 
37
66
  A scaffolded app has one root `test/` directory shaped the same way (feature first, kind second):
38
67
 
@@ -48,11 +77,11 @@ test/
48
77
 
49
78
  ## The `handle()` harness (`@webjsdev/server/testing`)
50
79
 
51
- `createRequestHandler({ appDir }).handle(request)` drives the FULL request pipeline (middleware, routing, SSR, page actions, server-action RPC, auth, CSRF) and returns a native `Response`. It is the same entry the framework's own suite uses, so the most realistic way to test an app is to fire a `Request` through it and assert on the `Response`, with no spawned process and no network. `@webjsdev/server/testing` ships thin builders over that `handle()`, each a few lines over native `Request` / `Response` that reuse the REAL cookie names, header names, and wire serializer. For a browser test that needs to drive the app in a real DOM, `createBrowserTestHandler()` from `@webjsdev/server/testing` exposes the same `handle()` pipeline to the WTR Chromium session.
80
+ `createRequestHandler({ appDir }).handle(request)` drives the FULL request pipeline (middleware, routing, SSR, form-dispatched actions, server-action RPC, auth, CSRF) and returns a native `Response`. It is the same entry the framework's own suite uses, so the most realistic way to test an app is to fire a `Request` through it and assert on the `Response`, with no spawned process and no network. `@webjsdev/server/testing` ships thin builders over that `handle()`, each a few lines over native `Request` / `Response` that reuse the REAL cookie names, header names, and wire serializer. For a browser test that needs to drive the app in a real DOM, `createBrowserTestHandler()` from `@webjsdev/server/testing` exposes the same `handle()` pipeline to the WTR Chromium session.
52
81
 
53
82
  ```js
54
83
  import { createRequestHandler } from '@webjsdev/server';
55
- import { testRequest, invokeActionForTest, rawActionRequest, loginAndGetCookies, withSessionCookie }
84
+ import { testRequest, submitForm, invokeActionForTest, rawActionRequest, loginAndGetCookies, withSessionCookie }
56
85
  from '@webjsdev/server/testing';
57
86
 
58
87
  const app = await createRequestHandler({ appDir: process.cwd(), dev: true });
@@ -77,6 +106,28 @@ const dash = await testRequest(app.handle, '/dashboard', withSessionCookie({}, c
77
106
  assert.equal(dash.status, 200);
78
107
  ```
79
108
 
109
+ ### `submitForm`: the no-JS write path
110
+
111
+ Use it for any page whose form binds an action (`<form action=${myAction}>`). A bound form carries a hidden `__webjs_action` field holding the action's identity, and that field is what tells the dispatcher which action to run, so a hand-written `POST` that omits it is not a form submission at all and is answered **405**. `submitForm` renders the page, reuses the identity the server put there, and posts it back, which is exactly what a browser with JS off does.
112
+
113
+ ```js
114
+ import { submitForm } from '@webjsdev/server/testing';
115
+
116
+ // success is a 303 PRG
117
+ const ok = await submitForm(app.handle, '/signup', { name: 'Ada', email: 'ada@example.com', password: 'hunter2' });
118
+ assert.equal(ok.status, 303);
119
+ assert.equal(ok.headers.get('location'), '/dashboard');
120
+
121
+ // a failure result re-renders the SAME page at 422 with the result on actionData
122
+ const bad = await submitForm(app.handle, '/signup', { email: 'not-an-email' });
123
+ assert.equal(bad.status, 422);
124
+ assert.match(await bad.text(), /Enter a valid email/);
125
+ ```
126
+
127
+ Options: `cookies` (submit as a logged-in user, paired with `loginAndGetCookies`), `match` (a string or RegExp the form's markup must contain, for a page with several forms), `index` (pick by position instead), `submitPath` (render one page, submit to another), and `headers`.
128
+
129
+ Do not hand-roll the identity scrape. When it is wrong the symptom is a 405, an assertion fails, and a surrounding `catch` reports it as a database that was never migrated, so the test goes quiet rather than red.
130
+
80
131
  ### `invokeActionForTest`: round-trip an action through the REAL endpoint
81
132
 
82
133
  ```js
@@ -110,11 +161,37 @@ A layout bug (a board that collapses, cells of unequal size, a grid that resizes
110
161
 
111
162
  WebJs runs on Node 24+ or Bun. The Node suite is the source of truth; an additive Bun matrix re-runs the runtime-sensitive suite under Bun to catch the long tail of cross-runtime incompatibilities (a `node:*` API Bun implements differently, a crypto or stream edge case, an error-message-format quirk).
112
163
 
113
- Bun parity is part of the definition of done. A change to a runtime-sensitive surface (the serializer, the `node:http` vs `Bun.serve` listener and request path, SSR / action / CSRF dispatch, streams, `node:crypto`, the TS stripper, auth / session / cors) is NOT done until you run the Bun matrix green AND add or update a `test/bun/<feature>.mjs` cross-runtime assertion. Run it with `node scripts/run-bun-tests.js` (needs `bun` on PATH).
164
+ If your app targets Bun, Bun parity is part of the definition of done. A change to a runtime-sensitive surface (the serializer, the `node:http` vs `Bun.serve` listener and request path, SSR / action / CSRF dispatch, streams, `node:crypto`, the TS stripper, auth / session / cors) is NOT done until you also run your suite under the Bun runtime (needs `bun` installed) and add a cross-runtime assertion for the touched surface.
165
+
166
+ ### Writing a plain proof script that boots a server
167
+
168
+ A cross-runtime proof is often a plain assert script rather than a test file, so the SAME file runs under `node script.mjs` and `bun script.mjs`. If it boots a real server through `startServer`, know what carries its verdict: the process EXIT CODE, since nothing is collecting results for you. WebJs makes that code trustworthy: a fatal shutdown, which is where a failed top-level assertion lands on both runtimes, exits 1, and so does a drain that rejects or is still hanging at the 10s deadline. The process exits 0 in one case only, an operator-requested stop that drained cleanly with no crash on the way out, so a proof script that finishes green really did finish green. Two habits keep it that way:
169
+
170
+ - **Assert the exit code, never the logs.** These scripts conventionally pass a `quiet` logger, so anything that only logs is invisible. If your script catches its own failure, report it with an explicit `process.exit(1)` rather than a `console.error` alone, guarded as `if (import.meta.main) process.exit(1); else throw failure;`. The guard matters when a `.test.mjs` wrapper imports the script under `node --test`: an unguarded exit kills the whole single-process run and hides every other file's results, while the throw lets the harness report one failed test.
171
+ - **Prove the script can FAIL before you trust it passing.** Break one assertion on purpose and confirm the run exits non-zero. A proof that cannot go red is worse than no proof: it reports success forever.
172
+
173
+ ## Proving display-only elision did not break anything
174
+
175
+ WebJs strips the JavaScript of every component that does no client work, so a wrong verdict costs an app real interactivity and does it silently. Two commands cover the two halves, and you need both.
176
+
177
+ ```sh
178
+ webjs elision --verify
179
+ ```
180
+
181
+ renders every static page route with elision on and off and diffs the served bytes. It is the framework's own differential guard pointed at your route table, and it exits non-zero on a divergence AND on a corpus where nothing could be compared, so it belongs in CI. It forces the ON side on rather than reading your config, and reports how many modules elision actually dropped, so a pass that compared two identical renders is visible rather than silent. Dynamic routes are skipped by name; add real paths with `--routes /,/blog/hello`.
182
+
183
+ That proves the bytes you SERVE did not change. It cannot prove post-hydration behaviour, because a wrongly dropped module shows up as a dead click, not as different bytes. Run your own browser or e2e suite twice for that half:
184
+
185
+ ```sh
186
+ WEBJS_ELIDE=1 npm run test:e2e
187
+ WEBJS_ELIDE=0 npm run test:e2e
188
+ ```
189
+
190
+ A test that passes under one and fails under the other is a wrong verdict, and `webjs elision` tells you which module and on what evidence. If the component's interactivity is genuinely invisible to static analysis, the fix is `static interactive = true` on it; see `components.md` for what that override does and does not rescue.
114
191
 
115
192
  ## Convention validation (`webjs check`)
116
193
 
117
- `webjs check` is the correctness validator. Every rule catches code that is wrong to ship (a crash, a security leak, a type-strip failure), plus the `no-scaffold-placeholder` sentinel for unreplaced scaffold content. Run it and fix every violation before considering the change done (`webjs check --json` for an agent loop, `webjs check --rules` to list the rules). It is separate from `CONVENTIONS.md`, which carries the customizable project conventions you follow by judgment.
194
+ `npm run check` is the correctness validator. Every rule catches code that is wrong to ship, a crash, a security leak, a reactive prop that silently stops re-rendering, or a type-strip failure. Run it and fix every violation before considering the change done (`npm run check -- --json` for an agent loop, `npm run check -- --rules` to list the rules). It is separate from `CONVENTIONS.md`, which carries the customizable project conventions you follow by judgment.
118
195
 
119
196
  ## What NOT to do
120
197