@webjsdev/mcp 0.1.13 → 0.1.15

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.
@@ -85,7 +85,7 @@ TodoList.register('todo-list');
85
85
 
86
86
  ### Author the optimistic mutation as a degrade-first form
87
87
 
88
- Wrap the mutation in a REAL `<form>` bound to the action, then intercept it for the optimistic path. One form serves both: with JS off the browser submits and the server dispatches to that action (the no-JS write path, see `routing-and-pages.md`), and with JS on `@submit` calls `e.preventDefault()` and runs the optimistic path. That is the progressive-enhancement contract, not a fetch-only handler.
88
+ Wrap the mutation in a REAL `<form>` bound to the action, then intercept it for the optimistic path. One form serves both: with JS off the browser submits and the server dispatches to that action (the no-JS write path, see `routing-and-pages.md`), and with JS on `@submit` calls `e.preventDefault()` and runs the optimistic path. That is the progressive-enhancement contract, not a fetch-only handler. Note the arrow wrapper on the listener: WebJs does not bind an `@event` handler to your component, so passing the method directly would leave `this` pointing at a framework-internal object (see `muscle-memory-gotchas.md`).
89
89
 
90
90
  The SAME imported function is the form binding and the optimistic path's callee, which is what makes a degrade-first form cheap to write: there is no second wiring to keep in step.
91
91
 
@@ -94,7 +94,7 @@ import { createTodo } from '#modules/todo/actions/create-todo.server.ts';
94
94
 
95
95
  render() {
96
96
  return html`
97
- <form action=${createTodo} @submit=${this.handleSubmit}>
97
+ <form action=${createTodo} @submit=${(e: SubmitEvent) => this.handleSubmit(e)}>
98
98
  <input name="title" required>
99
99
  <button>Add</button>
100
100
  </form>
@@ -130,19 +130,31 @@ The component reads that seeded prop as its `optimistic()` `source`, so `source:
130
130
  For a boolean toggle where the value itself is the mutation (like, follow, pin), `optimistic(signal, value, action)` is a thin wrapper over the signal primitive.
131
131
 
132
132
  ```ts
133
- import { signal, optimistic } from '@webjsdev/core';
133
+ import { WebComponent, prop, signal, optimistic, html } from '@webjsdev/core';
134
134
  import { likePost } from '#modules/posts/actions/like-post.server.ts';
135
135
 
136
- const liked = signal(false);
137
- // in an @click handler:
138
- const result = await optimistic(liked, true, () => likePost(postId));
139
- // `liked` flips to true instantly. If likePost THROWS or returns
140
- // { success: false }, `liked` rolls back to its prior value: the throw
141
- // re-throws, and the { success: false } result is returned so you can
142
- // read its error / fieldErrors. On success the optimistic value stays;
143
- // reconcile to the authoritative value from `result` if you need it.
136
+ class LikeButton extends WebComponent({ postId: prop(String) }) {
137
+ // INSTANCE scope: one signal per element. A module-scope `signal()` is SHARED
138
+ // across every instance (invariant 5), so a feed of these would all flip
139
+ // together on one click. Scope per-item state to the instance.
140
+ private liked = signal(false);
141
+
142
+ private async toggle() {
143
+ const next = !this.liked.get();
144
+ // Returns the action's ActionResult, so a { success: false } is readable
145
+ // by the caller. The rollback has already happened by then.
146
+ return optimistic(this.liked, next, () => likePost(this.postId));
147
+ }
148
+
149
+ render() {
150
+ return html`<button @click=${() => this.toggle()}>${this.liked.get() ? 'Liked' : 'Like'}</button>`;
151
+ }
152
+ }
153
+ LikeButton.register('like-button');
144
154
  ```
145
155
 
156
+ Reserve MODULE scope for state that genuinely is app-wide (a theme, a cart count, a sidebar-open flag), which is the case invariant 5's "module-scope signals share state across components" exists to serve. Per-item state goes on the instance, as above. `liked` is a plain instance field, not a reactive property declared in the factory, so the class-field ban does not reach it and `reactive-props-no-class-field` does not flag it. `gallery/modules/optimistic-ui/components/like-button.ts` is the same shape in shipped code.
157
+
146
158
  It rolls back on a thrown error OR an `ActionResult` `{ success: false }` envelope, and never on success. It is client-only (it mutates a signal), so a component importing it is never elided as a display-only component.
147
159
 
148
160
  ## When Optimistic UI Is Appropriate
@@ -18,6 +18,30 @@ Pages and layouts run **only on the server** to produce HTML. They do NOT hydrat
18
18
 
19
19
  `route.ts` is the one routing file that is NOT isomorphic: a server-only HTTP handler, never shipped to the client.
20
20
 
21
+ ### How much of the page belongs in the component
22
+
23
+ "Put every interactive behaviour in a component" is not "put the section containing it in a component". Because a page never hydrates, the markup it renders costs the browser nothing, so a page that keeps its static content and delegates only the interactive fragment is both the cheapest and the conventional shape:
24
+
25
+ ```ts
26
+ // app/products/[id]/page.ts
27
+ export default async function Product({ params }: PageProps<'/products/[id]'>) {
28
+ const product = await getProduct(params.id);
29
+ return html`
30
+ <article>
31
+ <h1>${product.name}</h1>
32
+ <p>${product.description}</p>
33
+ <spec-table .rows=${product.specs}></spec-table>
34
+
35
+ <add-to-cart product-id=${product.id}></add-to-cart>
36
+
37
+ <review-list .reviews=${product.reviews}></review-list>
38
+ </article>
39
+ `;
40
+ }
41
+ ```
42
+
43
+ Only `<add-to-cart>` ships. `<spec-table>` and `<review-list>` are display-only, so the framework elides them and the browser fetches neither. Wrapping the whole article in a `<product-page>` component to "own the page" would ship all three, because a component rendered by a component that ships can no longer be elided. The sizing rule and a before/after are in `components.md` under "Sizing an island".
44
+
21
45
  ## Pages (`app/**/page.ts`)
22
46
 
23
47
  The default export is a possibly-async function receiving `{ params, searchParams, url, actionData }`. It returns a `TemplateResult`; it never calls `render()` itself.
@@ -63,6 +87,7 @@ export default async function User({ params }: { params: { id: string } }) {
63
87
 
64
88
  - `[param]/page.ts` dynamic segment, read via `params.param`.
65
89
  - `[...rest]/page.ts` catch-all, `[[...rest]]/page.ts` optional catch-all.
90
+ - Overlapping routes resolve by positional specificity, for pages and `route.ts` handlers alike: segment by segment, a static segment beats `[param]`, which beats a catch-all, so `api/auth/callback/github/route.ts` answers before `api/auth/[...path]/route.ts` whatever the directory order.
66
91
  - `(group)/...` route group: the folder is NOT in the URL but still scopes layout / error.
67
92
  - `_private/...` private folder: ignored by the router.
68
93
 
@@ -113,6 +138,30 @@ export async function generateMetadata(ctx: MetadataContext): Promise<Metadata>
113
138
 
114
139
  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.
115
140
 
141
+ ### Search and share defaults (SEO)
142
+
143
+ Set `SITE_URL` (the app's public origin, e.g. `https://shop.example.com`) in production. It is the default `metadataBase`, and with it every page gets, unless it sets its own:
144
+
145
+ - `<link rel="canonical">` = site URL + the page's pathname (query dropped). Opt one page out with `alternates: { canonical: null }`. Without a site URL no canonical is emitted, because the request host can be set by a client.
146
+ - `og:title` / `og:description` from `title` / `description`, `og:type` `website`, `og:url` = the canonical.
147
+ - `og:image` from the nearest `opengraph-image` route above the page (and `twitter:image` from `twitter-image`), when the page declares no image.
148
+ - `twitter:card`: `summary_large_image` when there is an image, else `summary`.
149
+
150
+ `openGraph` and `twitter` take Next's shapes: `images` is a string, `URL`, `{ url, width, height, alt, type }` or an array of those (emits `og:image` + `og:image:width` / `height` / `alt`); camelCase keys become snake_case (`siteName` -> `og:site_name`); `publishedTime` / `modifiedTime` / `authors` / `section` / `tags` emit `article:*`. `metadataBase` may be a string or a `URL`.
151
+
152
+ So a public page needs only a specific title and description:
153
+
154
+ ```ts
155
+ export const metadata: Metadata = {
156
+ title: 'Opening hours',
157
+ description: 'When the bakery is open, including public holidays.',
158
+ };
159
+ ```
160
+
161
+ A signed-in or private page says `robots: { index: false }` (put it on the private section's layout and every page below inherits it); `pages()` then leaves it out of the sitemap too.
162
+
163
+ Structured data goes in `jsonLd` (from `generateMetadata` when it depends on a record): one schema.org object or an array, escaped safely into `<script type="application/ld+json">`.
164
+
116
165
  ## Control-flow throws
117
166
 
118
167
  From `@webjsdev/core`: throw to short-circuit a page / layout render or a form-bound action.
@@ -171,6 +220,8 @@ export default function Contact({ actionData }: {
171
220
 
172
221
  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
222
 
223
+ **Typed values survive a failed submission without extra code (#1581).** On a failure re-render `actionData.values` carries EVERY submitted text field, with any `values` the action returned layered on top (the action's own win, so it can normalize or blank a field). So `value=${values.x}` refills a field the action never echoed, with JavaScript off. With JavaScript on, the client router goes further: it snapshots the submitted form and, when the non-2xx re-render is applied in place, puts back what was typed in every text input, textarea, select, radio and checkbox whose server-rendered default did not change. A control the server rendered differently on purpose (refilled, normalized, reset) keeps the server's value. Passwords, files and hidden inputs are never restored. Opt a form out with `data-preserve-values="false"`. Refilling from `actionData.values` is still the right habit, since it is what the no-JS path shows; a checkbox group or multi-select repeats its name, and `values` keeps only the last one, so read those from the submitted `FormData` in the action.
224
+
174
225
  Three responses that are not the happy path:
175
226
 
176
227
  - **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.
@@ -187,12 +238,80 @@ Refusals worth knowing: `formaction=${fn}` is supported on a `<button>` anywhere
187
238
 
188
239
  - `error.ts` default-exports `({ error, ...ctx }) => TemplateResult`; catches sibling-page and deeper render errors, innermost wins (prod sends only `error.message`).
189
240
  - `loading.ts` wraps the sibling page in `Suspense` with an immediately-flushed fallback.
190
- - `not-found.ts` / `forbidden.ts` / `unauthorized.ts` render the nearest matching boundary for the thrown control-flow signal.
191
- - 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.
241
+ - `not-found.ts` / `forbidden.ts` / `unauthorized.ts` render the nearest matching boundary for the thrown control-flow signal, and receive the same ctx a page does (`params`, `searchParams`, `url`).
242
+ - **Every one of those boundaries renders INSIDE the layouts at and above its own segment** (#1298), so it carries the keyed `wj:children` pairs and a client-router navigation into a failing page stays a SOFT navigation with the surrounding chrome and its hydrated state intact. A layout deeper than the boundary is not rendered (it never rendered on the way in), and its module is not in the boundary's boot script; the boundary's own module IS. Since a boundary sits inside its own segment's layout, it cannot catch that layout, matching Next's `layout -> error -> page` hierarchy. What happens next differs by path, and the difference is worth knowing:
243
+
244
+ - On the **500 path**, a throwing layout is handled by the next `error.{js,ts}` OUT: the walk tries each boundary in the chain, innermost first, and a layout that throws fails every attempt whose wrapped set contains it, so control ends at `global-error` (or the default 500 page) when they are exhausted.
245
+ - On the **404 / 403 / 401 paths** there is NO outward walk. Each renders the one nearest boundary, so a throwing layout degrades that response to a chrome-less standalone render of the boundary with no boot script, keeping its status. A control-flow throw from a wrapped layout there (an auth-gate layout calling `redirect('/login')`) is discarded rather than honoured, deliberately: the status is already decided and the boundary page is the answer to that request.
246
+
247
+ A genuine layout crash is reported to `onError` (and to the dev overlay on the 404 / 403 / 401 paths, where nothing else claimed the frame) rather than being swallowed. Repeats of the SAME cause within one request collapse to a single report, because one shared layout can fail every boundary attempt (and, when the layout is what threw, arrives again as the error that produced the 500). The key is the STAGE plus the error's name, message and construction site: the stack below that site records how the throw was reached and differs on every re-render, so it cannot be part of the key, and the stage is what keeps `global-error`'s own crash from being swallowed by a boundary that failed through the same helper. Two DIFFERENT failures are both reported, and anything whose key cannot be derived safely (a non-Error throw) is always reported rather than risking a drop. A control-flow sentinel never is, since it is routing rather than a crash.
248
+
249
+ The BOUNDARY FILE's own crash (it throws, or fails to import at all) is reported the same way, and its response body follows the framework's standard rule for a thrown error: shown in dev, withheld in prod, where the page carries only its status. A thrown message is not author-controlled and may name a driver, a path or a connection string, so it does not reach the client; sanitizing the response never means losing the failure.
250
+
251
+ Two further consequences: a layout that fetches runs its fetch again on a boundary response, and a `<webjs-suspense>` inside a wrapped layout shows its fallback, because a boundary response is buffered so its status is final before the first byte. A 404 for a URL that matched NO route has no chain to wrap in and stays a bare document.
252
+ - 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). That verbatim document is exactly why it is the one boundary left UNWRAPPED: a second shell would nest inside the root layout's, wrapping it would re-run the code that just threw, and with no boot script it could not soft-swap anyway. `global-not-found.ts` renders for an unmatched-anywhere URL when no `not-found` matches.
253
+
254
+ 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 (a nested one answers under its segment: `app/blog/opengraph-image.ts` is `/blog/opengraph-image`) and default-export a possibly-async function that receives `{ request, url, siteUrl, pages }`. `siteUrl` is `SITE_URL`, else the request origin. `sitemap` may return an ARRAY of entries (urls may be paths, resolved against `siteUrl`) and `robots` an OBJECT (Next's `MetadataRoute.Robots` shape); the server serializes them. `sitemap()` / `sitemapIndex()` / `robots()` from `@webjsdev/server` are the same serializers, for a route that builds its own Response.
255
+
256
+ `pages()` lists every public page as sitemap entries: each static page (route groups dropped), each dynamic page that exports `generateSitemapParams()`, and none whose static metadata chain says `robots: { index: false }`. So the sitemap follows the app as pages are added:
257
+
258
+ ```ts
259
+ // app/sitemap.ts
260
+ import type { MetadataRouteContext } from '@webjsdev/server';
261
+ export default async function sitemap({ pages }: MetadataRouteContext) {
262
+ return pages();
263
+ }
264
+
265
+ // app/products/[id]/page.ts: list the products this route serves
266
+ export async function generateSitemapParams() {
267
+ return (await listProducts()).map((p) => ({ params: { id: p.id }, lastModified: p.updatedAt }));
268
+ }
269
+
270
+ // app/robots.ts
271
+ import type { MetadataRouteContext } from '@webjsdev/server';
272
+ export default function robots({ siteUrl }: MetadataRouteContext) {
273
+ return { rules: { userAgent: '*', allow: '/', disallow: ['/account', '/api/'] }, sitemap: `${siteUrl}/sitemap.xml` };
274
+ }
275
+ ```
276
+
277
+ `generateSitemapParams()` returns params objects (`[{ id: '1' }]`) or entries carrying them (`[{ params: { id: '1' }, lastModified }]`).
278
+
279
+ 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).
280
+
281
+ ### App icon and manifest (replace the placeholder)
282
+
283
+ **Every app needs its OWN icon.** `webjs create` ships `app/icon.svg` as a neutral PLACEHOLDER (a grey tile with a dashed frame, marked `data-webjs-placeholder="icon"`) and `app/manifest.webmanifest` with the app's name. Replacing the placeholder is part of building the app, not polish: until you do, the tab, the bookmark and the home-screen icon look like every other unfinished app, and `webjs doctor` warns (`APP_ICON`). Draw a simple symbol for what the app IS (a grid for a tic-tac-toe game, a cup for a cafe, a check for a task list), in the app's own colours, on a 32x32 or 24x24 `viewBox`: a filled rounded tile in the primary colour with one bold shape in its foreground colour reads at 16px. Avoid thin strokes (under 2px at 32px), text longer than one letter, and detail that blurs at tab size. Then set `name`, `short_name`, `theme_color` and `background_color` in `app/manifest.webmanifest` to match.
284
+
285
+ The icon conventions, all at the app ROOT and all auto-linked into `<head>` when the app declares no `metadata.icons`:
286
+
287
+ | File | Served at | Linked as |
288
+ |---|---|---|
289
+ | `app/icon.svg` / `icon.png` / `icon.ico` | `/icon.svg` ... | `<link rel="icon">` with `type`, `sizes="any"` (SVG) or the PNG's real pixel size |
290
+ | `app/apple-icon.png` (180x180) | `/apple-icon.png` | `<link rel="apple-touch-icon" sizes="180x180">` (iOS needs PNG, not SVG) |
291
+ | `app/icon.ts` / `apple-icon.ts` | `/icon`, `/apple-icon` | bare link (the route picks its content type per request) |
292
+ | `app/manifest.webmanifest` / `manifest.json` / `manifest.ts` | `/manifest.webmanifest`, `/manifest.json` | `<link rel="manifest">` (`metadata.manifest` wins; `manifest: null` opts out) |
293
+ | `app/favicon.ico` | `/favicon.ico` | not linked; browsers request it themselves |
294
+
295
+ `/favicon.ico` always answers: `public/favicon.ico`, else `app/favicon.ico`, else the app's icon (raster preferred over SVG, then the `icon.ts` route), so a crawler or feed reader that reads no markup gets the same icon. A static icon file wins the link over an icon route when both exist (the route still serves at its URL). Raster icons are linked before SVG, because Google's favicon crawler takes the first usable icon and wants a square raster. Use `icon.ts` only when the mark must be computed per request (per theme, per tenant); a route can render a PNG for `apple-icon.ts` the same way.
296
+
297
+ Declaring `metadata.icons` **suppresses** all of the above rather than merging with them, which is what Next does with its static icon files. So name icons explicitly only when they live elsewhere (a CDN, `public/`):
298
+
299
+ ```ts
300
+ // app/layout.ts -> these win; app/icon.* and app/apple-icon.* are no longer linked
301
+ export const metadata = {
302
+ icons: {
303
+ icon: [
304
+ { url: '/public/favicon-192.png', type: 'image/png', sizes: '192x192' },
305
+ { url: '/public/favicon.svg', type: 'image/svg+xml', sizes: 'any' },
306
+ ],
307
+ apple: { url: '/public/apple-touch-icon.png', sizes: '180x180' },
308
+ },
309
+ };
310
+ ```
192
311
 
193
- 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.
312
+ Never write a favicon as a hand-written `<link rel="icon">`: only the root layout may write a shell at all (invariant 8), so a hand-written tag is unavailable to every other layout.
194
313
 
195
- 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).
314
+ `opengraph-image` and `twitter-image` are LINKED too, Next's behaviour: a page that declares no `openGraph.images` gets `og:image` pointing at the nearest `opengraph-image` route above it (absolute against the site URL), and likewise `twitter:image`. A page that declares its own image keeps it.
196
315
 
197
316
  ```ts
198
317
  // app/opengraph-image.ts (OG is 1200x630; apple-icon 180x180)
@@ -200,5 +319,5 @@ export default function OgImage() {
200
319
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630">...</svg>`;
201
320
  return new Response(svg, { headers: { 'content-type': 'image/svg+xml' } });
202
321
  }
203
- // app/page.ts -> export const metadata = { openGraph: { images: ['/opengraph-image'] } };
322
+ // every page without its own image now carries og:image = <site>/opengraph-image
204
323
  ```
@@ -20,7 +20,7 @@ Pick a runtime from the deploy target, not the code. Default to Node unless you
20
20
  Three seams pick a runtime-specific implementation, all inside the framework, none in your app:
21
21
 
22
22
  - **The listener.** `startServer` selects the `node:http` request shell on Node and a native `Bun.serve` shell on Bun. Both parse the request, run middleware, dispatch to your routes, and stream the response through the same downstream pipeline, so an SSR page, a server action RPC, and a route handler behave identically.
23
- - **The type stripper.** WebJs serves `.ts` / `.tsx` as ES modules by erasing the types in place with no bundler. On Node that is the built-in `module.stripTypeScriptTypes`; on Bun it is `amaro` (the same engine, byte-identical and position-preserving so stack traces still point at the right line). Either way your TypeScript must be erasable (see `typescript.md`).
23
+ - **The type stripper.** WebJs serves `.ts` / `.mts` as ES modules by erasing the types in place with no bundler. Those two are the whole set: there is no JSX anywhere in the framework, so a `.tsx` file is not served. On Node that is the built-in `module.stripTypeScriptTypes`; on Bun it is `amaro` (the same engine, byte-identical and position-preserving so stack traces still point at the right line). Either way your TypeScript must be erasable (see `typescript.md`).
24
24
  - **A few built-ins.** SQLite, hot reload, and WebSockets each bind to the runtime's native primitive (see the table).
25
25
 
26
26
  ## Node vs Bun at a glance
@@ -32,11 +32,24 @@ Three seams pick a runtime-specific implementation, all inside the framework, no
32
32
  | Listener | `node:http` shell | native `Bun.serve` (faster on the listening path only, not end-to-end, because SSR render dominates a real page) |
33
33
  | TS strip | built-in `module.stripTypeScriptTypes` | `amaro` (byte-identical, position-preserving) |
34
34
  | SQLite | built-in `node:sqlite` + `drizzle-orm/node-sqlite` | built-in `bun:sqlite` + `drizzle-orm/bun-sqlite` |
35
- | Hot reload | `node --watch` | `bun --hot` |
35
+ | Hot reload | restart on change (the `webjs dev` supervisor, #1521) | in place in one long-lived process (`bun --hot` resets the module registry, #1575); only an `instrumentation.*` / `env.*` edit restarts it |
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
+ | Dev edit to a page / layout | full reload (the dev restart replaces the process) | refreshes IN PLACE, no reload (#1398) |
38
39
  | Reverse-proxy headers | `X-Forwarded-Proto` / `X-Forwarded-Host` honored | same |
39
40
 
41
+ **`webjs dev` lets an idle host sleep (#1507).** The live-reload stream sends nothing between edits (no keepalive) and is held open only while a tab showing the app is visible, so a sandbox or preview host that suspends on network quiet can suspend with a backgrounded dev tab open. Showing the tab reconnects, and an edit made meanwhile reloads the page on return. A host that counts an OPEN request as activity (a sandbox that suspends on idle) also needs `"webjs": { "dev": { "reloadIdle": 20 } }` (or `WEBJS_DEV_RELOAD_IDLE=20`): after that many seconds with no edit and no interaction the stream closes, and the next interaction, a tab showing, or an embed-bridge host command (`{ source: 'webjs-embed-host', type: 'resume' }`) reopens it. Off by default. Every reconnect also compares the server's state with the state the page on screen was rendered at, so an edit whose reload signal was lost while the stream was being replaced (a host that closes a held stream when it wakes) still reloads the page.
42
+
43
+ **The in-place dev refresh (#1398) needs the server process to SURVIVE the edit,** which is the whole of the Node-versus-Bun difference in that row. A page or layout never hydrates, so a freshly rendered page is the complete truth for it and the client router can swap it in without a reload, keeping scroll and (for a page edit) the hydrated state of components outside the changed region. The server classifies the changed file and puts the verdict on the live-reload event, so this needs a process that is still alive to do the classifying.
44
+
45
+ Bun keeps ONE dev server process for the whole session (#1575), so it gets the refresh. `bun --hot` re-runs the CLI on a change; the first run's server owns the process (its listener, live-reload stream, watchers and analysis caches) and a re-run only tells it the module registry was reset, so nothing is started twice and memory stays flat over hundreds of edits (it used to grow about 25 MB an edit until `bun --hot` stopped reloading). The app's modules load through a `Bun.plugin` that reads them fresh, and its `#` imports resolve through the app's own `imports` map, because Bun keeps the old source of a file that was replaced (an atomic save) and a stale directory listing for a new file next to a `*.server.*` module. So `bun --hot` no longer sees app edits itself, and the dev server asks it for a registry reset after an edit to a module some other module imports, a new module, or a `*.server.*` module, all without a process restart. A page, layout or route handler nothing imports needs no reset: the dev re-import is keyed by the file's content, so an unchanged file reuses its loaded module (no new module instance per request) and an edited one is a new import. `instrumentation.*` and `env.*` run once per process, so an edit to one restarts the server on both runtimes. On Node the `webjs dev` supervisor (it replaced `node --watch` in #1521) RESTARTS the server process on a change under `app`, `components`, `modules`, `lib`, or `actions`, or to a root `middleware.{ts,js,mts,mjs}`, and a fresh process holds no record of what changed, so those edits are always a full reload. Two Node cases still refresh in place: an edit OUTSIDE that watched set (`db/schema.server.ts`, a `webjs.dev.watch` content dir), and running `npm run dev -- --no-hot`, which keeps the server in one process on either runtime. An in-place refresh loads the rebuilt stylesheets (a `webjs.dev.regenerate` compile runs on that request) BEFORE it swaps the new markup in, and drops the old sheets only after, so an element that gained a utility class never paints without its rule (#1535; a full reload never had the gap, since a head stylesheet is render-blocking). A component edit is a full reload everywhere by design, because `customElements.define` is once-per-tag and swapping fresh markup onto the old class would be worse than the reload.
46
+
47
+ After a reset, app code that imports `@webjsdev/server` gets a fresh copy of the package while the first run's server keeps handling requests. Everything the two copies must agree on (the request `auth()`, `cookies()` and `headers()` read, the action signal, the seed collector and action identity, the default cache store, sessions, broadcast clients) lives in process-wide state keyed by `Symbol.for`, so a signed-in page and the `'use server'` queries it calls still see the request after any number of edits (#1590). Before that fix, `auth()` without an explicit request returned `null` in app code after the first edit.
48
+
49
+ **`webjs dev` and `webjs start` serve the directory they are started in, and refuse anywhere else (#1526).** Run them in the app directory, the one holding `app/`. In a workspace (`apps/web` under a root `package.json` with `workspaces`) that is the member, even when the CLI is hoisted to the root `node_modules`: the hoisted bin and the Bun `--hot` child both keep the directory they were started in. Started where there is no `app/` (the workspace root, or a subdirectory such as `app/` itself), both exit 1 before any `before` step runs, naming the app to start (`cd apps/web && webjs dev`), instead of booting a server that answers 404 for every route.
50
+
51
+ **`webjs dev` does not stay down (#1521).** The supervisor and the server's own watcher handle every watcher error: a file in a watched dir the dev server cannot read or watch (the 0600 temp file `sed -i` creates when another user runs it, a file removed mid-scan) logs one `file watcher skipped <path> (EACCES)` warning and both keep running, where `node --watch` used to crash and leave the preview dead. Edits that REPLACE a file (`sed -i`, an editor saving through a temp file, an atomic write) are heard every time, however often the same file is replaced (#1529: on Linux under Node the watchers watch each directory, since Node 24's own recursive watcher stopped hearing a file after its first replacement). A server process that crashes is started again on the next file change, or by itself after a backoff of 0.5s growing to 10s for repeated crashes. On Bun the supervisor restarts the server only for an `instrumentation.*` / `env.*` edit (#1575), and otherwise does only the crash recovery. An agent writing files the way an AI editor does (bursts, partial writes, syntax errors then fixes, renames, deletes, atomic writes) is exercised by `scripts/dev-reload-stress.mjs` (`node scripts/dev-reload-stress.mjs <appDir> <url>` against any running app). Stopping `webjs dev` (Ctrl-C, SIGTERM) stops the server child too, and a child whose supervisor was killed outright exits on its own, so nothing is left holding the port.
52
+
40
53
  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.
41
54
 
42
55
  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.
@@ -55,7 +68,7 @@ One limit worth knowing: the rewrite belongs to `startServer`. An app embedded t
55
68
  webjs create my-app --runtime bun
56
69
  ```
57
70
 
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.
71
+ `--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-slim` Dockerfile plus a bun-install CI, and bun-command agent docs. The test, db, and check tooling still runs on Node.
59
72
 
60
73
  ## Running on Bun
61
74
 
@@ -66,18 +79,24 @@ bun install
66
79
  bun run dev # or: bun run start
67
80
  ```
68
81
 
69
- `bun --bun` overrides the `webjs` bin's Node shebang so the server runs on Bun, selecting the native `Bun.serve` listener and `amaro` type stripping. The app's dependencies resolve from `node_modules` exactly as on Node. The `start.before` migrate step (`webjs db migrate`) runs under Bun too. Commit the `bun.lock` for reproducible, offline installs. The scaffold's Bun Dockerfile runs `bun install` and serves via `CMD ["bun", "--bun", "run", "start"]`.
82
+ `bun --bun` overrides the `webjs` bin's Node shebang so the server runs on Bun, selecting the native `Bun.serve` listener and `amaro` type stripping. The app's dependencies resolve from `node_modules` exactly as on Node. The `start.before` migrate step (`webjs db migrate`) runs under Bun too. Commit the `bun.lock` for reproducible, offline installs. The scaffold's Bun Dockerfile runs `bun install --production` and serves via `CMD ["bun", "--bun", "run", "start"]`.
83
+
84
+ **What the dev watcher ignores.** On both runtimes a change to a file nothing serves never reloads the page: `*.log`, `coverage/`, `.cache/`, `test-results/`, `playwright-report/`, and anything the app's `.gitignore` ignores (except `.env*`). So `npm run dev > dev.log` in the app folder is safe.
70
85
 
71
86
  ## Deploying either runtime
72
87
 
73
88
  Production runs `npm run start` (Node) or `bun run start` (Bun), which serves the source directly with no build step. Both speak plain HTTP/1.1, so put a reverse proxy or platform edge in front for TLS and HTTP/2 (production perf leans on HTTP/2 multiplexing plus `modulepreload` hints, not a bundle). A `start.before` migrate runs first on both runtimes.
74
89
 
75
- The scaffold ships a matching Dockerfile per runtime: a Node image for the default, a pure `oven/bun:1` image for `--runtime bun`. Commit the lockfile the runtime uses (`package-lock.json` for Node, `bun.lock` for Bun) so the deploy install is reproducible and offline.
90
+ The scaffold ships a matching Dockerfile per runtime: a Node image for the default, a pure `oven/bun:1-slim` image for `--runtime bun`. Both install production dependencies only and leave the package manager's cache out of the image. What the boot runs is a production dependency for that reason: `drizzle-kit` (the `start.before` migrate) and, on a UI app, `@tailwindcss/cli` + `tailwindcss` (the CSS compile). Keep a tool the boot runs in `dependencies`; one moved to `devDependencies` is missing from the image and the boot fails. Commit the lockfile the runtime uses (`package-lock.json` for Node, `bun.lock` for Bun) so the deploy install is reproducible and offline.
76
91
 
77
92
  ## SQLite busy_timeout
78
93
 
79
94
  Both `node:sqlite` and `bun:sqlite` default `busy_timeout` to 0, so a contended write throws `database is locked` immediately. The generated connection sets `PRAGMA busy_timeout = 5000` plus `PRAGMA journal_mode = WAL` on the raw client before Drizzle wraps it, on both runtime branches, so you get a sane 5-second wait instead of an instant failure. This is already wired in the scaffold's `db/connection.server.ts`.
80
95
 
96
+ ## Postgres pool (`--db postgres`)
97
+
98
+ A `--db postgres` app opens one `pg` Pool in `db/connection.server.ts`. It holds no session state between queries (no `SET`, `LISTEN`, or advisory lock kept across them), so the same code works behind a transaction pooler. Idle connections close after 10 seconds, a connection attempt gives up after 5 seconds (a database that is down fails the request instead of hanging it), `DATABASE_POOL_MAX` caps the pool (default 10), and an idle connection the server drops (a restart or failover) is logged rather than crashing the process. Keep it that way: do not add `SET` statements or session-scoped features to app code.
99
+
81
100
  ## Verifying a runtime-sensitive change
82
101
 
83
102
  Most app code needs no runtime-specific testing, because it does not touch a runtime seam. If you DO change something runtime-sensitive (the serializer, a stream, `node:crypto`, low-level request handling, anything that behaves differently under `Bun.serve` versus `node:http`), prove it on both runtimes. The Node suite is the source of truth, and an additive Bun matrix re-runs the runtime-sensitive tests under Bun. See `testing.md` for the cross-runtime matrix and the `test/bun/**` assertions.
@@ -36,7 +36,20 @@ When custom CSS IS unavoidable inside a light-DOM component, the tag-prefix inva
36
36
 
37
37
  ## DRY via a JS helper, not `@apply`
38
38
 
39
- When the same Tailwind bundle repeats across 2+ places, extract it into a helper in `lib/utils/ui.ts` that returns an `html` fragment (SSR-time, no client runtime, output identical to inline classes):
39
+ When the same Tailwind bundle repeats across 2+ places, extract it into a helper that returns an `html` fragment (SSR-time, no client runtime, output identical to inline classes). Where the helper LIVES follows the narrowest-owner rule, so pick the tier by who consumes it:
40
+
41
+ | Consumers | Home |
42
+ |---|---|
43
+ | routes across the app (a heading, a lede, a back link) | `lib/utils/ui.ts` |
44
+ | one feature (a todo row, a comment card, a board) | `modules/<feature>/utils/ui/<name>.ts` |
45
+
46
+ One file per fragment under `utils/ui/`, because a feature accumulates several and one-per-file keeps them greppable. A fragment promotes from the feature tier to `lib/` only when a second feature genuinely consumes it.
47
+
48
+ The app-wide tier grows the same way, on the same judgment `references/module-structure.md` applies to any module. `lib/utils/ui.ts` is where it starts and where it usually stays: small, independent, one-element helpers belong together in one file, however many of them there are (the blog example keeps nine there quite happily). Split to `lib/ui/<name>.ts`, one file per fragment, when a fragment stops being a one-liner, when one composes others, or when the single file is no longer scannable. The framework's own website crossed that line and its four composed page fragments live in `lib/ui/`.
49
+
50
+ So `modules/<feature>/utils/ui/`, `lib/utils/ui.ts`, and `lib/ui/` are one convention at three sizes rather than three conventions, and the `ui` segment is the part carrying the meaning at every one of them: inside `modules/<feature>/`, `components/` holds custom elements, `utils/ui/` holds functions returning a `TemplateResult`, and the rest of `utils/` holds functions returning data. Drop the segment and a view fragment ends up beside a pure data helper with nothing in the path to tell them apart.
51
+
52
+ The example below is the app-wide tier:
40
53
 
41
54
  ```ts
42
55
  import { html } from '@webjsdev/core';
@@ -62,15 +75,47 @@ export default function Post({ params }) {
62
75
  | Repeats | Action |
63
76
  |---|---|
64
77
  | Once | Inline the classes. |
65
- | 2 to 3 times, identical | Extract to `lib/utils/ui.ts`. |
78
+ | 2 to 3 times, identical, inside ONE feature | Extract to `modules/<feature>/utils/ui/<name>.ts`. |
79
+ | 2 to 3 times, identical, across features or routes | Extract to `lib/utils/ui.ts`. |
66
80
  | Varies by 1 to 2 props | Extract with a small parameter (`mb: 'sm' \| 'md'`). |
67
81
  | Radically different per call site | Keep inline, do not force-fit. |
68
82
 
69
83
  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.
70
84
 
85
+ ### Fragment or display-only component?
86
+
87
+ Two questions, in order.
88
+
89
+ **First, is this a UNIT or a repeated CLASS BUNDLE?** The helpers this section began with (a heading, a lede, a back link) are the second kind: one element with a class list you did not want to type twice. That is a fragment by definition and never a component; nobody wants `<page-lede>` as a tag in their DOM, and promoting a class bundle to an element is the same over-reach as absorbing a page section into an island. The question below only arises for a genuine unit of markup (a row, a card, a board) that could reasonably be either.
90
+
91
+ **Second, for a unit: can the markup carry an extra wrapper element at all?** A component is a tag in the DOM, so choosing one adds a node between the parent and the markup. Usually that is fine and the component is the better choice, since it gets a tag name to target and can grow behaviour later. Two cases make it impossible outright:
92
+
93
+ | Case | What happens |
94
+ |---|---|
95
+ | a `<table>` / `<tbody>` child | the parser FOSTER-PARENTS the element out of the table (an HTML spec rule, not a WebJs one), so it lands BEFORE the table and its cells are adopted by a `<tr>` it no longer owns. The component never renders where you put it |
96
+ | output that is not DOM | a `<webjs-stream>` payload, or HTML a `route.ts` returns, is a STRING, and a component has no way to produce one |
97
+
98
+ Three more render fine and are wrong in ways that surface later, so treat them as strong reasons rather than hard blocks. Measured in Chromium, the element survives in all three:
99
+
100
+ | Case | What survives, what breaks |
101
+ |---|---|
102
+ | a `<ul>` / `<ol>` / `<dl>` child | the list renders, but `ul > li`, `:nth-child`, and list markers now see the wrapper instead of the row |
103
+ | a `<select>` child | the control still offers a wrapped `<option>` (it is in `select.options`), but it is no longer `select > option`, so selector-based CSS and DOM code miss it, and the content model is invalid |
104
+ | a `grid` / `flex` child | the wrapper becomes THE ITEM, so the children you meant to lay out sit one level too deep and the track sizing applies to the wrong box |
105
+
106
+ Everywhere else the two are interchangeable, and the remaining difference is cost. Be precise about that too, because the obvious summary ("fragments are free, components ship") is wrong in both directions.
107
+
108
+ **Rendered only by pages, they cost the same: nothing.** A page is inert or import-only, so a fragment it calls runs at SSR and is never fetched. A component that does no client work is elided, so it is never fetched either. Byte arguments do not decide this case; pick whichever reads better.
109
+
110
+ **Rendered by a shipping island, BOTH ship.** A module a shipping component imports is fetched, fragment or not, so the fragment's function is downloaded too (verify it yourself: watch the network panel for the helper's path). What differs is what else comes with it. The component ships a custom element class plus its registration and upgrades once per instance, and, because elision propagates downward, it un-elides every display-only component IT renders. The fragment ships a function and stops there.
111
+
112
+ So near an island the fragment is the smaller and more predictable choice, not a free one, and the gap is a class and its blast radius rather than everything. That is a tiebreaker, not a rule: it is worth acting on where a shipping island is or might become the renderer, and not worth reorganising page-level markup over. When it matters, measure with `webjs elision` rather than reasoning from either rule of thumb.
113
+
114
+ **The summary.** A repeated class bundle is always a fragment. For a real unit, take the fragment where a wrapper element cannot exist (a table child, or string output) and where it would land wrong (a list, select, grid, or flex child); take the component whenever the markup needs behaviour, wants a tag to target, or might grow either; and treat the byte difference as the last consideration rather than the first.
115
+
71
116
  ### A design system for repeated PRIMITIVES: class helpers built on `@webjsdev/ui`
72
117
 
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), `cn('shadow-lg', 'shadow-red-500')` keeps both (a box-shadow and its colour), `cn('bg-clip-text', 'bg-primary')` keeps both (a clip and a colour, so the gradient-text idiom survives a later background), 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 it is still coarse in two ways. A prefix outside the families it knows is not grouped at all, so both classes are emitted and the winner is left to compiled stylesheet order (`inset-shadow-sm` against `inset-shadow-red-500`, `ring-2` against `ring-red-500`). And where one prefix carries two properties it reads the value against Tailwind's DEFAULT scales, so a `@theme`-extended name it cannot know about can still be misread and evict the wrong class: a custom `--shadow-card` makes `shadow-card` a box-shadow, but `cn` sees an unfamiliar name under a prefix whose bare names are usually colours and treats it as one. 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.
118
+ 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), `cn('shadow-lg', 'shadow-red-500')` keeps both (a box-shadow and its colour), `cn('bg-clip-text', 'bg-primary')` keeps both (a clip and a colour, so the gradient-text idiom survives a later background), 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 it is still coarse in two ways. A prefix outside the families it knows is not grouped at all, so both classes are emitted and the winner is left to compiled stylesheet order (`inset-shadow-sm` against `inset-shadow-red-500`, `ring-2` against `ring-red-500`). And where one prefix carries two properties it reads the value against Tailwind's DEFAULT scales, so a `@theme`-extended name it cannot know about can still be misread and evict the wrong class: a custom `--shadow-card` makes `shadow-card` a box-shadow, but `cn` sees an unfamiliar name under a prefix whose bare names are usually colours and treats it as one. 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. Under the opt-in `webjsui lint` (configured by a `lint` block in `components.json`, see `references/ui-kit.md`), `w-9 h-9` is layout and `rounded-full` is shape in the shadcn category taxonomy, so an app running it sets `no-restyle` to `allow: ["layout", "rounded"]` for this idiom to pass: the plain radius group is granted by name without opening the whole `shape` category, and `border-2` beside a helper still fires.
74
119
 
75
120
  ```ts
76
121
  // components/ui/button.ts (npx webjsdev ui add button, themed to your app)
@@ -109,6 +154,19 @@ The default stack is a static compiled Tailwind stylesheet (`css:build` compiles
109
154
 
110
155
  **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
156
 
157
+ **`@theme` and `@theme inline` differ in whether the token reaches `:root`, and the difference is silent.** Measured on `tailwindcss@4.3.0`, a token mapped in a theme block is emitted as a real `:root` custom property when:
158
+
159
+ | block | token used only through a utility (`border-border`) | token written as a raw `var(--color-x)` in any SCANNED file | token unused |
160
+ |---|---|---|---|
161
+ | `@theme` | emitted | emitted | not emitted |
162
+ | `@theme inline` | NOT emitted (the value is substituted into the utility) | emitted | not emitted |
163
+
164
+ The one cell that bites is `inline` plus utility-only usage. Nothing on the page can then inherit `--color-x`, so a raw `var(--color-x)` written somewhere Tailwind never scanned resolves to nothing and the declaration falls back to its initial value (a border or outline silently becomes `currentColor`).
165
+
166
+ "Scanned" is wider than it looks, and this is the part worth knowing: Tailwind scans source files as raw text, so a `var(--color-ring)` inside a component's `static styles` template DOES count and forces emission, exactly like one in the stylesheet. That is why the `@webjsdev/ui` kit theme works despite using `inline`. So the rule is not "shadow components need a plain `@theme`". It is: **if a token is only ever used through utilities, and something outside the scanned source needs to inherit it, map that token with a plain `@theme`.** Anything under a configured `@source` is scanned and needs no special handling.
167
+
168
+ Whichever form you use, a token nothing references is dropped in both, so an unused mapping is dead configuration rather than a safety net.
169
+
112
170
  **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
171
 
114
172
  ```html
@@ -135,7 +193,7 @@ The default stack is a static compiled Tailwind stylesheet (`css:build` compiles
135
193
  </style>
136
194
  ```
137
195
 
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.
196
+ `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. `webjs doctor` reports the floor as `DARK_THEME_UNREACHABLE` (the scaffold gates it `error`): an app whose tokens have a light value only, with no `light-dark()`, no `prefers-color-scheme` rule and no theme script in the root layout, because the scaffold stylesheet's `.dark` block would then be dead CSS and the OS dark setting ignored.
139
197
 
140
198
  **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
199
 
@@ -63,6 +63,8 @@ WEBJS_E2E=1 npm run test # adds the e2e layer
63
63
 
64
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.
65
65
 
66
+ An app with no browser tests yet (for example right after `npm run gallery:clear`) is not a failure: `webjs test --browser` sees that no file matches the config's `files` globs, prints `no browser tests yet`, and exits 0, the same way the server layer passes with zero files. So `npm run ci` stays green until you write the first browser test, and from then on the browser layer runs as normal.
67
+
66
68
  A scaffolded app has one root `test/` directory shaped the same way (feature first, kind second):
67
69
 
68
70
  ```
@@ -189,6 +191,17 @@ WEBJS_ELIDE=0 npm run test:e2e
189
191
 
190
192
  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.
191
193
 
194
+ ## One command for every layer (`webjs ci`)
195
+
196
+ ```sh
197
+ npm run ci # the webjs.ci list: check, doctor, typecheck, audit, then every test layer
198
+ npm run ci -- --only Tests # one step or group by title
199
+ npm run ci -- --fail-fast # stop at the first failure
200
+ npm run ci -- --json # one JSON document for an agent loop
201
+ ```
202
+
203
+ The scaffold declares its gate once, in `package.json` under `webjs.ci`, and `npm run ci` runs it with a timed result line per step (the Rails `bin/ci` model). The generated GitHub workflow runs the same list, so a green local run predicts CI. Run it before every push; the pre-commit hook deliberately runs none of it so a commit stays fast. Browser and e2e steps need a Chromium on the machine (`npx playwright install chromium`, plus `puppeteer-core` for the e2e layer), which the workflow installs for itself. The list, the flags, the `--signoff` merge gate, and the JSON shape are in `references/built-ins.md` under "Local CI".
204
+
192
205
  ## Type-checking your tests (`webjs typecheck`)
193
206
 
194
207
  Your tests are inside the tsconfig `include`, so `npm run typecheck` reads them (#1299). Treat a type error in a test as a failed gate, not a review catch: the checker sees a wrong argument shape or an unannotated parameter in a test the same way it sees one in `app/`.
@@ -52,13 +52,38 @@ So the loop is: `add` the component, then query `ui <name>` (MCP) or
52
52
  that ships inside the installed `@webjsdev/ui`, with no network. This pins you
53
53
  to the installed version; run `npx webjsdev ui diff` to see where your local copies
54
54
  drift from the upstream (that command alone compares against the live registry).
55
+ - `npx webjsdev ui lint` is an OPT-IN design-system linter over the app's own
56
+ source. It reads the Tailwind classes in `html` templates, `cn()` calls and
57
+ `class=${...}` holes and reports, at the line, a raw palette colour where the
58
+ theme declares a role token (`no-raw-colors`, with a message naming only the
59
+ `--color-*` tokens the configured `tailwind.css` actually declares), an
60
+ arbitrary value such as `p-[13px]` (`no-arbitrary-values`; an arbitrary
61
+ VARIANT like `[&_svg]:size-4` never fires), and a class composed over a kit
62
+ helper (`no-restyle`, naming the helper's real variants and sizes read from
63
+ the app's copied `components/ui/*.ts`). It is off until `components.json`
64
+ carries a `lint` block, and with no block it reports nothing and exits 0.
65
+ `components/ui/**` is skipped by default (a copied primitive owns structural
66
+ values no variant expresses), and `allow` uses shadcn's category taxonomy
67
+ (`layout`, `color`, `typography`, `spacing`, `shape`, `effects`, `motion`) or
68
+ a class-group id such as `rounded`. `--json` emits `{ violations, summary }`
69
+ for an agent loop; `--max-warnings <n>` pins a count.
70
+
71
+ ```json
72
+ "lint": {
73
+ "rules": {
74
+ "no-raw-colors": "warn",
75
+ "no-arbitrary-values": { "severity": "warn", "allow": ["layout"] },
76
+ "no-restyle": { "severity": "error", "allow": ["layout", "rounded"] }
77
+ }
78
+ }
79
+ ```
55
80
 
56
81
  ## Inventory (run `npx webjsdev ui list` or the MCP `ui` tool for the authoritative, current set)
57
82
 
58
83
  **Tier 1 (class helpers):** accordion, alert, aspect-ratio, avatar, badge,
59
- breadcrumb, button, card, checkbox, collapsible, input, kbd, label,
60
- native-select, pagination, popover, progress, radio-group, separator, skeleton,
61
- switch, table, textarea.
84
+ breadcrumb, button, card, checkbox, collapsible, empty, field, input, kbd,
85
+ label, native-select, pagination, popover, progress, radio-group, separator,
86
+ skeleton, spinner, switch, table, textarea.
62
87
 
63
88
  **Tier 2 (custom elements, own their ARIA):** alert-dialog, dialog,
64
89
  dropdown-menu, hover-card, sonner, tabs, tooltip, plus toggle and toggle-group
@@ -68,9 +93,35 @@ dropdown-menu, hover-card, sonner, tabs, tooltip, plus toggle and toggle-group
68
93
 
69
94
  - A helper is a function, so compose it: `class=${buttonClass({ variant: 'outline' })}`.
70
95
  The unquoted `${...}` is a normal `html` attribute hole.
96
+ - The three states a screen owes its user each have a primitive, so do not hand-roll
97
+ them. A list or table with no rows renders `empty` (title as a real heading, a
98
+ description saying what to do next, and the create action). A form field is
99
+ `field` (`fieldClass()` holding a `<label for>`, the control, `fieldDescriptionClass()`
100
+ text, and on a validation error `data-invalid="true"`, `aria-invalid` on the control,
101
+ and a `fieldErrorClass()` message with `role="alert"` whose id the control's
102
+ `aria-describedby` lists, with the typed value kept). A pending save puts
103
+ `spinner({ decorative: true })` in a `disabled` + `aria-busy="true"` submit button
104
+ that keeps its text. The `fieldClass` / `fieldLabelClass` in `components/ui/field.ts`
105
+ are not the same-named rhythm helpers in `lib/utils/cn.ts`; import each from its own
106
+ module and alias one if a file needs both.
71
107
  - Tier-1 helpers assume the design tokens exist; if a component paints unstyled,
72
108
  the tokens are missing (re-run `npx webjsdev ui init` or let `add` self-heal them).
73
109
  - Custom elements are display-only-safe at SSR and hydrate in the browser, the
74
110
  standard WebJs component model (`references/components.md`).
111
+ - A registry module should do no work at module scope, because the elision
112
+ analyser reads a module-scope call or a `document` reference as client work
113
+ and then the page that imports it ships whole (#1320). `cn` itself is clean,
114
+ so importing it never pins a page. Six modules still trip the analyser and DO
115
+ pin an importing page: `checkbox`, `radio-group`, `pagination`, `progress`,
116
+ `sonner`, `tabs`. The first two inject a stylesheet for real; the other four
117
+ are an analyser precision gap (an arrow with an expression body puts its call
118
+ at brace depth 0). Either way the page ships, so treat the list as fact rather
119
+ than as a technicality. Keep your own copies clean when you edit them, and run
120
+ `npx webjsdev elision`, which names the blocker whenever a page ships.
121
+ - `native-select`'s `<option>` colours ride the design tokens, not the module.
122
+ An app with no theme block gets the browser default `<option>` colours along
123
+ with everything else unstyled, fixed the same way (re-run `init`, or let `add`
124
+ plant the block). An app whose block predates the rule keeps the default until
125
+ the rule is added by hand, because `init` never rewrites an existing block.
75
126
 
76
127
  Full per-package reference lives in the installed `@webjsdev/ui/AGENTS.md`.