@webjsdev/mcp 0.1.14 → 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.
- package/package.json +1 -1
- package/resources/AGENTS.md +98 -34
- package/resources/SKILL.md +57 -20
- package/resources/corpus.json +3 -3
- package/resources/references/auth-and-sessions.md +15 -0
- package/resources/references/built-ins.md +95 -3
- package/resources/references/client-router-and-streaming.md +54 -5
- package/resources/references/components.md +57 -5
- package/resources/references/data-and-actions.md +3 -1
- package/resources/references/module-structure.md +229 -0
- package/resources/references/muscle-memory-gotchas.md +31 -5
- package/resources/references/optimistic-ui.md +23 -11
- package/resources/references/routing-and-pages.md +107 -9
- package/resources/references/runtime.md +24 -5
- package/resources/references/styling.md +49 -4
- package/resources/references/testing.md +13 -0
- package/resources/references/ui-kit.md +39 -3
- package/src/mcp-source.js +148 -6
- package/src/mcp.js +9 -2
|
@@ -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,19 +238,66 @@ 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
|
-
-
|
|
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
|
+
```
|
|
192
276
|
|
|
193
|
-
|
|
277
|
+
`generateSitemapParams()` returns params objects (`[{ id: '1' }]`) or entries carrying them (`[{ params: { id: '1' }, lastModified }]`).
|
|
194
278
|
|
|
195
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).
|
|
196
280
|
|
|
197
|
-
|
|
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.
|
|
198
296
|
|
|
199
|
-
Declaring `metadata.icons` **suppresses** the
|
|
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/`):
|
|
200
298
|
|
|
201
299
|
```ts
|
|
202
|
-
// app/layout.ts -> these win; /icon and /apple-icon are no longer linked
|
|
300
|
+
// app/layout.ts -> these win; app/icon.* and app/apple-icon.* are no longer linked
|
|
203
301
|
export const metadata = {
|
|
204
302
|
icons: {
|
|
205
303
|
icon: [
|
|
@@ -211,9 +309,9 @@ export const metadata = {
|
|
|
211
309
|
};
|
|
212
310
|
```
|
|
213
311
|
|
|
214
|
-
|
|
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.
|
|
215
313
|
|
|
216
|
-
`opengraph-image` and `twitter-image` are
|
|
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.
|
|
217
315
|
|
|
218
316
|
```ts
|
|
219
317
|
// app/opengraph-image.ts (OG is 1200x630; apple-icon 180x180)
|
|
@@ -221,5 +319,5 @@ export default function OgImage() {
|
|
|
221
319
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630">...</svg>`;
|
|
222
320
|
return new Response(svg, { headers: { 'content-type': 'image/svg+xml' } });
|
|
223
321
|
}
|
|
224
|
-
//
|
|
322
|
+
// every page without its own image now carries og:image = <site>/opengraph-image
|
|
225
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` / `.
|
|
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 | `
|
|
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
|
|
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 `
|
|
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)
|
|
@@ -148,7 +193,7 @@ Whichever form you use, a token nothing references is dropped in both, so an unu
|
|
|
148
193
|
</style>
|
|
149
194
|
```
|
|
150
195
|
|
|
151
|
-
`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.
|
|
152
197
|
|
|
153
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.
|
|
154
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,
|
|
60
|
-
native-select, pagination, popover, progress, radio-group, separator,
|
|
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,6 +93,17 @@ 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
|