@webjsdev/cli 0.10.45 → 0.10.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/bin/webjs.js +15 -12
  2. package/lib/create.js +109 -141
  3. package/package.json +1 -1
  4. package/templates/.agents/rules/workflow.md +7 -3
  5. package/templates/.agents/skills/webjs/SKILL.md +4 -2
  6. package/templates/.agents/skills/webjs/references/auth-and-sessions.md +78 -16
  7. package/templates/.agents/skills/webjs/references/built-ins.md +16 -2
  8. package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +28 -4
  9. package/templates/.agents/skills/webjs/references/components.md +82 -2
  10. package/templates/.agents/skills/webjs/references/data-and-actions.md +19 -2
  11. package/templates/.agents/skills/webjs/references/optimistic-ui.md +18 -0
  12. package/templates/.agents/skills/webjs/references/routing-and-pages.md +25 -2
  13. package/templates/.agents/skills/webjs/references/styling.md +82 -2
  14. package/templates/.agents/skills/webjs/references/ui-kit.md +8 -3
  15. package/templates/AGENTS.md +13 -5
  16. package/templates/gallery/app/apple-icon.ts +2 -5
  17. package/templates/gallery/app/examples/layout.ts +7 -2
  18. package/templates/gallery/app/examples/todo/page.ts +2 -1
  19. package/templates/gallery/app/features/async-render/page.ts +3 -2
  20. package/templates/gallery/app/features/auth/dashboard/layout.ts +2 -1
  21. package/templates/gallery/app/features/auth/dashboard/page.ts +4 -2
  22. package/templates/gallery/app/features/auth/dashboard/settings/page.ts +2 -1
  23. package/templates/gallery/app/features/auth/login/middleware.ts +15 -0
  24. package/templates/gallery/app/features/auth/login/page.ts +7 -4
  25. package/templates/gallery/app/features/auth/page.ts +6 -5
  26. package/templates/gallery/app/features/auth/signup/middleware.ts +11 -0
  27. package/templates/gallery/app/features/auth/signup/page.ts +7 -4
  28. package/templates/gallery/app/features/boundaries/error.ts +5 -4
  29. package/templates/gallery/app/features/boundaries/gated/forbidden.ts +5 -4
  30. package/templates/gallery/app/features/boundaries/not-found.ts +5 -4
  31. package/templates/gallery/app/features/boundaries/page.ts +11 -10
  32. package/templates/gallery/app/features/boundaries/private/unauthorized.ts +5 -4
  33. package/templates/gallery/app/features/broadcast/page.ts +4 -3
  34. package/templates/gallery/app/features/caching/page.ts +5 -4
  35. package/templates/gallery/app/features/client-router/page.ts +7 -5
  36. package/templates/gallery/app/features/client-router/second/page.ts +4 -3
  37. package/templates/gallery/app/features/components/page.ts +3 -2
  38. package/templates/gallery/app/features/directives/page.ts +3 -2
  39. package/templates/gallery/app/features/env/page.ts +4 -3
  40. package/templates/gallery/app/features/file-storage/page.ts +8 -5
  41. package/templates/gallery/app/features/forms/page.ts +10 -6
  42. package/templates/gallery/app/features/frames/page.ts +16 -8
  43. package/templates/gallery/app/features/layout.ts +60 -5
  44. package/templates/gallery/app/features/metadata/page.ts +7 -6
  45. package/templates/gallery/app/features/optimistic-ui/page.ts +3 -2
  46. package/templates/gallery/app/features/rate-limit/page.ts +6 -5
  47. package/templates/gallery/app/features/route-handler/page.ts +4 -3
  48. package/templates/gallery/app/features/routing/[id]/page.ts +7 -6
  49. package/templates/gallery/app/features/routing/page.ts +10 -9
  50. package/templates/gallery/app/features/server-actions/page.ts +5 -4
  51. package/templates/gallery/app/features/service-worker/page.ts +4 -3
  52. package/templates/gallery/app/features/sessions/page.ts +5 -4
  53. package/templates/gallery/app/features/stream/page.ts +4 -3
  54. package/templates/gallery/app/features/streaming/page.ts +4 -3
  55. package/templates/gallery/app/features/suspense/page.ts +4 -3
  56. package/templates/gallery/app/features/view-transitions/page.ts +6 -3
  57. package/templates/gallery/app/features/view-transitions/second/page.ts +4 -2
  58. package/templates/gallery/app/features/websockets/page.ts +4 -3
  59. package/templates/gallery/app/global-error.ts +2 -5
  60. package/templates/gallery/app/global-not-found.ts +4 -6
  61. package/templates/gallery/app/icon.ts +2 -5
  62. package/templates/gallery/app/manifest.ts +1 -4
  63. package/templates/gallery/app/opengraph-image.ts +3 -6
  64. package/templates/gallery/app/robots.ts +0 -3
  65. package/templates/gallery/app/sitemap.ts +0 -3
  66. package/templates/gallery/app/twitter-image.ts +3 -6
  67. package/templates/gallery/components/ui/badge.ts +41 -0
  68. package/templates/gallery/components/ui/button.ts +86 -0
  69. package/templates/gallery/components/ui/card.ts +36 -0
  70. package/templates/gallery/components/ui/input.ts +50 -0
  71. package/templates/gallery/lib/utils/ui.ts +31 -0
  72. package/templates/gallery/modules/broadcast/components/broadcast-feed.ts +4 -2
  73. package/templates/gallery/modules/caching/components/cache-buster.ts +2 -1
  74. package/templates/gallery/modules/client-router/components/router-controls.ts +4 -3
  75. package/templates/gallery/modules/components/components/counter-card.ts +4 -2
  76. package/templates/gallery/modules/components/components/reactive-meter.ts +9 -1
  77. package/templates/gallery/modules/components/components/task-loader.ts +3 -2
  78. package/templates/gallery/modules/components/components/theme-context.ts +5 -3
  79. package/templates/gallery/modules/directives/components/directive-demo.ts +17 -10
  80. package/templates/gallery/modules/gallery/components/gallery-nav.ts +54 -0
  81. package/templates/gallery/modules/gallery/nav.ts +79 -0
  82. package/templates/gallery/modules/optimistic-ui/components/like-button.ts +19 -1
  83. package/templates/gallery/modules/rate-limit/components/rate-probe.ts +2 -1
  84. package/templates/gallery/modules/route-handler/components/rich-data.ts +2 -1
  85. package/templates/gallery/modules/server-actions/components/greeter.ts +7 -4
  86. package/templates/gallery/modules/stream/components/stream-demo.ts +10 -5
  87. package/templates/gallery/modules/streaming/components/token-stream.ts +10 -3
  88. package/templates/gallery/modules/suspense/components/slow-fact.ts +2 -1
  89. package/templates/gallery/modules/todo/components/todo-app.ts +8 -4
  90. package/templates/gallery/modules/websockets/components/ws-echo.ts +5 -3
  91. package/templates/public/favicon.svg +10 -3
  92. package/templates/scripts/clear-gallery.mjs +126 -19
@@ -4,7 +4,7 @@ Env vars, caching, rate limiting, broadcast, file storage, and the `package.json
4
4
 
5
5
  ## What This Covers
6
6
 
7
- - **Environment variables** and the `WEBJS_PUBLIC_` browser-exposed prefix.
7
+ - **Environment variables**, the `WEBJS_PUBLIC_` browser-exposed prefix, and `env.ts` boot validation.
8
8
  - **Caching primitives.** `cache()` with tag invalidation, HTTP `Cache-Control`, the server HTML response cache (`export const revalidate`), content-hash asset URLs, conditional GET (ETag).
9
9
  - **Rate limiting** (`rateLimit()` middleware) and **broadcast** (`broadcast()` over WebSockets).
10
10
  - **File storage.** `FileStore` / `diskStore`, safe keys, signed URLs.
@@ -25,6 +25,18 @@ Read this when wiring caching or rate limiting, storing uploads, hardening heade
25
25
 
26
26
  Defaults are single-instance memory stores. To scale horizontally, switch the store once at startup: `setStore(redisStore({ url: process.env.REDIS_URL }))`.
27
27
 
28
+ **Validate required vars at boot with an app-root `env.ts`** (optional). It default-exports either a SCHEMA object (each var mapped to a type `string` / `number` / `boolean` / `url` / `enum`, or an options object with `optional` / `default` / `minLength` / `pattern` / `values`) OR a validator function `(env) => void` that throws. It runs at boot after `.env` loads, coerces values and writes defaults back to `process.env`, and fails fast naming EVERY bad var:
29
+
30
+ ```ts
31
+ // env.ts
32
+ export default {
33
+ DATABASE_URL: 'url',
34
+ SESSION_SECRET: { type: 'string', minLength: 16 },
35
+ PORT: { type: 'number', default: 8080 },
36
+ LOG_LEVEL: { type: 'enum', values: ['debug', 'info', 'warn'], default: 'info' },
37
+ };
38
+ ```
39
+
28
40
  ## Caching
29
41
 
30
42
  ### `cache()` for query and computation results
@@ -112,7 +124,7 @@ setFileStore(diskStore({ dir: '/var/data/uploads', baseUrl: '/files' }));
112
124
 
113
125
  **Never trust a user filename as a key.** `generateKey(file.name)` returns an opaque `<uuid>.<ext>` with a sanitized extension; a traversal attempt yields a bare safe key. Keys are containment-checked before any filesystem op.
114
126
 
115
- **Signed URLs** gate serving without a session lookup. `signedUrl(key, { secret, expiresIn })` mints an expiring HMAC signature; `verifySignedUrl(searchParams, secret)` returns `{ valid }`. An `expiresIn` of `0` or negative fails closed.
127
+ **Signed URLs** gate serving without a session lookup. `signedUrl(key, { secret, expiresIn })` mints an expiring HMAC signature; `verifySignedUrl(searchParams, secret)` returns `{ valid }`. An `expiresIn` of `0` or negative fails closed. Pass `base` to point the signed link at your own serve route instead of the default upload URL: `signedUrl(key, { secret, base: '/files/' + key, expiresIn: 3600 })`.
116
128
 
117
129
  **Serving-XSS warning.** The recorded content-type is attacker-controlled (the browser sent it at upload). A serving route MUST send `X-Content-Type-Options: nosniff` and SHOULD send `Content-Disposition: attachment` for user uploads. Only serve inline after validating bytes against a strict inert allowlist, never `text/html` / `image/svg+xml`. Add the uploads directory to `.gitignore`.
118
130
 
@@ -136,6 +148,8 @@ On by default (`X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`,
136
148
 
137
149
  Off by default. `{ "webjs": { "csp": true } }` enables a strict-dynamic + per-request nonce posture. An object form merges `directives` and supports `reportOnly`. Read the nonce with `cspNonce()` from `@webjsdev/core` to stamp your own inline `<script>`.
138
150
 
151
+ Enforcement is the HTTP `Content-Security-Policy` HEADER, never a `<meta http-equiv>` tag, so `frame-ancestors` / `report-uri` work. The emitted `<meta name="csp-nonce">` is only the client-side nonce CARRIER. Across a client-router soft navigation the ORIGINAL page-load nonce stays authoritative (the browser enforces the original document's CSP header, not the fetched response's fresh one), so the router preserves that meta and re-stamps every dynamically-inserted script / preload with the original nonce via `getCspNonce()`. The server still mints a fresh nonce per request, and CSP pages are excluded from the HTML cache so a nonce is never served stale. No client config is needed.
152
+
139
153
  ### Redirects, trailing-slash, basePath, allowed origins
140
154
 
141
155
  ```jsonc
@@ -35,10 +35,13 @@ Note for anyone testing this: **the Chromium web-test-runner currently resolves
35
35
  ```
36
36
 
37
37
  ```js
38
- import { disableClientRouter } from '@webjsdev/core';
39
- disableClientRouter();
38
+ import { disableClientRouter, enableClientRouter } from '@webjsdev/core';
39
+ disableClientRouter(); // stop intercepting document <a> / <form> (plain links resume full loads)
40
+ enableClientRouter(); // turn soft navigation back on
40
41
  ```
41
42
 
43
+ `disableClientRouter()` / `enableClientRouter()` are a runtime pair that toggle only the document-level `<a>` / `<form>` interception. An explicit `navigate(url)` call still does a soft navigation either way (it is not gated by the toggle).
44
+
42
45
  Per link, opt out with `data-no-router` (auth flows like `/logout`, OAuth redirects, print views, an experimental route with a different runtime). Cross-origin hrefs, `download`, a non-`_self` target, pure same-page hash jumps, and non-HTML extensions are auto-skipped.
43
46
 
44
47
  **Programmatic navigation and cache eviction.**
@@ -113,8 +116,17 @@ The router can wrap a navigation's DOM mutation in the native View Transitions A
113
116
  <meta name="view-transition" content="same-origin">
114
117
  ```
115
118
 
119
+ A page (or layout) does not write raw `<head>` markup, so emit that meta through the `other` metadata field, which scopes it to the page that declares it:
120
+
121
+ ```ts
122
+ // app/gallery/page.ts
123
+ export const metadata = { other: { 'view-transition': 'same-origin' } };
124
+ ```
125
+
116
126
  The accepted value is `same-origin`. When enabled it wraps every swap path (the two-tier boundary swap, the `<webjs-frame>` swap, and the background-revalidation full-body path). When `startViewTransition` is unavailable the swap runs synchronously with no flash and no throw. To persist a live element (a playing `<audio>`, an open menu) across a swap by node identity, mark it `data-webjs-permanent` and give it an `id`.
117
127
 
128
+ The opt-in is **per page**, so it is a page-scoped meta: put it on a page's metadata to animate that page, or on the root layout to animate the whole app. Navigating to a page that does NOT declare it turns transitions back off, because the soft-nav head merge reconciles page-scoped `<meta>` tags (a stale one the previous page declared is removed, not left to leak, #1046). View transitions **compose with Suspense streaming**: a streamed boundary (a `loading.{js,ts}` skeleton or a `<webjs-suspense>` region) navigated to under an active transition still resolves its content progressively, because the streamed resolve waits for the transition's DOM swap to commit before it applies (#1048).
129
+
118
130
  ## `<webjs-stream>` Surgical Updates
119
131
 
120
132
  `<webjs-stream>` is WebJs's take on Turbo Streams, and the action set mirrors `<turbo-stream>`. It is the only SINGLE-element update primitive (append one row, remove one item, bump a count, insert a toast), whereas a frame or layout swap redraws a whole region.
@@ -186,13 +198,25 @@ export function WS(ws, req, { params }) {
186
198
  }
187
199
  ```
188
200
 
189
- **Client.** `connectWS(url, handlers)` from `@webjsdev/core` auto-reconnects with exponential backoff, handles JSON parse/stringify, and queues sends while disconnected.
201
+ **Client.** `connectWS(url, handlers)` from `@webjsdev/core` auto-reconnects with exponential backoff, handles JSON parse/stringify, and queues sends while disconnected. The handler set is `{ onOpen, onMessage, onClose }`, and it RETURNS a connection handle with `.send(data)` and `.close()`. Open it in `connectedCallback` and close it in `disconnectedCallback`, driving a connection-status signal from `onOpen` / `onClose`:
190
202
 
191
203
  ```js
192
204
  import { connectWS, renderStream } from '@webjsdev/core';
193
- connectWS('/feed', { onMessage: (m) => renderStream(m) });
205
+
206
+ connectedCallback() {
207
+ super.connectedCallback();
208
+ this.conn = connectWS('/feed', {
209
+ onOpen: () => (this.online = true),
210
+ onClose: () => (this.online = false),
211
+ onMessage: (m) => renderStream(m), // apply a server-pushed <webjs-stream> payload
212
+ });
213
+ }
214
+ disconnectedCallback() { super.disconnectedCallback(); this.conn?.close(); }
215
+ send(text) { this.conn.send(text); }
194
216
  ```
195
217
 
218
+ **Gotcha: a component re-render clobbers surgical `renderStream()` updates.** `renderStream()` (and `<webjs-stream>` in general) mutates the DOM out of band, appending rows the component's own `render()` does not know about. If the component then re-renders, `render()` re-runs and wipes those out-of-band rows. So render the target container ONCE and drive any mutation counter with a PLAIN instance field, never a signal or reactive prop that `render()` reads (a read would re-render and blow away the streamed-in DOM).
219
+
196
220
  **Broadcast.** `broadcast(path, data)` from `@webjsdev/server` fans a message to every connected client on that path (single-instance). For multi-instance, add Redis pub/sub yourself, there is no framework magic.
197
221
 
198
222
  ## Navigation-Loading Indicator (opt-in)
@@ -3,11 +3,13 @@
3
3
  ## What This Covers
4
4
 
5
5
  - Declaring reactive properties through the `WebComponent({ ... })` factory and `prop()`, with options (`reflect`, `state`, `attribute`, `default`, `converter`, `hasChanged`)
6
- - Signals as the default state primitive for component-local and shared state
6
+ - Signals as the default state primitive for component-local and shared state, plus `effect` / `batch`
7
7
  - The Lit-aligned lifecycle and exactly which hooks SSR runs versus skips
8
8
  - Light DOM (default) versus shadow DOM, and the light-host `display: block` rule
9
9
  - Slots with full shadow-DOM parity in both DOM modes
10
10
  - `async render()`: SSR-blocking first paint, client stale-while-revalidate, `renderFallback()` / `renderError()`
11
+ - `Task` for client-only async data, and context (`createContext` / `ContextProvider` / `ContextConsumer`) to avoid attribute drilling
12
+ - The lit-html directive set (`repeat`, `watch`, `live`, `keyed`, `guard`, `cache`, `until`, `unsafeHTML`, `ref`, `asyncAppend` / `asyncReplace`, `templateContent`)
11
13
  - Display-only elision (when a component is stripped from the browser)
12
14
  - Inherited members app code must NOT shadow (`title`, `remove`, `render`, ...)
13
15
 
@@ -70,6 +72,19 @@ const count = computed(() => cart.get().length); // derived
70
72
 
71
73
  Read with `signal.get()` inside `render()`; the built-in `SignalWatcher` tracks the read and re-renders on change. An instance signal created in the constructor is component-local. For a fine-grained DOM swap use `${watch(signal)}` from `@webjsdev/core/directives`.
72
74
 
75
+ Two more signal primitives from `@webjsdev/core` cover client-side reactions and batched writes:
76
+
77
+ - `effect(fn)` runs `fn` now and re-runs it whenever a signal it read changes. It is a BROWSER-ONLY side-effect primitive (a subscription, a `document.title` sync, an analytics ping), not a render path. It returns a disposer, so create it in `connectedCallback` and call the disposer in `disconnectedCallback` to avoid a leak.
78
+ - `batch(fn)` coalesces several `.set()` writes inside `fn` into ONE re-render instead of one per write. Reach for it when a handler updates multiple signals at once.
79
+
80
+ ```ts
81
+ import { signal, effect, batch } from '@webjsdev/core';
82
+ const open = signal(false), count = signal(0);
83
+ connectedCallback() { super.connectedCallback(); this.dispose = effect(() => { document.title = `(${count.get()})`; }); }
84
+ disconnectedCallback() { super.disconnectedCallback(); this.dispose?.(); }
85
+ reset() { batch(() => { open.set(false); count.set(0); }); } // one re-render, not two
86
+ ```
87
+
73
88
  ## Lifecycle (Lit-aligned) and what SSR runs
74
89
 
75
90
  Each update cycle runs these in order; each receives a `changedProperties` Map.
@@ -158,6 +173,71 @@ Errors are isolated per component by default (no user code): a thrown `await` re
158
173
 
159
174
  Decision rules. Use `async render()` for request-time server data that should be in the first paint (the default). Add `renderFallback()` when a client re-fetch's stale content would mislead. Use `Task` / signals for genuinely client-only data (a click, viewport, live updates). For SLOW data where blocking the first byte hurts, wrap the region in `<webjs-suspense .fallback=${html\`Loading...\`}>` to stream it (the only way to show a first-paint fallback; see `client-router-and-streaming.md`). Do NOT fetch in `connectedCallback` for data knowable server-side, and do NOT prop-drill what a leaf can fetch itself.
160
175
 
176
+ ## Task: client-only async data
177
+
178
+ For async data that is genuinely CLIENT-only (it depends on a click, viewport, or a live source, so `async render()` cannot bake it in at SSR), use the `Task` reactive controller. It shows its pending state at SSR (staying `INITIAL`), then runs in the browser.
179
+
180
+ ```ts
181
+ import { Task, TaskStatus } from '@webjsdev/core/task';
182
+
183
+ class SearchResults extends WebComponent({ q: String }) {
184
+ #search = new Task(this, {
185
+ task: async ([q], { signal }) => (await fetch(`/api/s?q=${q}`, { signal })).json(),
186
+ args: () => [this.q], // the args array spreads into the task's first parameter
187
+ });
188
+ render() {
189
+ switch (this.#search.status) {
190
+ case TaskStatus.PENDING: return html`<p>Searching...</p>`;
191
+ case TaskStatus.ERROR: return html`<p>${this.#search.error.message}</p>`;
192
+ case TaskStatus.COMPLETE: return html`<ul>${this.#search.value.map((r) => html`<li>${r.title}</li>`)}</ul>`;
193
+ default: return html`<p>Type to search.</p>`; // INITIAL (also the SSR state)
194
+ }
195
+ }
196
+ }
197
+ ```
198
+
199
+ `args()` re-runs the task whenever its return changes; call `this.#task.run()` to trigger it manually. `TaskStatus` is `INITIAL` / `PENDING` / `COMPLETE` / `ERROR`. Prefer `async render()` for server data that should be in the first paint; reach for `Task` only when the data cannot exist until the browser runs.
200
+
201
+ ## Context: share state without attribute drilling
202
+
203
+ When a value must reach a deep descendant without threading it through every intermediate component's attributes, use context (from `@webjsdev/core/context`). This is a CLIENT-TIME concern: a provider publishes on connect, so context is empty at SSR. For server-known data, pass it through the page function or a `.prop` instead (see `muscle-memory-gotchas.md`).
204
+
205
+ ```ts
206
+ import { createContext, ContextProvider, ContextConsumer } from '@webjsdev/core/context';
207
+
208
+ export const themeContext = createContext<'light' | 'dark'>('theme');
209
+
210
+ class ThemeRoot extends WebComponent({}) {
211
+ #provider = new ContextProvider(this, { context: themeContext, initialValue: 'dark' });
212
+ toggle() { this.#provider.setValue(this.#provider.value === 'dark' ? 'light' : 'dark'); }
213
+ }
214
+
215
+ class ThemedCard extends WebComponent({}) {
216
+ #theme = new ContextConsumer(this, { context: themeContext, subscribe: true }); // re-renders on change
217
+ render() { return html`<div class=${this.#theme.value === 'dark' ? 'bg-black' : 'bg-white'}>...</div>`; }
218
+ }
219
+ ```
220
+
221
+ `subscribe: true` re-renders the consumer on every provider change; omit it for a one-shot read. A component can also fire a `ContextRequestEvent` to pull a value imperatively.
222
+
223
+ ## Directives (lit-html parity)
224
+
225
+ Import from `@webjsdev/core/directives`. Everything a `class`/`style`/conditional needs is plain JS (`classMap` is `class=${cond ? 'a' : 'b'}`, `when` is a ternary, `map` is `.map`); reach for a directive only for the jobs below.
226
+
227
+ | Directive | Use it for |
228
+ |---|---|
229
+ | `repeat(items, keyFn, tpl)` | A keyed list where items reorder / insert / remove (preserves DOM + state per key). A static list is a plain `.map`. |
230
+ | `watch(signal)` | A fine-grained DOM swap of one signal's value without re-rendering the whole component. |
231
+ | `live(value)` | An `input` / `textarea` `.value` bound to state, so a user edit that equals the last committed value still resets. |
232
+ | `keyed(key, tpl)` | Force a fresh subtree (discard old DOM + state) when `key` changes. |
233
+ | `guard(deps, () => tpl)` | Skip re-rendering an expensive subtree unless `deps` change. |
234
+ | `cache(tpl)` | Keep the DOM of an inactive branch around when toggling between templates. |
235
+ | `until(promise, fallback)` | Render `fallback` until `promise` resolves (prefer `Task` in a component, `Suspense` for a page). |
236
+ | `unsafeHTML(str)` | Render a TRUSTED raw HTML string. NEVER pass user input (XSS). |
237
+ | `ref(cb)` / `createRef()` | Get a handle to the rendered DOM node. |
238
+ | `asyncAppend(iter)` / `asyncReplace(iter)` | Stream from an async iterable, appending each value or replacing with the latest. |
239
+ | `templateContent(el)` | Render the content of a `<template>` element. |
240
+
161
241
  ## Display-only elision
162
242
 
163
243
  A component that does no client-side work renders the same SSR'd HTML with or without its JS, so WebJs strips its import from the served source (and any vendor reachable only through it). This is automatic and conservative. A component stays elidable while it has NONE of:
@@ -174,7 +254,7 @@ A bare `async render()` (no other signal, light DOM) is elided too: the SSR'd da
174
254
 
175
255
  ## Members app code must not shadow
176
256
 
177
- A `WebComponent` inherits `HTMLElement` (browser) or an `ElementShim` (SSR) plus the framework reactivity base. A reactive prop or method whose NAME collides either fails to compile (`TS2415` for a type-incompatible property, `TS2416` for a method signature) or silently hijacks the native member at runtime. The fix is always to rename.
257
+ A `WebComponent` inherits `HTMLElement` (browser) or an `ElementShim` (SSR) plus the framework reactivity base. A reactive prop or method whose NAME collides either fails to compile (`TS2415` for a type-incompatible property, `TS2416` for a method signature) or silently hijacks the native member at runtime. The fix is always to rename. The DOM MUTATION methods WebJs instruments for the light-DOM slot API (`append`, `prepend`, `before`, `after`, `replaceWith`, `replaceChildren`, `remove`, `appendChild`, `insertBefore`, `removeChild`, `replaceChild`) are the dangerous case TypeScript does NOT catch (a shorter override is assignable to the native signature), so a handler named `append()` compiles yet silently never runs. `webjs check`'s `no-shadowed-native-member` rule catches exactly these.
178
258
 
179
259
  - HTMLElement / Element: `title`, `id`, `slot`, `role`, `hidden`, `dir`, `lang`, `translate`, `draggable`, `tabIndex`, `className`, `dataset`, `remove`, `closest`, `matches`, `focus`, `blur`, `click`, `append` / `prepend`, `before` / `after`. Rename (`postTitle`, `removeItem`, `handleClick`).
180
260
  - WebComponent base: `render`, `update`, `requestUpdate`, `updated` / `firstUpdated`, `willUpdate` / `shouldUpdate`, `connectedCallback`, `renderError` / `renderFallback`, `addController` / `removeController`, `updateComplete` (#1021: there is no WebJs slot API to override; slots are native). Only override one deliberately, with its exact signature; never repurpose the name for app logic.
@@ -5,7 +5,7 @@
5
5
  - The `modules/<feature>/` architecture (thin `app/` adapters, `actions/` mutations, `queries/` reads, one function per file)
6
6
  - `'use server'` RPC actions, the serializer-safe wire, and how a client import becomes a typed stub
7
7
  - Input validation at the boundary via `export const validate`
8
- - HTTP-verb config exports (`method`, `cache`, `tags`, `invalidates`, `middleware`)
8
+ - HTTP-verb config exports (`method`, `cache`, `tags`, `invalidates`, `middleware`), the middleware `ctx` shape, and `actionSignal()` cancellation
9
9
  - The `ActionResult<T>` envelope and its robust failure detection
10
10
  - The `route()` REST adapter that exposes an action over HTTP
11
11
  - Drizzle rc.3 reads (`db.query.*`) and mutations (`.returning()`)
@@ -141,7 +141,24 @@ export async function updateUser(id: number, patch: Partial<User>) { /* ... */ }
141
141
 
142
142
  - A **GET** rides args in the URL (POST fallback over a 4KB cap), is CSRF-exempt, and carries `Cache-Control` + a weak `ETag` (304 on `If-None-Match`) + `X-Webjs-Tags`. A **mutation** (POST/PUT/PATCH/DELETE) sends the rich body (DELETE rides the URL), is CSRF-protected, and on success evicts its `invalidates` tags and reports them via `X-Webjs-Invalidate`. A method mismatch is a `405` + `Allow`.
143
143
  - **SAFETY.** `cache` with `public: true` SHARES one response across ALL users, keyed only by URL + args. Use it ONLY for data identical for every visitor (the same rule as a page's `export const revalidate`), never for a session or per-user read.
144
- - Per-action `middleware` short-circuits by returning an `ActionResult` instead of calling `next()`, and accumulates context the action reads via `actionContext()` from `@webjsdev/server`.
144
+ - Per-action `middleware` short-circuits by returning an `ActionResult` instead of calling `next()`, and accumulates context the action reads via `actionContext()` from `@webjsdev/server`. Each middleware is `async (ctx, next) => result` where `ctx` is `{ request, args, signal, context }`. It writes to the shared bag `ctx.context.<key>` (for example `ctx.context.user = user`), which is exactly what `actionContext().user` reads back in the action. A direct server-to-server call skips the RPC boundary (so its middleware does NOT run), so the action must guard rather than assume a middleware-set value is present.
145
+
146
+ ### Cancellation with `actionSignal()`
147
+
148
+ Inside an action, `actionSignal()` from `@webjsdev/server` returns the request's `AbortSignal`. It fires when the client disconnects OR when a newer client render supersedes this one (the RPC stub aborts the previous in-flight fetch). Thread it into the work you start, and re-check it after an await to map an abort to a cancelled envelope:
149
+
150
+ ```ts
151
+ 'use server';
152
+ import { actionSignal } from '@webjsdev/server';
153
+ export async function search(q: string) {
154
+ const signal = actionSignal();
155
+ const res = await fetch(`https://api/x?q=${q}`, { signal }); // aborts the fetch on disconnect
156
+ if (signal.aborted) return { success: false, error: 'Request cancelled.', status: 499 };
157
+ return { success: true, data: await res.json() };
158
+ }
159
+ ```
160
+
161
+ A guard placed BEFORE any await can never fire (nothing has yielded yet). Outside an action the signal never aborts, so a server-to-server call stays safe.
145
162
 
146
163
  ## The `ActionResult<T>` envelope
147
164
 
@@ -67,6 +67,24 @@ TodoList.register('todo-list');
67
67
  - Multiple `.add()` calls stack independently. Each carries its own release by ID, so overlapping in-flight mutations do not clobber one another.
68
68
  - When `update` is omitted, the payload REPLACES the state directly (`Action = State`), matching the simple `useOptimistic(setState)` pattern.
69
69
 
70
+ ### Author the optimistic mutation as a degrade-first form
71
+
72
+ Wrap the mutation in a REAL `<form method="post" action="">` posting to the page's own URL, then intercept it for the optimistic path. One form then serves both: with JS off the browser submits to the page `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.
73
+
74
+ ```ts
75
+ render() {
76
+ return html`
77
+ <form method="post" action="" @submit=${this.handleSubmit}>
78
+ <input type="hidden" name="intent" value="create"> <!-- one page action dispatches on intent -->
79
+ <input name="title" required>
80
+ <button>Add</button>
81
+ </form>
82
+ <ul>${this.optimisticTodos.value.map(t => html`<li class=${t.pending ? 'opacity-50' : ''}>${t.title}</li>`)}</ul>`;
83
+ }
84
+ ```
85
+
86
+ When a page owns SEVERAL mutations (create, toggle, delete), give each form a hidden `intent` field and let the single page `action` dispatch on it. Each interactive control (a toggle button, a delete button) is also its own tiny form so it still works with JS off.
87
+
70
88
  ## Seed the list from the server for SSR plus optimistic
71
89
 
72
90
  For a page that server-renders a list AND lets the user add to it optimistically, let ONE component own both the list and the form, and seed it from the page through a `.prop` hole (a DOM property that round-trips through SSR on custom elements). The list is then fully server-rendered on first paint (readable with JS off) and re-renders optimistically on each add. A separate static list in the page would not update on an optimistic add.
@@ -4,8 +4,8 @@
4
4
 
5
5
  - Pages, layouts, and where the HTML shell comes from
6
6
  - Dynamic (`[param]`), catch-all (`[...rest]`), and optional catch-all (`[[...rest]]`) segments, route groups, private folders
7
- - `route.ts` HTTP handlers and `middleware.ts`
8
- - `metadata` and `generateMetadata` (folded in here)
7
+ - `route.ts` HTTP handlers and `middleware.ts`, the route-handler toolkit (`json` / `readBody` / `clientIp` / the no-arg accessors), and calling one from the client with `richFetch`
8
+ - `metadata` and `generateMetadata` (folded in here), including image metadata routes that return a `Response`
9
9
  - Control-flow throws: `notFound()`, `redirect()`, `forbidden()`, `unauthorized()`
10
10
  - The no-JS page `action` write path
11
11
  - Boundaries: `error.ts`, `loading.ts`, `not-found.ts`, `forbidden.ts`, `unauthorized.ts`, and the two root-only ones
@@ -78,6 +78,18 @@ export async function GET() {
78
78
 
79
79
  **NEVER throw `redirect()` / `notFound()` / `forbidden()` inside a `route.ts` handler** (an uncaught throw is a generic 500). Return a real response instead: `return Response.redirect(url, 303)` for a redirect, `return new Response('Not Found', { status: 404 })` for a 404. A `route.ts` is also NOT covered by the action CSRF check, so authenticate every mutating endpoint, validate, and rate-limit. Export `WS(ws, req, { params })` from the same file for a WebSocket endpoint.
80
80
 
81
+ **The route-handler toolkit** (from `@webjsdev/server`). Two accessors take the request explicitly: `readBody(req)` decodes a rich request body (the inverse of `json()`, round-tripping `Date` / `Map` / `Set` / `BigInt` / `Blob`), and `clientIp(req)` reads the caller IP. To respond with rich types, `json(value)` serializes with the same wire (so a `Date` survives), versus a plain object return that auto-JSONs. The context accessors take NO argument because they read the in-flight request from context: `headers()`, `cookies()`, `requestId()`, `cspNonce()`.
82
+
83
+ ```ts
84
+ import { json, readBody, clientIp } from '@webjsdev/server';
85
+ export async function POST(req: Request) {
86
+ const body = await readBody(req); // rich types decoded
87
+ return json({ at: new Date(), ip: clientIp(req) }); // Date survives the wire
88
+ }
89
+ ```
90
+
91
+ **Calling your own `route.ts` from the client:** use `richFetch<T>(url, opts?)` from `@webjsdev/core`, a drop-in `fetch` that sends `Accept: application/vnd.webjs+json`, encodes a plain-object `body` with the rich wire, and decodes the `json()` response (so `data.at` is a real `Date`). This is the ONE legitimate hand-fetch (importing a `'use server'` action is the way to call the server otherwise, never a hand-written `fetch` to an action).
92
+
81
93
  ## Middleware (`middleware.ts`)
82
94
 
83
95
  Optional root-level plus per-segment. The default export is `async (req, next) => Response`. Return a Response to short-circuit, or call `next()` and post-process. Per-segment middleware applies to its subtree, outermost to innermost.
@@ -156,3 +168,14 @@ How the result is read (server side): a success PRG-redirects with `303` (to a s
156
168
  - Root-only (in `app/` exactly): `global-error.ts` is the app-wide catch-all after nested `error` boundaries are exhausted and renders its OWN `<!doctype><html><body>` (returned verbatim, so keep it static HTML with no components or hydration). `global-not-found.ts` renders for an unmatched-anywhere URL when no `not-found` matches.
157
169
 
158
170
  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.
171
+
172
+ 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).
173
+
174
+ ```ts
175
+ // app/opengraph-image.ts (OG is 1200x630; apple-icon 180x180)
176
+ export default function OgImage() {
177
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630">...</svg>`;
178
+ return new Response(svg, { headers: { 'content-type': 'image/svg+xml' } });
179
+ }
180
+ // app/page.ts -> export const metadata = { openGraph: { images: ['/opengraph-image'] } };
181
+ ```
@@ -8,6 +8,7 @@
8
8
  - Design tokens: `:root` / `@theme` in the root layout
9
9
  - Light-DOM host `display: block` behaviour (and shadow hosts via `:host`)
10
10
  - When to use `static styles` (shadow DOM)
11
+ - Accessible native controls (label association, `aria-pressed`, `aria-label`)
11
12
  - `position: fixed`, not `sticky`, for a pinned header (the iOS WebKit flicker)
12
13
  - Even-grid / no-reflow layout tips
13
14
 
@@ -67,9 +68,77 @@ export default function Post({ params }) {
67
68
 
68
69
  Avoid `@apply`: it hides which utilities a class uses and creates a second source of truth. A JS helper keeps the bundle visible at the definition site, composes with conditional classes and active states, and runs at SSR time.
69
70
 
70
- ## Design tokens
71
+ ### A design system for repeated PRIMITIVES: class helpers built on `@webjsdev/ui`
71
72
 
72
- The default stack is a static compiled Tailwind stylesheet (`css:build` compiles `public/input.css` to the linked `public/tailwind.css`, so it works with JS off) plus `@theme` design tokens (palette, fonts, fluid type, motion durations) declared once in the root layout. Consume them as utility classes (`text-foreground`, `bg-card`, `font-serif`, `duration-fast`). If you wire your own theme switch, drive BOTH signals on `<html>` (the `data-theme` attribute for the app palette blocks AND the `.dark` class for the `@webjsdev/ui` kit), or half the UI renders stale tokens. Verify dark mode in a real browser, light mode passing proves nothing about dark.
73
+ An `html`-fragment helper is right for a repeated CHUNK of markup (the rubric above). For a repeated UI PRIMITIVE (button, input, card, badge) that needs variants and sizes, use a class helper instead: a function that returns a Tailwind class STRING you spread onto a native element. That is exactly what `@webjsdev/ui` ships (`buttonClass({ variant, size })`, `cardClass()`, `inputClass()`, `badgeClass({ variant })`), and it is what the scaffold gallery uses in `components/ui/`. To style a ONE-OFF that a variant does not cover (a circular icon button, a pill), compose the helper and override the bespoke bits with `cn()`: `cn(buttonClass({ variant: 'secondary', size: 'none' }), 'w-9 h-9 rounded-full')`. `cn` resolves Tailwind conflicts so a later class wins, including a shorthand over the axis it subsumes (`p-0` beats an earlier `px-4 py-2`), so an override just works. For an icon button prefer `size: 'none'` (it states "I supply my own box" by dropping the helper's padding + radius) over layering a `p-0` on top of the default size.
74
+
75
+ ```ts
76
+ // components/ui/button.ts (webjs ui add button, themed to your app)
77
+ import { cn } from '#lib/utils/cn.ts';
78
+ const BASE = 'inline-flex cursor-pointer items-center justify-center ...';
79
+ const VARIANTS = { default: 'bg-primary text-primary-foreground ...', secondary: '...' } as const;
80
+ const SIZES = { default: 'px-4 py-2 rounded-xl', sm: '...' } as const;
81
+ export function buttonClass(o: { variant?: keyof typeof VARIANTS; size?: keyof typeof SIZES } = {}) {
82
+ return cn(BASE, VARIANTS[o.variant ?? 'default'], SIZES[o.size ?? 'default']);
83
+ }
84
+ ```
85
+
86
+ ```ts
87
+ // a page or component
88
+ import { buttonClass } from '#components/ui/button.ts';
89
+ html`<button class=${buttonClass({ variant: 'secondary', size: 'sm' })} @click=${...}>Reset</button>`;
90
+ ```
91
+
92
+ Why a class helper (not a `<ui-button>` wrapper): it adds NO indirection, so the element stays native (`@click`, `?disabled`, form submission, focus, a11y all just work) and the markup stays readable, while every button shares one source of truth (so no button can forget `cursor-pointer` or drift). Put the affordance every variant needs (like `cursor-pointer`) on the shared BASE.
93
+
94
+ **Default: `webjs ui add`, then modify. Do not hand-write a primitive from scratch.** For a repeated primitive with variants, run `webjs ui add <name>` then trim and theme the copied source. The scaffold already ships the `cn` prerequisite at `lib/utils/cn.ts`, so `add` works out of the box (a non-scaffold app runs `webjs ui init` once first to write `components.json`, the `cn` util, and the design tokens). The kit is shadcn-style, so `add` COPIES the helper's source INTO your `components/ui/` and you own and edit it exactly as freely as code you typed yourself. That is the key point: `add`-then-modify and hand-writing end at the SAME place (owned, editable class-helper source), so the difference is only the STARTING POINT. `add` starts you from vetted, variant-complete source you then adapt (and the copied header spells out the primitive's accessibility obligations), where hand-writing starts from a blank file and re-derives all of it for no benefit. Theme it to YOUR app (change the class values so the helper produces YOUR look, rather than bending your app to the kit's defaults) and keep only the parts you use (the gallery's `cardClass` is surface-only, since its panels vary their own padding and layout). Hand-author a primitive yourself ONLY for a one-off the kit does not cover, or a deliberate opt-out of the kit. Reserve `lib/utils/ui.ts` `html`-fragment helpers for repeated markup chunks; reserve `components/ui/*` class helpers for themed primitives with variants.
95
+
96
+ ## Accessible native controls
97
+
98
+ Even with the kit, an app hand-authors SOME markup (a one-off primitive the kit does not cover, or the native element you wrap a class helper around), and there accessibility is your job (the `@webjsdev/ui` primitives carry their own, but a raw `<button>` / `<input>` does not). Three habits keep hand-authored interactive markup accessible on BOTH the JS and no-JS paths:
99
+
100
+ - **Associate a label with its control.** `<label for="email">` paired with `<input id="email">` (or wrap the control in the `<label>`), so a click on the label focuses the field and a screen reader announces it.
101
+ - **State a toggle's pressed state.** A button that toggles carries `aria-pressed=${on}` so assistive tech announces on/off, not just "button".
102
+ - **Name an icon-only button.** A button whose only content is an icon has no accessible name, so give it `aria-label="Delete task"`.
103
+
104
+ Native `<button>` / `<a>` / `<input>` already have correct focus + keyboard behaviour, which is the main reason to prefer them (and the `buttonClass()` / `inputClass()` class helpers) over a custom `<div role>` element.
105
+
106
+ ## Design tokens and theming
107
+
108
+ The default stack is a static compiled Tailwind stylesheet (`css:build` compiles `public/input.css` to the linked `public/tailwind.css`, so it works with JS off) plus `@theme` design tokens declared once in the root layout. You consume them as utility classes (`bg-background`, `text-foreground`, `bg-card`, `border-border`, `font-serif`).
109
+
110
+ **Two halves.** (1) `public/input.css` MAPS token names into Tailwind with `@theme inline` (`--color-background: var(--background)`), so `bg-background` resolves to `var(--background)`. That is infrastructure; leave it. (2) The root layout (`app/layout.ts`) DEFINES the values as plain CSS custom properties in a `<style>` block. That is your palette; make it your own. A freshly cleared app (after `npm run gallery:clear`) ships only the OS system-colour base (`Canvas` / `CanvasText`) with NO tokens, so building this palette is your first styling step.
111
+
112
+ **Light and dark, defined once (DRY).** Write each colour token ONE time with the native CSS `light-dark(LIGHT, DARK)` function and let `color-scheme` pick the side. The default `color-scheme: light dark` follows the OS; a `[data-theme]` attribute forces one. No duplicated light/dark blocks:
113
+
114
+ ```html
115
+ <style>
116
+ :root {
117
+ --font-sans: ui-sans-serif, system-ui, sans-serif;
118
+ color-scheme: light dark; /* follow the OS by default */
119
+ --background: light-dark(#ffffff, #1e2226);
120
+ --foreground: light-dark(#191c20, #dee2e6);
121
+ --card: light-dark(#f7f8fa, #313539);
122
+ --muted-foreground: light-dark(#565c64, #94989c);
123
+ --border: light-dark(#e2e5e9, #3d434b);
124
+ --primary: light-dark(#1e2226, #dee2e6);
125
+ /* a derived token tracks BOTH themes for free via var(--primary) */
126
+ --primary-tint: color-mix(in srgb, var(--primary) 22%, transparent);
127
+ }
128
+ :root[data-theme='light'] { color-scheme: light; } /* the toggle forces a scheme */
129
+ :root[data-theme='dark'] { color-scheme: dark; }
130
+ </style>
131
+ ```
132
+
133
+ `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.
134
+
135
+ **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.
136
+
137
+ **Edge cases.** `light-dark()` is COLOUR-only. A colour needed in just one theme sets the unused side to a no-op (`light-dark(#fff, transparent)`). A derived token that references a `light-dark()` one (like `--primary-tint` above) tracks both themes automatically. A NON-colour token that must differ per theme (a shadow's geometry, a gradient, a size, an image) cannot use `light-dark()`; give it a `:root[data-theme='dark']` override plus an `@media (prefers-color-scheme: dark) { :root:not([data-theme]) { ... } }` rule for the OS default.
138
+
139
+ **The ui class helpers build on these tokens.** `buttonClass()` / `cardClass()` / `inputClass()` / `badgeClass()` emit Tailwind utilities that reference the same tokens (`bg-primary`, `border-border`), so theming the tokens re-skins every helper at once.
140
+
141
+ **Focus rings.** The design system applies ONE themed, keyboard-only focus ring globally in the theme CSS: `@layer base { * { @apply border-border outline-ring/50 } }` themes the outline COLOUR to `--ring/50`, and a `:focus-visible { outline: 2px solid color-mix(in oklab, var(--color-ring) 50%, transparent); outline-offset: 2px }` forces a SOLID outline. That second rule matters: `outline-ring/50` alone leaves `outline-style: auto`, so the browser draws its OWN focus ring (which can look thick and white and ignore the colour). So every focusable element (button, link, input) shares one `--ring`-coloured ring with no per-element styling (a native `<button>` renders it a touch wider than a link, a Chromium form-control quirk, but the colour is the same). Do NOT re-add a focus style on a light-DOM element (`buttonClass` deliberately carries none), and NEVER remove it (`outline: none` with no replacement fails WCAG 2.4.7). `:focus-visible` already limits the ring to keyboard / programmatic focus, not a mouse click. A SHADOW-DOM component is the ONE exception: a document rule cannot cross the shadow boundary, so it styles its own focus in `static styles`, matching the global ring EXACTLY (`--ring` at 50%, the same as `outline-ring/50`): `button:focus-visible { outline: 2px solid color-mix(in oklab, var(--color-ring) 50%, transparent); outline-offset: 2px }`. Without it, its controls fall back to the raw browser outline (thick, light on a dark theme, and shown on window-refocus).
73
142
 
74
143
  ## Light-DOM host display, and shadow hosts
75
144
 
@@ -83,6 +152,17 @@ static styles = css`:host { display: block }`; // a shadow host with no :host
83
152
 
84
153
  **Size the HOST, not just an inner wrapper.** The host custom element is the box the parent lays out. A host that is a flex/grid item in a centering parent (`flex justify-center`, `grid place-items-center`) is sized to its content unless it carries width itself. Put the sizing classes on the host (`w-full max-w-[400px]`), not only on an inner `<div>`. Symptom: a board or card renders tiny even though its inner grid says `w-full max-w-[400px]`. Fix: move the sizing onto the host.
85
154
 
155
+ ## Section rhythm: one gap, defined once
156
+
157
+ Per-element margins can never give consistent vertical spacing: each element controls only one side, so a block's gap-above (the previous element's margin) drifts from its gap-below (its own). For a content column (a docs page, a demo page, an article), make the COLUMN own the spacing with a flex stack, and zero the children's own margins so only the one gap applies:
158
+
159
+ ```css
160
+ .stack { --section-gap: 1.5rem; display: flex; flex-direction: column; gap: var(--section-gap); }
161
+ .stack > * { margin: 0; }
162
+ ```
163
+
164
+ Every top-level child (heading, paragraph, component, list) is then equally spaced from ONE variable, and a spacing change is a one-line edit. Flex `gap` beats forcing `display: block` + margins on children: a `grid`/`flex` child keeps its own layout (a blanket `display: block` clobbers it), an inline shadow-DOM host is blockified as a flex item so it honours the gap, a `display: contents` element (a streaming `<webjs-suspense>`) is replaced by its children which become the flex items, and a `display: none` node (a streaming `<script>`/`<template>`) is not an item at all, so it gets no phantom gap. A group that must stay tight (a caption directly above its code block) wraps in one child `<div>` and keeps its own inner spacing. The gallery's `/features` layout (`demo-stack`) is the worked example.
165
+
86
166
  ## Even grids, no reflow
87
167
 
88
168
  The reflow bug (a cell grows when it gets content while the others shrink) comes from `auto`-sized grid rows. Size the tracks explicitly so every cell is an equal fraction regardless of content:
@@ -1,8 +1,13 @@
1
1
  # The `@webjsdev/ui` component kit
2
2
 
3
- Load this when the app has a `components.json` (it uses `@webjsdev/ui`, the
4
- shadcn-style kit for WebJs). The source is copied into your repo (`components/ui/`),
5
- so you own and edit it. Two tiers:
3
+ Load this when the app uses `@webjsdev/ui` (a `components.json` is present), OR
4
+ when you are about to add a UI primitive (button, card, input, badge) to a fresh
5
+ app that has not initialised the kit yet: running `webjs ui init` then
6
+ `webjs ui add <name>` is HOW the kit comes to exist, and it is the default for a
7
+ repeated primitive over hand-writing one from scratch. `@webjsdev/ui` is the shadcn-style
8
+ kit for WebJs. The source is copied into your repo (`components/ui/`), so you own
9
+ and edit it exactly as freely as code you wrote yourself (the copied file is
10
+ yours to trim and theme). Two tiers:
6
11
 
7
12
  - **Tier 1, class helpers (23 components).** Pure functions returning Tailwind
8
13
  class strings (`buttonClass({ variant })`, `cardClass()`), composed with
@@ -30,14 +30,22 @@ The order matters:
30
30
  `.agents/skills/webjs/` teaches the same patterns and SURVIVES the clear, so
31
31
  clearing is not a knowledge-loss event, the gallery is just a runnable bonus.
32
32
  2. **Clear it.** Run `npm run gallery:clear` to shed the whole gallery in one
33
- step (removes `app/features/`, `app/examples/`, the demo `modules/`, the demo
34
- `todos` table, and resets `app/page.ts` to a minimal home), while KEEPING the
35
- agent skill, the layout, and the database wiring.
33
+ step: it removes `app/features/`, `app/examples/`, the demo `modules/`, the
34
+ gallery's example design system (`components/ui/`, the theme-toggle), the
35
+ example tests, and the demo `todos` table, and resets `app/page.ts` to a
36
+ minimal home AND `app/layout.ts` to a token-free blank slate (OS system
37
+ colours, no navbar, no palette). A layout you already customised (the gallery
38
+ brand removed) is KEPT, with only the theme-toggle wiring stripped. It KEEPS
39
+ the agent skill, the database wiring, and `lib/utils/cn.ts` (the
40
+ `webjs ui add` prerequisite).
36
41
  3. **Build.** Regenerate the database (`npm run db:generate` then `npm run
37
42
  db:migrate`), then grow the app in place: routes under `app/`, components
38
43
  under `components/`, features under `modules/<feature>/`, server-only code
39
- behind `.server.ts`, and the app's own palette via the tokens in
40
- `app/layout.ts`.
44
+ behind `.server.ts`. Build the app's OWN design system from the blank slate:
45
+ define design tokens in `app/layout.ts` and pull primitives with
46
+ `npx webjsdev ui add <name>` then theme the copied source (you own it, so
47
+ modify it rather than hand-writing a primitive from scratch), following
48
+ `.agents/skills/webjs/references/styling.md`.
41
49
 
42
50
  If you are only exploring, keep the gallery and browse it.
43
51
 
@@ -1,14 +1,11 @@
1
- // (delete this file), then delete this marker line. webjs check fails while the
2
- // marker remains.
3
- //
4
1
  // app/apple-icon.ts serves /apple-icon (the Apple touch icon iOS uses when a
5
2
  // visitor adds the site to their home screen). Apple expects a 180x180 square
6
3
  // with no rounded corners (iOS rounds them). Same shape as icon.ts: return a
7
4
  // Response with the exact content type. Swap the inline SVG for your real mark.
8
5
  export default function AppleIcon() {
9
6
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="180" height="180" viewBox="0 0 180 180">
10
- <rect width="180" height="180" fill="#1c1613"/>
11
- <text x="90" y="120" font-family="system-ui, sans-serif" font-size="104" font-weight="700" fill="#ff8a3d" text-anchor="middle">w</text>
7
+ <rect width="180" height="180" fill="#1e2226"/>
8
+ <text x="90" y="120" font-family="system-ui, sans-serif" font-size="104" font-weight="700" fill="#94989c" text-anchor="middle">w</text>
12
9
  </svg>`;
13
10
  return new Response(svg, {
14
11
  headers: { 'content-type': 'image/svg+xml', 'cache-control': 'public, max-age=3600' },
@@ -1,11 +1,16 @@
1
1
  import { html } from '@webjsdev/core';
2
+ import { backLink } from '#lib/utils/ui.ts';
2
3
 
3
4
  // Shared layout for every gallery example app under /examples/*. It adds the same
4
5
  // slim "back to the gallery" link the feature demos get, so an example is never a
5
6
  // dead end. A non-root layout, so it never writes the document shell.
6
7
  export default function ExamplesLayout({ children }: { children: unknown }) {
8
+ // An example app has no sidebar, so center it in a focused reading column
9
+ // (the root centers the whole page; this narrows the example within it).
7
10
  return html`
8
- <a href="/" class="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors no-underline mb-6">&larr; Gallery</a>
9
- ${children}
11
+ <div class="max-w-xl mx-auto">
12
+ <div class="mb-6">${backLink('/', html`&larr; Gallery`)}</div>
13
+ ${children}
14
+ </div>
10
15
  `;
11
16
  }
@@ -4,6 +4,7 @@
4
4
  // lives in modules/todo/. This is the idiomatic app-thin + modules-logic split.
5
5
  import { html } from '@webjsdev/core';
6
6
  import type { Metadata } from '@webjsdev/core'; // Metadata is a @webjsdev/core type
7
+ import { pageHeading } from '#lib/utils/ui.ts';
7
8
  import { listTodos } from '#modules/todo/queries/list-todos.server.ts';
8
9
  import { createTodo } from '#modules/todo/actions/create-todo.server.ts';
9
10
  import { toggleTodo } from '#modules/todo/actions/toggle-todo.server.ts';
@@ -16,7 +17,7 @@ export default async function TodoExample() {
16
17
  // SSR-fetched and seeded, so <todo-app> paints the real list on first byte.
17
18
  const todos = await listTodos();
18
19
  return html`
19
- <h1 class="text-h2 font-bold mb-4">Optimistic todo</h1>
20
+ ${pageHeading('Optimistic todo')}
20
21
  <todo-app .todos=${todos}></todo-app>
21
22
  `;
22
23
  }
@@ -1,5 +1,6 @@
1
1
  import { html, Suspense } from '@webjsdev/core';
2
2
  import type { Metadata } from '@webjsdev/core';
3
+ import { pageHeading, lede } from '#lib/utils/ui.ts';
3
4
  import '#modules/async-render/components/server-clock.ts';
4
5
 
5
6
  export const metadata: Metadata = { title: 'Async render (server data in first paint) | features' };
@@ -14,8 +15,8 @@ async function slowRegion() {
14
15
 
15
16
  export default function AsyncRenderExample() {
16
17
  return html`
17
- <h1 class="text-h2 font-bold mb-4">Async render</h1>
18
- <p class="text-muted-foreground mb-4">A component's <code>async render()</code> awaits server data. SSR blocks, so the resolved value is in the first paint (no fallback, readable with JS off).</p>
18
+ ${pageHeading('Async render')}
19
+ ${lede(html`A component's <code>async render()</code> awaits server data. SSR blocks, so the resolved value is in the first paint (no fallback, readable with JS off).`)}
19
20
  <server-clock></server-clock>
20
21
  <p class="text-muted-foreground mt-6 mb-2">For a SLOW region where blocking the first byte hurts, wrap it in <code class="font-mono">Suspense</code> to stream it instead:</p>
21
22
  ${Suspense({ fallback: html`<p class="text-muted-foreground">loading slow region…</p>`, children: slowRegion() })}
@@ -1,4 +1,5 @@
1
1
  import { html } from '@webjsdev/core';
2
+ import { buttonClass } from '#components/ui/button.ts';
2
3
 
3
4
  // Nested layout for the protected dashboard subtree. Logout is a plain
4
5
  // <form method="POST"> posting to the createAuth signout route: it clears the
@@ -12,7 +13,7 @@ export default function DashboardLayout({ children }: { children: unknown }) {
12
13
  <a href="/features/auth/dashboard" class="text-sm font-medium text-foreground hover:underline">Dashboard</a>
13
14
  <a href="/features/auth/dashboard/settings" class="text-sm font-medium text-foreground hover:underline">Settings</a>
14
15
  <form method="POST" action="/api/auth/signout" class="ml-auto">
15
- <button type="submit" class="px-3 py-1.5 rounded-lg border border-border text-sm text-foreground bg-transparent cursor-pointer transition-colors hover:bg-accent">Log out</button>
16
+ <button type="submit" class=${buttonClass({ variant: 'secondary', size: 'sm' })}>Log out</button>
16
17
  </form>
17
18
  </nav>
18
19
  ${children}