@webjsdev/cli 0.10.40 → 0.10.41

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 (69) hide show
  1. package/bin/webjs.js +4 -46
  2. package/lib/create.js +282 -479
  3. package/lib/doctor.js +1 -38
  4. package/package.json +5 -1
  5. package/templates/.agents/rules/workflow.md +61 -271
  6. package/templates/.agents/skills/webjs/SKILL.md +226 -0
  7. package/templates/.agents/skills/webjs/references/auth-and-sessions.md +220 -0
  8. package/templates/.agents/skills/webjs/references/built-ins.md +200 -0
  9. package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +204 -0
  10. package/templates/.agents/skills/webjs/references/components.md +167 -0
  11. package/templates/.agents/skills/webjs/references/data-and-actions.md +187 -0
  12. package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +170 -0
  13. package/templates/.agents/skills/webjs/references/optimistic-ui.md +128 -0
  14. package/templates/.agents/skills/webjs/references/routing-and-pages.md +158 -0
  15. package/templates/.agents/skills/webjs/references/runtime.md +80 -0
  16. package/templates/.agents/skills/webjs/references/service-worker.md +78 -0
  17. package/templates/.agents/skills/webjs/references/styling.md +123 -0
  18. package/templates/.agents/skills/webjs/references/testing.md +125 -0
  19. package/templates/.agents/skills/webjs/references/typescript.md +148 -0
  20. package/templates/.claude/hooks/check-server-imports.mjs +1 -1
  21. package/templates/.claude/hooks/require-tests-with-src.sh +1 -1
  22. package/templates/.claude/settings.json +0 -14
  23. package/templates/.cursorrules +21 -189
  24. package/templates/.github/copilot-instructions.md +7 -185
  25. package/templates/.github/pull_request_template.md +1 -1
  26. package/templates/AGENTS.md +59 -1494
  27. package/templates/CLAUDE.md +0 -1
  28. package/templates/CONVENTIONS.md +32 -1383
  29. package/templates/GEMINI.md +11 -0
  30. package/templates/gallery/app/apple-icon.ts +0 -1
  31. package/templates/gallery/app/examples/todo/page.ts +0 -1
  32. package/templates/gallery/app/features/async-render/page.ts +0 -1
  33. package/templates/gallery/app/features/boundaries/page.ts +0 -1
  34. package/templates/gallery/app/features/broadcast/page.ts +0 -1
  35. package/templates/gallery/app/features/caching/page.ts +0 -1
  36. package/templates/gallery/app/features/client-router/page.ts +0 -1
  37. package/templates/gallery/app/features/client-router/second/page.ts +0 -1
  38. package/templates/gallery/app/features/components/page.ts +0 -1
  39. package/templates/gallery/app/features/directives/page.ts +0 -1
  40. package/templates/gallery/app/features/env/page.ts +0 -1
  41. package/templates/gallery/app/features/file-storage/page.ts +0 -1
  42. package/templates/gallery/app/features/forms/page.ts +0 -1
  43. package/templates/gallery/app/features/metadata/page.ts +0 -1
  44. package/templates/gallery/app/features/optimistic-ui/page.ts +0 -1
  45. package/templates/gallery/app/features/rate-limit/page.ts +0 -1
  46. package/templates/gallery/app/features/route-handler/page.ts +0 -1
  47. package/templates/gallery/app/features/routing/page.ts +0 -1
  48. package/templates/gallery/app/features/server-actions/page.ts +0 -1
  49. package/templates/gallery/app/features/service-worker/page.ts +0 -1
  50. package/templates/gallery/app/features/sessions/page.ts +0 -1
  51. package/templates/gallery/app/features/websockets/page.ts +0 -1
  52. package/templates/gallery/app/global-error.ts +0 -1
  53. package/templates/gallery/app/global-not-found.ts +0 -1
  54. package/templates/gallery/app/icon.ts +0 -1
  55. package/templates/gallery/app/manifest.ts +0 -1
  56. package/templates/gallery/app/opengraph-image.ts +0 -1
  57. package/templates/gallery/app/robots.ts +0 -1
  58. package/templates/gallery/app/sitemap.ts +0 -1
  59. package/templates/gallery/app/twitter-image.ts +0 -1
  60. package/templates/public/favicon.svg +5 -0
  61. package/templates/public/sw.js +1 -1
  62. package/templates/scripts/clear-gallery.mjs +95 -0
  63. package/lib/clear-placeholders.js +0 -98
  64. package/lib/design-bar.js +0 -67
  65. package/templates/.claude/hooks/design-review-before-stop.sh +0 -36
  66. package/templates/.claude/hooks/route-skills.sh +0 -35
  67. package/templates/.claude/skills/webjs-design-review/SKILL.md +0 -84
  68. package/templates/LAYOUT-REFERENCE.md +0 -96
  69. package/templates/lib/utils/ui.ts +0 -83
@@ -1,1496 +1,61 @@
1
1
  # AGENTS.md for {{APP_NAME}}
2
2
 
3
- Read this before editing any file. This is a webjs app: AI-first, web-
4
- components-first, no build step. The framework's own full API reference
5
- lives at https://github.com/webjsdev/webjs/blob/main/AGENTS.md and the
6
- full hosted documentation (every API, recipe, and example) lives at
7
- **https://docs.webjs.dev**. Treat this file as the app-scoped
8
- companion and reach for docs.webjs.dev whenever you need more detail.
9
-
10
- ## If you just scaffolded this app (AI agents, read first)
11
-
12
- This project was created with `webjs create`. The files you see right
13
- now (`app/page.ts` printing "Hello from {{APP_NAME}}", the example `User`
14
- model in `db/schema.server.ts`, the `theme-toggle` component, the
15
- example users module in api/saas templates) are **starting-point
16
- references, not the final product**. Your job is to replace them with
17
- the app the user actually asked for. That includes designing
18
- `app/layout.ts`, not just the page. It ships as a MINIMAL shell (theme,
19
- design tokens, and Tailwind infra, then `${children}` in a bare padded
20
- container) with NO header, nav, footer, or reading column, so design the
21
- app's own chrome from scratch. `LAYOUT-REFERENCE.md` at the project root is
22
- a complete worked layout (fixed header, brand, nav, theme toggle, reading
23
- column, footer) to learn the patterns from, then build your own. **Give the
24
- app a unique design, and redesign means more than recolor.** When it has a
25
- UI, choose its palette, typography, LAYOUT, and chrome from what the app IS.
26
- Decide whether it needs a header at all, a nav (or none), a footer, a
27
- sidebar, a centered reading column, or a full-bleed canvas (a centered
28
- board, a full-bleed dashboard, a split, a single card). The `api` template
29
- has no UI, so this does not apply there. The design tokens and theme wiring
30
- are infrastructure to keep and set your own palette VALUES on. Style with
31
- Tailwind utilities wherever they reach, and use custom CSS only for what
32
- utilities cannot express (@theme tokens, @keyframes, scrollbar, complex
33
- color-mix or gradients). This is ENFORCED:
34
- the example `app/page.ts` and `app/layout.ts` carry a
35
- `webjs-scaffold-placeholder` marker comment, and `webjs check` fails
36
- while any marker remains, so this freshly scaffolded app fails the check
37
- until you replace the example content (or deliberately keep it) and
38
- delete the marker line. To keep the gallery and clear every marker at
39
- once, run `webjs check --clear-placeholders` (it strips the marker lines
40
- and keeps the demo code), then delete any demo you do not want. The
41
- delivered app must contain only what the user asked for, never leftover
42
- scaffold code.
43
-
44
- **Render the app and LOOK before you call UI work done (every agent, not
45
- just one harness).** You write CSS blind, so a layout or design defect
46
- ships silently: `webjs check` and `webjs typecheck` pass even when a
47
- component collapses, grid cells are uneven, the layout resizes as it fills,
48
- or the app just kept the scaffold's colors. Static tools give no failure
49
- signal for this. The only thing that catches it is rendering the app and
50
- looking at the pixels. So for ANY page, layout, or component work: run it
51
- (`webjs dev`), open every route you changed in a real browser (drive it
52
- with your harness's browser tool or MCP if it has one, otherwise open it
53
- yourself and screenshot), and PLAY THROUGH every state (empty, filled, win,
54
- draw, reload, narrow and wide, light and dark). Confirm nothing collapses
55
- or reflows, that cells stay equal, that the design is the app's OWN, and
56
- that both themes read. Ship a real-browser test (`webjs test --browser`)
57
- for the mechanical floor (measure `getBoundingClientRect()` and assert
58
- cells stay equal across a move). Fix and re-render until it holds, then
59
- state what you rendered and confirmed. Claude Code additionally ENFORCES
60
- this via the `webjs-design-review` skill plus a Stop hook, but the
61
- discipline is harness-agnostic and applies to every agent.
62
-
63
- **Non-negotiables for every webjs app:**
64
-
65
- 1. **Use Drizzle + SQLite for persistence.** It's already wired up
66
- (`db/schema.server.ts`, `db/connection.server.ts`, `npm run db:generate`
67
- + `npm run db:migrate`). For any data the app stores (todos, posts,
68
- messages, products, comments, anything), define a Drizzle table and
69
- persist there.
70
- - **NEVER** store app data in JSON files (`data/todos.json`,
71
- `db.json`, …). It resets on reload and cannot scale. This is a project convention,
72
- and the user's prompt explicitly forbids it.
73
- - **NEVER** use in-memory arrays or `Map`s as a substitute for the
74
- database. They vanish on every dev-server reload and aren't
75
- shared across processes.
76
- - **NEVER** use `localStorage` to persist app data. It's per-browser
77
- and doesn't reach the server.
78
- 2. **One of three scaffolds only.** The CLI exposes exactly three:
79
- `full-stack` (default), `--template api`, `--template saas`. Don't
80
- reach for a `--template blog` / `--template todo` / `--template
81
- ecommerce`. They don't exist and the CLI will reject them.
82
- 3. **First step after scaffolding:** edit `db/schema.server.ts` to the
83
- app's real domain models (delete the example `User` model unless the
84
- app actually needs users), run `webjs db generate` then
85
- `webjs db migrate`, then build pages / actions / queries against them.
86
- 4. **Prune what the app does not use.** The scaffold is reference, so keep
87
- the infrastructure the app USES and delete the rest, both files AND
88
- folders. No persistence means delete `db/`, `drizzle.config.ts`, and
89
- the `db:*` scripts. No UI kit used means delete `components/ui/`,
90
- `components.json`, and `lib/utils/cn.ts`. No PWA means delete
91
- `public/sw.js` and `offline.html`. Always KEEP the durable knowledge
92
- (`AGENTS.md`, `CONVENTIONS.md`, the per-agent rule files, the MCP
93
- wiring), never prune it, so removing example code never removes your
94
- context. Prune AFTER you have used the features and examples as reference, never
95
- blindly up front. This is a no-op for the `api` template (no UI kit,
96
- no PWA files).
97
-
98
- **Picking the right scaffold from the user's prompt** (you do this BEFORE
99
- running `webjs create`; if you're reading this you've already scaffolded.
100
- Verify the choice was correct, otherwise re-scaffold in a fresh dir):
101
-
102
- | User asks for… | Scaffold |
103
- |---|---|
104
- | Todo app, blog, notes, dashboard, marketplace, social feed, e-commerce, any product with a UI | `webjs create <name>` (default full-stack) |
105
- | HTTP/JSON API only, no UI | `webjs create <name> --template api` |
106
- | Anything with login / signup / accounts / protected pages / SaaS | `webjs create <name> --template saas` |
107
-
108
- When in doubt, **full-stack is the default**. Pick `api` only if the user
109
- is explicit about wanting a backend-only API. Pick `saas` only if the user
110
- is explicit about auth / accounts / SaaS.
111
-
112
- ## Framework source is in `node_modules/`
113
-
114
- No build step, no bundler, no minification. What you read is what
115
- runs. When in doubt, grep the framework:
116
-
117
- ```
118
- node_modules/@webjsdev/
119
- core/ renderer, WebComponent, directives, client router,
120
- Task, context, testing helpers
121
- src/component.js ← lifecycle, properties, light vs shadow DOM
122
- src/render-client.js ← client-side DOM patching + hydration
123
- src/render-server.js ← renderToString / renderToStream
124
- src/router-client.js ← Turbo-Drive-style client navigation
125
- src/directives.js ← unsafeHTML, live
126
- src/context.js ← Context Protocol
127
- src/task.js ← async data with states
128
- server/ dev + prod server, SSR, file router, actions,
129
- auth, sessions, cache, rate-limit, WebSocket
130
- src/ssr.js ← how metadata becomes <head> tags
131
- src/router.js ← file convention → route table
132
- src/actions.js ← .server.ts scanner, RPC stubs, action endpoint
133
- src/action-route.js ← route() adapter (action over REST via route.ts)
134
- src/auth.js, session.js, cache.js, rate-limit.js, csrf.js
135
- cli/ webjs CLI (dev / start / build / test / check / create / db)
136
- intellisense/ tsserver plugin: go-to-definition + diagnostic suppression
137
- + attribute auto-complete for Class.register('tag') elements
138
- ```
139
-
140
- Reaching straight for the source is the fastest way to resolve "why
141
- doesn't X work?" with no documentation guesswork and no stale blog posts.
142
-
143
- ## Use the webjs MCP server (introspection + framework knowledge)
144
-
145
- This project ships a **read-only Model Context Protocol server** that gives
146
- you (the AI agent) live, version-accurate facts about THIS app and the
147
- framework. Prefer it over guessing or recalling webjs from training data,
148
- which drifts. It mutates nothing.
149
-
150
- **It is already available, no install needed:** the webjs CLI (a project
151
- dependency) has it built in as `webjs mcp`. It is an MCP STDIO server (JSON-RPC
152
- over stdout), so you do not run it in a terminal and read its output. Your MCP
153
- host (Claude Code, Cursor, etc.) launches it and surfaces its tools, then you
154
- invoke those tools through the MCP protocol.
155
-
156
- Claude Code is pre-wired (see `.claude.json`). For another host, register the
157
- server by pointing it at the CLI (or the equivalent standalone package):
158
-
159
- ```jsonc
160
- // Cursor: .cursor/mcp.json (or your host's MCP config)
161
- { "mcpServers": { "webjs": {
162
- "command": "npx", "args": ["@webjsdev/cli", "mcp"] // the built-in CLI route
163
- // equivalent: "command": "npx", "args": ["@webjsdev/mcp"]
164
- } } }
165
- ```
166
-
167
- What it serves:
168
-
169
- - **Introspection of this app** (read-only, no module load, no DB side
170
- effects): `list_routes` (the route table), `list_actions` (server actions
171
- with their `/__webjs/action/<hash>/<fn>` RPC endpoints), `list_components`
172
- (registered custom-element tags), `check` (the structured `webjs check`
173
- violations). Use these to learn the real route/action/component surface
174
- before editing, instead of grepping or assuming.
175
- - **Framework knowledge**: an `init` primer (the read-first mental model +
176
- invariants), a `docs` tool (retrieve a topic or search the `agent-docs`
177
- corpus), MCP `resources` (the docs corpus + this AGENTS.md), recipe
178
- `prompts` (guided page/route/action/component workflows), and a `source`
179
- tool that reads the framework's OWN no-build source from
180
- `node_modules/@webjsdev/*/src` (what actually runs).
181
-
182
- You have TWO complementary ways to understand the framework, use whichever
183
- helps (or both): (1) **grep the full framework source** under
184
- `node_modules/@webjsdev/*/src`, which is the real no-build code that runs (no
185
- sourcemaps, no guessing), and (2) **the MCP** for live app introspection plus
186
- the curated `init` / `docs` / `source` knowledge tools. Reach for either before
187
- guessing from training data or asking the user.
188
-
189
- ## Editor TS plugin: `@webjsdev/intellisense`
190
-
191
- This scaffold's `tsconfig.json` lists a single tsserver plugin. It is
192
- editor-only, not required for the framework to run.
193
-
194
- ```jsonc
195
- // tsconfig.json (already wired by the scaffold)
196
- "plugins": [
197
- { "name": "@webjsdev/intellisense" }
198
- ]
199
- ```
200
-
201
- `@webjsdev/intellisense` is **standalone** (no Lit dependency): one plugin
202
- entry, its own template parser. Inside `` html`…` `` templates you get:
203
-
204
- - Go-to-definition on custom-element tags, attribute / property / event
205
- names, and CSS classes in `class="…"`.
206
- - Binding-aware completions: reachable tag names after `<`, and
207
- prefix-keyed attributes (`.prop` property names, `?bool` / plain
208
- hyphenated attribute names).
209
- - Diagnostics: value type-checks against the reactive props declared in
210
- `WebComponent({ ... })`, unquoted `@`/`.`/`?` bindings, and
211
- expressionless `.prop` bindings.
212
- - Hover showing the component class / declared member type.
213
-
214
- In VS Code / Cursor / Windsurf, the **`webjs` extension** bundles this
215
- automatically (no `tsconfig.json` edit, no separate Lit extension).
216
-
217
- See [docs.webjs.dev → Editor setup](https://docs.webjs.dev/docs/editor-setup)
218
- for the full walkthrough.
219
-
220
- **Config validation in `package.json`.** The scaffold ships
221
- `.vscode/settings.json`, which associates the published webjs-config JSON
222
- Schema (`@webjsdev/server/webjs-config.schema.json`) with the `webjs` block
223
- of `package.json`. In VS Code an unknown / typo'd `webjs.*` key (`redirect`
224
- for `redirects`, say) is then flagged inline instead of silently dropped to
225
- the default. The same shape is typed by the `WebjsConfig` type from
226
- `@webjsdev/core` (`import type { WebjsConfig } from '@webjsdev/core'`) for a
227
- typed reference.
228
-
229
- ## UI components: Webjs UI (preinstalled)
230
-
231
- This scaffold ships with the standard Webjs UI component kit
232
- **already installed at `components/ui/`**. The kit is **AI-first** and
233
- splits into two tiers. Internalise the split. Picking the wrong tier
234
- produces broken markup.
235
-
236
- ### Tier 1: class-helper functions (the majority)
237
-
238
- Pure functions that return Tailwind class strings. You apply them to
239
- **raw native HTML elements** that you write yourself. Examples:
240
- `button`, `card`, `input`, `label`, `alert`, `badge`, `separator`,
241
- `skeleton`, `kbd`, `table`, `breadcrumb`, `pagination`, `native-select`,
242
- `avatar`, `checkbox`, `switch`, `radio-group`, `textarea`, `toggle`,
243
- `aspect-ratio`.
244
-
245
- ```ts
246
- import {
247
- cardClass, cardHeaderClass, cardTitleClass,
248
- cardContentClass, cardFooterClass,
249
- } from '#components/ui/card.ts';
250
- import { inputClass } from '#components/ui/input.ts';
251
- import { labelClass } from '#components/ui/label.ts';
252
- import { buttonClass } from '#components/ui/button.ts';
253
-
254
- return html`
255
- <div class=${cardClass()}>
256
- <div class=${cardHeaderClass()}>
257
- <h3 class=${cardTitleClass()}>Profile</h3>
258
- </div>
259
- <div class=${cardContentClass()}>
260
- <label class=${labelClass()} for="name">Name</label>
261
- <input class=${inputClass()} id="name" name="name">
262
- </div>
263
- <div class=${cardFooterClass()}>
264
- <button class=${buttonClass()}>Save</button>
265
- </div>
266
- </div>
267
- `;
268
- ```
269
-
270
- Helpers with variants take an options object:
271
- `buttonClass({ variant: 'outline', size: 'sm' })`.
272
-
273
- ### Tier 2: stateful custom elements
274
-
275
- For things the browser doesn't provide natively (focus traps, portaled
276
- overlays, keyboard-navigated lists): `dialog`, `alert-dialog`, `popover`,
277
- `tooltip`, `hover-card`, `tabs`, `accordion`, `collapsible`,
278
- `dropdown-menu`, `progress`, `sonner`, `toggle-group`. These ARE custom
279
- elements. Import them once (typically in `app/layout.ts`) and use
280
- `<ui-X>` tags:
281
-
282
- ```ts
283
- // app/layout.ts (registers the custom elements for every page)
284
- import '#components/ui/dialog.ts';
285
- import '#components/ui/tabs.ts';
286
- ```
287
-
288
- ```ts
289
- // app/some-page/page.ts (uses the registered elements)
290
- import { buttonClass } from '#components/ui/button.ts';
291
-
292
- return html`
293
- <ui-dialog>
294
- <ui-dialog-trigger>
295
- <button class=${buttonClass({ variant: 'outline' })}>Edit</button>
296
- </ui-dialog-trigger>
297
- <ui-dialog-content>
298
- <h2>Edit profile</h2>
299
- ...
300
- </ui-dialog-content>
301
- </ui-dialog>
302
- `;
303
- ```
304
-
305
- ### Adding more components
306
-
307
- ```sh
308
- webjs ui add dialog dropdown-menu tabs progress
309
- ```
310
-
311
- Each `webjs ui add` call fetches the component source from
312
- `https://ui.webjs.dev/registry/<name>.json`, copies it into
313
- `components/ui/`, and installs any required npm deps. Run
314
- `webjs ui list` to browse the catalogue or visit
315
- [https://ui.webjs.dev](https://ui.webjs.dev).
316
-
317
- ### AI agents, picking the right tier
318
-
319
- For forms, dashboards, settings pages, marketing layouts: **call the
320
- Tier-1 class helpers on raw native elements**. You get accessibility,
321
- visual consistency, and form submission semantics for free.
322
- `<input class=${inputClass()}>` is a real `<input>` with native
323
- autofill, browser validation, and `<form>` submission unchanged.
324
-
325
- Because Tier-1 helpers wrap *real* HTML elements, a `buttonClass()`
326
- button inside a `<form action="/posts" method="post">` participates
327
- in the client router's partial-swap submission automatically. No JS
328
- handler, no `fetch`. See *Client navigation patterns* below for the
329
- full form-submission + 4xx-HTML-render-in-place pattern.
330
-
331
- For modals, dropdowns, tooltips, tab strips, accordions: use the
332
- Tier-2 `<ui-X>` custom element tags after importing the corresponding
333
- module.
334
-
335
- The composition style is deliberately **not** shadcn's
336
- component-everything React API. We use native elements + class helpers
337
- for the visual stuff because hiding a `<button>` inside a `<Button>`
338
- wrapper adds zero value and obscures the real element from inspection,
339
- form submission, and screen readers. Custom elements are reserved for
340
- behavior the browser can't deliver natively.
341
-
342
- ### Accessible control labeling
343
-
344
- Give every interactive control an accessible name, and make clickable
345
- text a `<label for="control-id">` (or the control itself) so a text click
346
- activates the control on BOTH the JS path and the no-JS form-submit path.
347
- Use `aria-label` and `aria-pressed` on icon-only controls (a toggle
348
- button, an icon-only close/menu button). A native `<input>` under a
349
- `<label>` gets this for free, which is another reason to reach for the
350
- Tier-1 class helpers on real elements. In a browser test,
351
- `assertNoA11yViolations(el)` from `@webjsdev/core/testing` catches
352
- missing labels.
353
-
354
- ## File conventions
355
-
356
- ```
357
- app/ ROUTING ONLY: thin route adapters (import from modules/).
358
- No CSS, helpers, or constants here; those live in
359
- styles/, lib/utils/, and modules/. globals.css is at
360
- styles/, NOT app/.
361
- page.ts → / (the scaffold home links to the gallery)
362
- features/<name>/ single-feature demos (routing, boundaries, components,
363
- server-actions, optimistic-ui, async-render,
364
- directives, route-handler, forms, metadata, caching,
365
- env, client-router, service-worker); prune what you skip
366
- examples/<name>/ whole example apps that compose features (todo);
367
- prune what you skip
368
- layout.ts root layout, wraps every page
369
- error.ts error boundary (render failures → user-friendly)
370
- loading.ts Suspense fallback for sibling page
371
- not-found.ts custom 404 page (nearest wins on notFound())
372
- forbidden.ts 403 page (nearest wins on forbidden())
373
- unauthorized.ts 401 page (nearest wins on unauthorized())
374
- global-error.ts root-only app-wide error boundary (owns its <html>)
375
- global-not-found.ts root-only 404 for an unmatched-anywhere URL
376
- middleware.ts global request middleware
377
- [slug]/page.ts dynamic route segment
378
- [...rest]/page.ts catch-all
379
- (group)/ route group (parens not in URL)
380
- _private/ underscore = not routable
381
- api/
382
- <path>/route.ts GET / POST / PUT / DELETE / WS handlers
383
- sitemap.ts metadata route → /sitemap.xml
384
- robots.ts metadata route → /robots.txt
385
- opengraph-image.ts metadata route → /opengraph-image
386
- components/ web components (extend WebComponent, call .register())
387
- modules/<feature>/
388
- actions/*.server.ts server actions (one function per file)
389
- queries/*.server.ts data reads (one function per file)
390
- components/*.ts feature-scoped components
391
- utils/*.ts feature-scoped helpers
392
- types.ts feature types
393
- lib/
394
- ... cross-cutting infra (session, auth config, etc.)
395
- styles/
396
- globals.css @webjsdev/ui theme tokens (NOT in app/; app/ is routing-only)
397
- db/
398
- schema.server.ts Drizzle models + relations (your data layer)
399
- columns.server.ts column helpers (dialect-specific; the only file to swap for Postgres)
400
- connection.server.ts opens the driver, exports the \`db\` singleton (import \`db\` from here)
401
- seed.server.ts optional seed (run via \`webjs db seed\`)
402
- dev.db SQLite file (gitignored); created when migrations apply (\`dev\`/\`start\` run \`webjs db migrate\`)
403
- migrations/ generated migration SQL (committed)
404
- drizzle.config.ts drizzle-kit config (root; SQLite by default, --db postgres to switch)
405
- public/ static assets at /public/* (favicon, sw.js, offline.html serve at root)
406
- test/<feature>/ feature-scoped tests, one folder per concern
407
- <name>.test.ts node unit / integration test (node --test)
408
- browser/<name>.test.js real-browser test (web-test-runner); may ALSO be
409
- co-located next to a component, e.g.
410
- modules/<feature>/components/browser/<name>.test.js
411
- (see the gallery counter-card test for the idioms:
412
- suite/test, ssrFixture, inline assert, no chai)
413
- e2e/<name>.test.ts end-to-end test (full app boot, opt in via WEBJS_E2E=1)
414
- smoke/<name>.test.ts fast post-deploy sanity check
415
- middleware.ts root middleware (optional, outermost)
416
- instrumentation.ts optional boot hook: register() runs once; wire APM via setOnError
417
- instrumentation-client.ts optional client boot hook, runs first before app modules
418
- ```
419
-
420
- ### The gallery (reference content, prune it)
421
-
422
- The scaffold ships a gallery organized by KIND, so features and whole apps are
423
- not mixed:
424
- - `app/features/<name>/` are single-feature demos, one webjs concept each
425
- (routing, boundaries, components, server-actions, optimistic-ui, async-render,
426
- directives, route-handler, forms, metadata, caching, env, client-router,
427
- service-worker, plus the infra demos websockets, file-storage, rate-limit,
428
- broadcast).
429
- - `app/examples/<name>/` are whole example apps that compose several features
430
- (todo: optimistic UI + progressive enhancement + a11y + db + modules).
431
-
432
- Both keep their logic in `modules/<name>/`. Each route is small, idiomatic, and
433
- heavily commented, and the gallery is your PRIMARY reference for how webjs works.
434
-
435
- **Study the whole gallery FIRST, prune SECOND.** Before you write or delete
436
- anything, read every feature demo and the example app end to end (the code AND
437
- the comments) to absorb the idioms you will reuse: the modules split, signals,
438
- the `optimistic()` API, `async render()`, the `.server.ts` vs `'use server'`
439
- boundary, progressive-enhancement forms, `<label for>` a11y, dynamic routes, and
440
- `route.ts` handlers. Only AFTER you have internalised the patterns should you
441
- prune. Never delete the examples blindly up front (that throws away your context
442
- before you have read it), and never prune the durable knowledge surfaces
443
- (`AGENTS.md`, `CONVENTIONS.md`, the per-agent rule files), which stay as context
444
- for every future iteration.
445
-
446
- Then prune: the examples are REFERENCE, not your app, so keep and adapt the ones
447
- you need and delete the rest. Pruning a route means deleting its
448
- `app/features/<name>` or `app/examples/<name>` folder AND its `modules/<name>`
449
- folder (and, for the todo app, the `todos` table in `db/schema.server.ts`), then
450
- removing its link from `app/page.ts`. Each route page carries a
451
- `webjs-scaffold-placeholder` marker so `webjs check` fails until you have
452
- consciously kept-and-adapted or pruned it. After pruning, delete any now-empty
453
- directories (an empty `lib/utils/` or `modules/<name>/` is leftover scaffolding,
454
- not structure).
455
-
456
- ### Typed page / layout / route-handler props
457
-
458
- Type page / layout / route-handler arguments with the exported helpers so a
459
- param typo is a compile-time error:
460
-
461
- ```ts
462
- import type { PageProps, LayoutProps, RouteHandlerContext } from '@webjsdev/core';
463
-
464
- export default function Post({ params }: PageProps<'/blog/[slug]'>) {
465
- return html`<h1>${params.slug}</h1>`; // params typed { slug: string }
466
- }
467
- export default function RootLayout({ children }: LayoutProps) { /* ... */ }
468
- export async function GET(req: Request, ctx: RouteHandlerContext) { /* ctx.params */ }
469
- ```
470
-
471
- Run `webjs types` once (and ensure `tsconfig.json` `include` lists
472
- `.webjs/routes.d.ts`, the scaffold already does) to generate the route union:
473
- `PageProps<'/blog/[slug]'>['params']` then narrows to `{ slug: string }` and
474
- `navigate()` only accepts real app routes. `webjs dev` regenerates the file on
475
- startup, so it stays current. Without it, `params` is `Record<string, string>`
476
- and `navigate()` accepts any string (non-breaking).
477
-
478
- ## Database (Drizzle + SQLite by default)
479
-
480
- Every scaffold includes a Drizzle setup pointed at a local SQLite file,
481
- under a `db/` folder (`schema.server.ts`, `columns.server.ts`,
482
- `connection.server.ts`). Drizzle has no codegen and no engine binary.
483
- First-run workflow:
484
-
485
- ```sh
486
- cp .env.example .env # DATABASE_URL is pre-filled for SQLite
487
- npm run db:generate # schema -> SQL migration (drizzle-kit)
488
- npm run db:migrate # apply it (creates db/dev.db)
489
- npm run dev # webjs dev, then serves
490
- ```
491
-
492
- ### `npm run dev` / `npm start` and `webjs dev` / `webjs start` behave identically
493
-
494
- `npm run dev` and `npm start` are the documented entrypoints, and they
495
- are thin aliases for `webjs dev` / `webjs start`. The start orchestration
496
- (applying migrations, and compiling Tailwind) lives in the `webjs` block
497
- of `package.json` and runs INSIDE `webjs dev` / `webjs start`:
498
-
499
- ```jsonc
500
- "webjs": {
501
- "dev": {
502
- "before": ["webjs db migrate", "tailwindcss -i ./public/input.css -o ./public/tailwind.css --minify"],
503
- "regenerate": [
504
- { "output": "public/tailwind.css",
505
- "command": "tailwindcss -i ./public/input.css -o ./public/tailwind.css --minify",
506
- "inputs": ["app", "components", "modules", "lib", "public/input.css"] }
507
- ]
508
- },
509
- "start": { "before": ["webjs db migrate", "tailwindcss -i ./public/input.css -o ./public/tailwind.css --minify"] }
510
- }
511
- ```
512
-
513
- Both `dev` and `start` apply pending migrations via `webjs db migrate`
514
- (idempotent, a no-op when the db is current) and compile the static
515
- `public/tailwind.css`, so a freshly generated migration is applied and
516
- the app is fully styled with no manual step. In dev the stylesheet is
517
- then kept fresh by `webjs.dev.regenerate`: the dev server recompiles it
518
- ON REQUEST whenever a source changes, so a newly added utility class is
519
- never served stale and there is no `tailwindcss --watch` process that can
520
- die mid-session (`webjs.dev.parallel` still exists for a genuinely
521
- long-lived side process, torn down on exit).
522
- `before` steps run to completion first; a failed `webjs db migrate`
523
- aborts the boot with a clear message rather than serving a stale schema.
524
-
525
- In Docker / Railway, `CMD ["npm", "start"]` and `CMD ["webjs", "start"]`
526
- are equivalent: `webjs start` runs `webjs.start.before` (`webjs db
527
- migrate`) in-process before serving, so the migrate no longer depends on
528
- an npm `prestart` hook.
529
-
530
- ### Running on Bun instead of Node
531
-
532
- WebJs runs on **Node 24+ or Bun**. The same `package.json` scripts work on
533
- either; to run under Bun, force it with `--bun` so the server executes on Bun
534
- rather than the `webjs` bin's Node shebang:
535
-
536
- ```sh
537
- bun install
538
- bun --bun run dev # or: bun --bun run start
539
- ```
540
-
541
- On Node the `.ts` type-stripping is the built-in `module.stripTypeScriptTypes`;
542
- on Bun (which has no built-in) it comes from `amaro` automatically, so the same
543
- source serves identically. SSR action-result seeding (an internal hydration
544
- optimization) works on both runtimes: Node installs it via `module.registerHooks`,
545
- Bun via a `Bun.plugin` `onLoad`, so an async-render component does not re-fetch
546
- on hydration on either runtime.
547
-
548
- **Containerized deploy ships with the scaffold.** `Dockerfile`,
549
- `compose.yaml`, and `.dockerignore` are scaffolded at the app root. The
550
- Dockerfile pins `node:24-alpine` (the same Node major CI uses), installs
551
- deps (no build step, since Drizzle has no codegen), and starts via
552
- `npm start` (`webjs start` runs `webjs.start.before` = `webjs db migrate`
553
- before serving). Run it locally with `docker compose up --build` (the
554
- app comes up on http://localhost:8080 against a SQLite file on a named
555
- volume). For production, point `DATABASE_URL` at managed Postgres and set
556
- `AUTH_SECRET`. The `.dockerignore` keeps the `.webjs/vendor/` importmap in
557
- the image while excluding `node_modules`, tests, and local state.
558
-
559
- **Health and readiness probes.** Every webjs server answers two endpoints:
560
- `/__webjs/health` (liveness, 200 once the process is listening) and
561
- `/__webjs/ready` (readiness, 503 until the instance is fully warm, then 200).
562
- Fully warm means the deterministic analysis AND the first vendor attempt have
563
- both completed, so the importmap and its build id are settled. Point your
564
- platform's readiness check at `/__webjs/ready` so it holds traffic off a
565
- not-yet-warmed instance instead of routing the first user request into the cold
566
- analysis or the brief window where the importmap is still resolving. The
567
- scaffolded `Dockerfile` and `compose.yaml` already wire this up with a
568
- `HEALTHCHECK` that probes `/__webjs/ready`, so any Docker-based deploy gets the
569
- gate with no extra config. On a platform that reads its own config instead,
570
- point its equivalent knob at the same path: Railway `"healthcheckPath":
571
- "/__webjs/ready"`, Render `healthCheckPath: /__webjs/ready`, Fly a
572
- `[[http_service.checks]]` on `/__webjs/ready`, or a Kubernetes `readinessProbe`
573
- with `httpGet.path: /__webjs/ready`. For dependency-aware readiness (gate on a
574
- live DB ping), add an optional `readiness.{js,ts}` at the app root that
575
- default-exports an async check; `/__webjs/ready` runs it once warm and reports
576
- 503 if it returns `false` or throws.
577
-
578
- Scripts (all wrap `drizzle-kit`):
579
-
580
- - `npm run db:generate`: `webjs db generate` (schema -> SQL migration)
581
- - `npm run db:migrate`: `webjs db migrate` (apply pending migrations)
582
- - `npm run db:push`: `webjs db push` (push the schema straight to the dev DB)
583
- - `npm run db:studio`: `webjs db studio` (visual DB browser)
584
- - `npm run db:seed`: `webjs db seed` (run `db/seed.server.ts`)
585
- - `webjs.dev.before` and `webjs.start.before` both run `webjs db migrate` inside `webjs dev` / `webjs start` (idempotent; replaces the old `prestart` hook), so after you `db:generate` a migration it is applied on the next boot with no manual `db:migrate` step.
586
- - The INITIAL migration for the shipped schema is authored by `webjs create` at setup time (right after install), so `db/migrations/` is populated and the first `run dev` works with no manual database step. You run `db:generate` yourself only when you CHANGE `db/schema.server.ts` (a new table or column), then the next `run dev` applies it.
587
-
588
- Always import `db` from `db/connection.server.ts` (the globalThis-cached
589
- singleton avoids opening a new connection on every dev-server reload), and
590
- the tables from `db/schema.server.ts`:
591
-
592
- ```ts
593
- import { db } from '#db/connection.server.ts';
594
- const users = await db.query.users.findMany();
595
- ```
596
-
597
- To switch to Postgres: scaffold with `--db postgres`, or swap
598
- `db/columns.server.ts` + `db/connection.server.ts` for the Postgres
599
- variants and point `DATABASE_URL` at Postgres. The schema, queries, and
600
- actions are unchanged.
601
-
602
- ## NPM packages (vendor pipeline)
603
-
604
- Adding a third-party npm package follows the same `npm install` flow
605
- as any Node project, with one webjs-specific concern: how the BROWSER
606
- fetches that package.
607
-
608
- ```sh
609
- npm install dayjs # standard npm install
610
- ```
611
-
612
- Now write `import dayjs from 'dayjs'` in any component or page. The
613
- import works in dev immediately. webjs's scanner discovers bare
614
- imports on the first request (memoized for the process) and asks
615
- `api.jspm.io` to resolve them to CDN URLs (jspm.io serves pre-bundled
616
- ESM for every npm package). The browser fetches the bundle directly
617
- from `https://ga.jspm.io`.
618
-
619
- **For production deploys**, run `webjs vendor pin` once and commit
620
- the result:
621
-
622
- ```sh
623
- webjs vendor pin # writes .webjs/vendor/importmap.json
624
- git add .webjs/vendor/
625
- git commit -m "vendor dayjs"
626
- ```
627
-
628
- The pin file holds the resolved jspm.io URLs. Server reads it from
629
- disk on the first request (memoized); no `api.jspm.io` call needed in
630
- production. Deterministic across deploys.
631
-
632
- **For offline-capable / strict-CSP production**, use `--download`:
633
-
634
- ```sh
635
- webjs vendor pin --download # also vendors bundle bytes locally
636
- git add .webjs/vendor/
637
- git commit -m "vendor + download dayjs"
638
- ```
639
-
640
- Bundle files land in `.webjs/vendor/<pkg>@<version>.js`. importmap
641
- points at local `/__webjs/vendor/` paths. Browser fetches from your
642
- own origin. Suitable for `script-src 'self'` CSP, air-gapped deploys,
643
- or compliance environments. See [docs.webjs.dev Deployment → CSP](https://docs.webjs.dev/docs/deployment#csp).
644
-
645
- **Other CLI commands:**
646
-
647
- ```sh
648
- webjs vendor list # show pinned packages with versions
649
- webjs vendor unpin <pkg> # remove one entry from pin file
650
- webjs vendor audit # npm security advisories against pinned versions
651
- webjs vendor outdated # list pinned packages with newer versions on npm
652
- webjs vendor update # re-pin every outdated package to its latest
653
-
654
- # Switch CDN at pin time (default: jspm.io). Resolver options:
655
- # jspm, jsdelivr, unpkg, skypack. Useful for jspm.io incident response.
656
- webjs vendor pin --from jsdelivr
657
- webjs vendor update --from jsdelivr
658
- ```
659
-
660
- Same posture as Rails 7 + importmap-rails: explicit pin command,
661
- committed manifest, optional `--download` for full offline capability,
662
- and a `--from` knob to swap the resolver CDN if jspm.io has an
663
- incident.
664
-
665
- **Don't auto-run `webjs vendor pin` in a `webjs.dev.before` / `webjs.start.before`
666
- step.** Auto-pin would silently churn the committed importmap.json as jspm.io
667
- resolves URLs or transitive deps drift. Pin is a deliberate developer action,
668
- like `npm install` itself.
669
-
670
- **Do NOT modify the `.webjs/` lines in `.gitignore` / `.dockerignore`.**
671
- The scaffolded `.gitignore` pattern is three lines (`**/.webjs/*` +
672
- `!**/.webjs/vendor/` + `!**/.webjs/vendor/**`) and is structurally
673
- load-bearing. Collapsing it to a single `.webjs/` excludes the parent
674
- directory; once the parent is excluded, git cannot re-include
675
- `.webjs/vendor/` via a child negation (gitignore semantics: parent
676
- exclusion blocks child negations). The breakage is invisible: `webjs
677
- vendor pin` runs, writes files, and git silently ignores them.
678
- Production then has no importmap.json and the server falls back to
679
- calling api.jspm.io on every cold start. The `**/` prefix matters too:
680
- it ignores `.webjs/` at any depth, so an app nested below its repo root
681
- (a monorepo package) does not leak its generated `.webjs/routes.d.ts`
682
- into `git status`. The `vendor-gitignore` check (`webjs doctor`)
683
- verifies the pattern with `git check-ignore` and warns if it regresses
684
- (it is a project-config / setup concern, not a source-code-correctness
685
- CI gate).
686
-
687
- ## Imports
688
-
689
- ```ts
690
- import { html, css, WebComponent } from '@webjsdev/core';
691
- import { unsafeHTML, live } from '@webjsdev/core/directives';
692
- import { createContext } from '@webjsdev/core/context';
693
- import { Task } from '@webjsdev/core/task';
694
- import { fixture, ssrFixture, waitForUpdate, assertNoA11yViolations } from '@webjsdev/core/testing';
695
-
696
- import { rateLimit, cors, cache, createAuth, Credentials, Session } from '@webjsdev/server';
697
- ```
698
-
699
- ## Environment variables (server vs browser)
700
-
701
- Server-only is the default. Any `process.env.X` read on the server stays on the server. Names that start with `WEBJS_PUBLIC_` are also exposed in the browser as `process.env.X`, via an inline script injected at SSR time. No build step.
702
-
703
- ```sh
704
- # .env
705
- DATABASE_URL=postgres://... # server-only
706
- AUTH_SECRET=... # server-only
707
- WEBJS_PUBLIC_API_URL=https://x.com # browser too
708
- ```
709
-
710
- ```ts
711
- // Server-side (page function, action, middleware, route handler):
712
- const dburl = process.env.DATABASE_URL; // works
713
-
714
- // Browser-side (component render method, client-only utilities):
715
- const url = process.env.WEBJS_PUBLIC_API_URL; // works
716
- const secret = process.env.AUTH_SECRET; // undefined (fail-closed)
717
- ```
718
-
719
- `process.env.NODE_ENV` is also defined in the browser (`'development'` in `webjs dev`, `'production'` in `webjs start`), so vendor bundles that probe it work without setup. Full docs: [Configuration](https://docs.webjs.dev/docs/configuration).
720
-
721
- ## Component pattern
722
-
723
- ```ts
724
- import { WebComponent, html, css } from '@webjsdev/core';
725
-
726
- // Recommended declare-free base-class factory style
727
- export class Counter extends WebComponent({
728
- count: Number
729
- }) {
730
- static styles = css`button { padding: 8px 12px; }`; // shadow-DOM only
731
- // static shadow = true; // opt into shadow DOM (default: light DOM)
732
- // static lazy = true; // download JS only when scrolled into view
733
-
734
- constructor() {
735
- super();
736
- this.count = 0; // SSR-meaningful default, see below
737
- }
738
-
739
- render() {
740
- return html`
741
- <button @click=${() => { this.count = this.count + 1; }}>
742
- ${this.count}
743
- </button>
744
- `;
745
- }
746
- }
747
- Counter.register('my-counter');
748
- ```
749
-
750
- **Progressive-enhancement rule for components.** Every webjs component
751
- is SSR'd. The server constructs the component, applies attributes,
752
- and runs `render()`. With JS disabled, the component's initial HTML
753
- still paints (an unstyled counter still shows the number, and only
754
- the click handler is inert). Two consequences for how you write code:
755
-
756
- 1. **Defaults for the first paint go in `constructor()`** (after
757
- `super()`), never as class-field initializers (which break
758
- reactivity) and never in `connectedCallback` (which the server
759
- doesn't run). For reactive properties declared via the
760
- `WebComponent({ ... })` factory, set the default in the constructor
761
- after `super()`.
762
- 2. **`connectedCallback` is browser-only.** Use it for
763
- `localStorage`, viewport size, online status, or anything that
764
- genuinely can't be known on the server. Read the value, then
765
- assign it to a reactive property (`this.items = stored`) or write
766
- to a signal to refine the render. The SSR'd first paint shows the
767
- constructor default. The browser refines after hydration.
768
- 3. **Server-known data goes through the page function**, not into
769
- `connectedCallback`. Fetch in the page (which runs on the server),
770
- pass the result down via `.prop=${value}` (custom elements) or
771
- `attr=${string}` (native elements). For custom elements, the wire
772
- serializer round-trips Array / Object / Date / Map / Set / BigInt
773
- through the SSR `data-webjs-prop-*` side-channel, so the
774
- component's first paint already has the rich-typed value with no
775
- flash. The framework owns the attribute, applies it on
776
- `connectedCallback`, then strips it from the live DOM. For native
777
- elements use `value=${v}` / `checked=${b}` etc.; `.value` on a
778
- native element drops at SSR (the property form is for client-only
779
- re-render scenarios like controlled inputs via `.value=${live(v)}`).
780
- 4. **For write-paths, prefer `<form>` + server action over `fetch`.**
781
- Plain forms POST without JS; the client router upgrades them to
782
- partial-swaps automatically when scripts are active. One
783
- implementation covers both.
784
-
785
- See [Progressive Enhancement](https://docs.webjs.dev/docs/progressive-enhancement) for the full design rationale.
786
-
787
- ## Lit muscle-memory gotchas (read if you have written lit before)
788
-
789
- Webjs's runtime API matches lit. The `WebComponent` base class,
790
- reactive properties (declared via the `WebComponent({ ... })` factory),
791
- the lifecycle hooks, ReactiveControllers, the
792
- directive set, `html` / `css` tagged templates. The **rendering
793
- model**, however, is different. Pure-lit patterns that work fine in a
794
- client-only lit app break in webjs's SSR pipeline or its reactivity
795
- system. Read this section before reaching for lit idioms.
796
-
797
- ### Mental model. JS opt-in per behavior, not per component
798
-
799
- Lit hydrates per component. You decide at the component boundary
800
- whether JS ships and runs for that island.
801
-
802
- Webjs ships JS per **interactive behavior**, not per component. Every
803
- component is server-rendered. JavaScript is requested by the specific
804
- holes you write in the template.
805
-
806
- - `@click=${...}`, `@input=${...}`, any event binding requests JS.
807
- - A reactive property assignment (`this.count = …`) or a signal
808
- `set()` that the component reads requests JS for reactive updates.
809
- - `.prop=${richObject}` requests JS for property hydration.
810
- - A controller like `Task` requests JS for that async behavior.
811
- - A plain `<a href>`, a `<form action method>` submission, or a
812
- purely display-time component (no event listeners, no property
813
- mutations, no signal subscriptions, no property bindings) does
814
- **not** request JS.
815
-
816
- A single component can mix both. A product card with server-rendered
817
- title, price, image, plus a "View" link (no JS) and an "Add to cart"
818
- button with a `@click` (JS for that one behavior) is correct webjs
819
- style. The framework loads JS for the component because of the
820
- `@click` and runs it, while the rest of the card stays exactly as the
821
- server painted it.
822
-
823
- Practical consequences for agents writing webjs code.
824
-
825
- 1. Never reach for `fetch()` plus a `@click` handler when a `<form>`
826
- plus a server action would do. The form is free (no JS), the
827
- server action is typed and CSRF-protected, the result reaches the
828
- page through normal navigation.
829
- 2. Never make first paint depend on hydration. A blank skeleton until
830
- JS runs means the feature was written wrong.
831
- 3. Don't think binary about "static vs interactive components." Pick
832
- interactive primitives per behavior. A page with ten components
833
- can ship zero JS for eight of them and handlers only for the two
834
- that need it.
835
-
836
- ### Gotchas at a glance
837
-
838
- | Lit pattern | What breaks in webjs | Webjs equivalent |
839
- |---|---|---|
840
- | Fetch in `connectedCallback` / `firstUpdated` | Empty first paint (neither hook runs in SSR) | Fetch in the page function, pass as props |
841
- | `Task` for initial-paint data | SSR ships the pending state, flashes to resolved on hydration | Page function fetch, pass as props, OR an `async render()` in the component (`Task` is fine for client-time async) |
842
- | Expecting a sync `render()` only | webjs allows `async render() { const d = await getData(); ... }`; SSR bakes the data into the first paint | Use it for request-time server data; `renderFallback()` is the re-fetch loading UI (never first paint); error isolation is automatic |
843
- | Assuming an `async render()` always ships its module | A bare one (no other client signal) is ELIDED, so it costs zero JS and skips the on-hydration re-fetch, first paint unchanged | Rely on it for a fetch-and-display leaf. `static interactive = true` forces a ship the analyser would otherwise elide, `static shadow = true` always ships |
844
- | `window.X` / `document.X` in constructor or `render()` | SSR crash | Move to `connectedCallback` |
845
- | Top-level `import` of a browser-only library | SSR crash | Dynamic `import()` inside `connectedCallback` |
846
- | Class-field initializer for a reactive property (`student: Student = {...}`) | Silently breaks reactivity (overwrites the framework accessor) | Pass the shape to the base-class factory `WebComponent({ student: Object })` and set the default in the constructor (flagged by `reactive-props-no-class-field`) |
847
- | `@property()` decorator | Banned by invariant 10 (erasable TS) | Pass the shape to the base-class factory `WebComponent({ ... })` (the only supported form) |
848
- | Hand-written `static properties = { ... }` | Throws at construction (the factory owns property setup) | Pass the same shape to the base-class factory `WebComponent({ ... })` (flagged by `no-static-properties`) |
849
- | Array-typed prop declared with `Object` (`items: prop<Tag[]>(Object)`) | Works (Object and Array share one JSON converter), but misstates the prop's shape | Pass the `Array` constructor (`items: prop<Tag[]>(Array)`), flagged by `array-prop-uses-array-type` |
850
- | Extending raw `HTMLElement` directly | Bypasses SSR, reactive properties, elision, and lifecycle hooks; keeps the component from being elided | Always subclass `WebComponent` (or the factory form `WebComponent({...})`) |
851
- | Module-scope client work in a `page.ts` / `layout.ts` (a top-level call, a `window` / `document` / `customElements` access, a `@webjsdev/core/client-router` import), or importing a client-global-touching non-component util into one | The page/layout module stops being a droppable carrier and SHIPS its own JS to the browser (it shows up in the network tab); invisible in tests because it is an elision verdict, not a behaviour change | Keep pages/layouts pure carriers (their only browser job is registering the components they import; routing is automatic). Put client behaviour in a component, server-only code in `.server.{js,ts}`. Self-check: `page.ts` / `layout.ts` should not appear in the network tab |
852
- | Scoped `static styles = css` or an inline `<style>` with semantic class names (`.hero`, `.card`) in a light-DOM component | Scoped block does nothing without `static shadow = true`; inline `<style>` class names leak globally | Tailwind utilities (the light-DOM default); or `static shadow = true` for genuinely scoped CSS |
853
- | `willUpdate` computing SSR-visible derived state | Works (runs at SSR), but overriding it opts the component out of elision | Fine for interactive components; for display-only, derive inline in `render()` |
854
- | `this.hasAttribute` / `getAttribute` in `render()` | Works (server attribute shim backs the attribute methods at SSR) | Read attributes directly, or declare a reactive prop via the base-class factory `WebComponent({ ... })` |
855
- | `ContextProvider` for server-known data | Default value during SSR, content shift on hydration | Pass via props from the page function |
856
-
857
- The full annotated catalog with code examples lives in the framework
858
- repo at
859
- [`agent-docs/lit-muscle-memory-gotchas.md`](https://github.com/webjsdev/webjs/blob/main/agent-docs/lit-muscle-memory-gotchas.md).
860
-
861
- ### Styling: Tailwind-first (the most common lit reflex to unlearn)
862
-
863
- **Tailwind utilities are the strong default for pages AND light-DOM
864
- components (the default DOM mode).** Use them for layout, spacing, color
865
- (via the `@theme` tokens), typography, borders, radius, shadows, and
866
- interaction states (hover/focus/active/disabled, dark mode). Light DOM
867
- does not scope styles, so utilities apply directly.
868
-
869
- The lit habit is to scope CSS in a shadow root (`static styles =
870
- css\`\``) or write an inline `<style>` with semantic class names
871
- (`.hero`, `.card`). In a light-DOM webjs component the scoped block does
872
- nothing without `static shadow = true`, and the inline class names leak
873
- globally. Prefer Tailwind. When a utility bundle repeats, extract it into
874
- a `lib/utils/ui.ts` helper returning an `` html`...` `` fragment, not a
875
- CSS class.
876
-
877
- #### Design tokens: ONE theme, shadcn-canonical
878
-
879
- The app has a SINGLE theme, defined once in `app/layout.ts`. It uses the
880
- standard `@webjsdev/ui` (shadcn-compatible) semantic tokens, set to this app's
881
- brand palette. Use the canonical utility names everywhere, in the page chrome
882
- AND inside components: `bg-background`, `text-foreground`, `bg-card`, `bg-muted`,
883
- `text-muted-foreground`, `bg-primary`, `text-primary-foreground`, `bg-accent`,
884
- `text-accent-foreground`, `border-border`, `ring-ring`. These are exactly the
885
- tokens the components copied in by `webjs ui add <name>` read, so a scaffolded
886
- page and a later-added ui component share one coherent theme automatically.
887
-
888
- - **Never invent a parallel token vocabulary** (`--fg`, `--bg`, `text-fg`,
889
- `bg-elev`, a separate `--brand`). It collides with the ui tokens (the accent
890
- once flipped to neutral on navigation for exactly this reason) and diverges
891
- from the shadcn conventions the ui kit and AI agents both expect.
892
- - **Reach for opacity modifiers before a new token**: `bg-primary/10` for a
893
- tint, `hover:bg-primary/90` for a hover, `text-muted-foreground/70` for a
894
- subtler text level.
895
- - **Edit the palette in one place** (`app/layout.ts`). To ADD a token, do it
896
- the canonical way: a `--x` variable in the `:root` / `.dark` blocks plus a
897
- `--color-x: var(--x)` line in the `@theme inline` block, then use it as
898
- `bg-x` / `text-x`.
899
- - Dark mode is a `.dark` class the theme toggle sets. Tokens switch by theme
900
- automatically, so a component written with these names works in both.
901
-
902
- Reserve raw CSS for what utilities cannot express: design-token `:root` /
903
- `@theme` definitions, `@property` + `@keyframes` animations,
904
- `::-webkit-scrollbar`, `prefers-reduced-motion` blocks, and complex
905
- `color-mix()` / gradient effects. When custom CSS is unavoidable in a
906
- light-DOM component, prefix every class selector with the component tag
907
- (invariant below). Shadow-DOM components (`static shadow = true`)
908
- legitimately use `static styles = css\`\`` for scoped CSS.
909
-
910
- ## Server action pattern
911
-
912
- **The `.server.ts` vs `'use server'` decision, in one question.** Will the
913
- client call it? Add `'use server'` and the file becomes an RPC action
914
- (the browser import is rewritten to a typed stub). Is it server-only
915
- infra instead (a DB driver, secrets, `node:*`)? Use NO directive, and
916
- never import it into a page, layout, or component. Reach it from a
917
- `'use server'` action, a `route.ts` handler, or `middleware.ts`. A
918
- `.server.ts` file WITHOUT the directive is a server-only utility whose
919
- browser import throws at module load (invariant 2 below), so a
920
- page/component that imports it directly crashes on the client.
921
-
922
- ```ts
923
- // modules/posts/actions/create-post.server.ts
924
- 'use server';
925
- import { db } from '#db/connection.server.ts';
926
- import { posts } from '#db/schema.server.ts';
927
-
928
- export async function createPost(input: { title: string; body: string }) {
929
- if (!input.title) return { success: false, error: 'title required', status: 400 };
930
- const [post] = await db.insert(posts).values(input).returning();
931
- return { success: true, data: post };
932
- }
933
- ```
934
-
935
- Import it from a client component. The framework rewrites it into a
936
- type-safe RPC stub automatically.
937
-
938
- A server action is a POST by default, but reserved sibling exports change its
939
- HTTP semantics without changing the call site (`await getUser(7)` stays the
940
- same): `export const method = 'GET'` (a read; rides args in the URL, CSRF-exempt,
941
- cacheable), `export const cache = 60` + `export const tags` (GET response
942
- caching), `export const invalidates` (a mutation's tags to evict), `export const
943
- middleware` (a per-action chain, `actionContext()`), and `export const validate`
944
- (the boundary validator). One callable function per configured file. An action
945
- that RETURNS a `ReadableStream` / async generator streams its chunks (consume
946
- with `for await`); read the request `AbortSignal` via `actionSignal()` to cancel
947
- on disconnect. **SAFETY:** a `cache` with `public: true` shares one response
948
- across all users, so use it only for data identical for every visitor. Full
949
- reference: https://docs.webjs.dev/docs/server-actions
950
-
951
- ## Mutations: default to optimistic UI
952
-
953
- Default to optimistic UI for every feasible mutation. Use `optimistic()`
954
- from `@webjsdev/core` (create, toggle, like, follow, reorder, rename,
955
- status change) so the UI updates instantly and rolls back automatically
956
- on failure. The declarative form queues an update on a component with
957
- auto-release when the action promise settles, no hand-written try-catch,
958
- cache-and-restore, or temp-id bookkeeping.
959
-
960
- ```ts
961
- import { WebComponent, prop, optimistic, html } from '@webjsdev/core';
962
- import { createTodo } from '#modules/todos/actions/create-todo.server.ts';
963
-
964
- class TodoList extends WebComponent({ todos: prop<Todo[]>(Array) }) {
965
- private optimisticTodos = optimistic(this, {
966
- source: () => this.todos,
967
- update: (state, title: string) => [...state, { title, pending: true }],
968
- });
969
- async handleSubmit(title: string) {
970
- const promise = createTodo({ title });
971
- this.optimisticTodos.add(title, promise); // auto-releases on settle
972
- await promise;
973
- }
974
- render() {
975
- return html`<ul>${this.optimisticTodos.value.map(t => html`
976
- <li class=${t.pending ? 'opacity-50' : ''}>${t.title}</li>`)}</ul>`;
977
- }
978
- }
979
- ```
980
-
981
- Do NOT use optimistic UI where it hurts: unpredictable or server-computed
982
- results (AI output, server-assigned values the client cannot guess),
983
- side-effectful mutations the user must wait on (payment, email, OAuth),
984
- and destructive irreversible actions (a confirm-first UX is better). Full
985
- reference: https://docs.webjs.com/docs/optimistic-ui
986
-
987
- ## Client navigation patterns (auto-magic)
988
-
989
- The client router enables itself automatically: it turns on whenever
990
- `@webjsdev/core` loads in the browser, which happens on any page that
991
- ships a component, so there is no import to add. **Every `<a href>` and
992
- `<form action>` on the page is enhanced into a partial-swap navigation
993
- or submission automatically**. You don't call a router API. Write
994
- standard HTML; the swap happens.
995
-
996
- What this changes for how you write apps:
997
-
998
- ### 1. Put shared chrome in `layout.ts`, not in every page
999
-
1000
- When you navigate from `/posts` to `/posts/123`, the framework swaps
1001
- only the deepest layout's `${'${children}'}` slot. Outer layouts stay
1002
- mounted. The sidenav's scroll position, an open `<details>`, a focused
1003
- input, and an inflight `<video>` are all preserved across the navigation
1004
- without you writing any code.
1005
-
1006
- The rule: anything that should persist across navigations within a
1007
- section lives in that section's `layout.ts`. Page-specific content
1008
- lives in `page.ts`. Don't duplicate a sidenav into every page.
1009
-
1010
- ### 2. Forms POST through `<form action>` (no `fetch` for write-paths)
1011
-
1012
- A `<form action=${'${createPost}'} method="post">` works as a plain
1013
- HTML form when JS is disabled and as a partial-swap submission when JS
1014
- is active. **The same form covers both paths.** Don't reach for
1015
- `fetch` + a click handler unless you genuinely need to.
1016
-
1017
- ### 3. Server-side validation: re-render the form with errors
1018
-
1019
- The router applies any `text/html` response to the DOM regardless of
1020
- status code (4xx, 422, etc.). This is the Rails / Django / Phoenix
1021
- server-side validation pattern. Pair a `<form action="/posts" method="post">`
1022
- with a `route.ts` POST handler:
1023
-
1024
- ```ts
1025
- // app/posts/route.ts
1026
- import { redirect, html } from '@webjsdev/core';
1027
- import { createPost } from '#modules/posts/actions/create-post.server.ts';
1028
-
1029
- export async function POST(req: Request) {
1030
- const form = await req.formData();
1031
- const result = await createPost({
1032
- title: String(form.get('title') ?? ''),
1033
- body: String(form.get('body') ?? ''),
1034
- });
1035
- if (!result.success) {
1036
- // Re-render the form page with the user's input + inline errors.
1037
- // The client router applies this HTML in place, no full reload.
1038
- return new Response(renderNewPostForm(result.errors, form), {
1039
- status: 422,
1040
- headers: { 'content-type': 'text/html; charset=utf-8' },
1041
- });
1042
- }
1043
- // Success → PRG redirect; fetch follows, history records /posts/<id>
1044
- redirect(`/posts/${result.data.id}`);
1045
- }
1046
- ```
1047
-
1048
- ```html
1049
- <!-- The form: standard HTML, no JS handler needed -->
1050
- <form action="/posts" method="post">
1051
- <input name="title" required />
1052
- <textarea name="body" required></textarea>
1053
- <button>Publish</button>
1054
- </form>
1055
- ```
1056
-
1057
- With JS active: router intercepts the submit, sends the POST, applies
1058
- the response in place (2xx + redirect for success, 4xx HTML for
1059
- errors). With JS disabled: browser performs the same POST as a normal
1060
- form submission and renders the response page. Same code, both paths.
1061
-
1062
- (For RPC-style server actions that return typed values to client
1063
- components. See *Server action pattern* above. The HTML-form pattern
1064
- here is for the "submit → server processes → render new page" flow.)
1065
-
1066
- ### 4. `<webjs-frame id="...">` for non-layout swap regions
1067
-
1068
- `<webjs-frame>` is webjs's take on **Turbo Frames** (Hotwire Turbo), so
1069
- `<turbo-frame>` muscle memory transfers directly: a lazy, URL-addressable
1070
- region that swaps on its own, driven by a link/form targeting its id. Use it
1071
- for a region that loads or refreshes INDEPENDENTLY of a full navigation
1072
- (a self-refreshing widget, a `loading="lazy"` below-the-fold region, a
1073
- URL-addressable panel); it ships zero component JS. Its route can itself use
1074
- `<webjs-suspense>` so a lazy frame's slow data streams in behind a fallback.
1075
-
1076
- For a widget that should swap on click but isn't a route boundary
1077
- (e.g. a tab strip inside a page), wrap it:
1078
-
1079
- ```ts
1080
- return html`
1081
- <nav>
1082
- <a href=${'${path + "?tab=overview"}'}>Overview</a>
1083
- <a href=${'${path + "?tab=stats"}'}>Stats</a>
1084
- </nav>
1085
- <webjs-frame id="tab-content">
1086
- ${'${tab === "stats" ? renderStats() : renderOverview()}'}
1087
- </webjs-frame>
1088
- `;
1089
- ```
1090
-
1091
- The router's `closest('webjs-frame')` detection takes precedence over
1092
- layout markers. Only the frame's content swaps. Use this sparingly,
1093
- folder-based layouts handle 99% of cases.
1094
-
1095
- **External targeting + `_top` (Turbo-style).** A trigger does not have to be
1096
- nested in the frame it drives. An `<a>` or `<form>` (or any ancestor)
1097
- carrying `data-webjs-frame="<id>"` drives the frame with that id from
1098
- anywhere (an external sidebar/nav link, a filter form), resolved via
1099
- `getElementById`. The reserved token `data-webjs-frame="_top"` on a trigger
1100
- INSIDE a frame breaks OUT to a full-page navigation. An id that does not
1101
- resolve to a live `<webjs-frame>` warns once and falls back to a normal nav
1102
- (never throws). With JS disabled a `data-webjs-frame` link is an inert
1103
- attribute on a plain `<a href>`, so the click is a normal full navigation.
1104
-
1105
- **Busy state.** While a frame nav is in flight the router sets the native
1106
- `aria-busy="true"` on the frame (cleared to `"false"` on any exit: success,
1107
- error, abort, or a missing frame), so AT announces it and CSS can style
1108
- `webjs-frame[aria-busy="true"]`. It also dispatches a bubbling
1109
- `webjs:frame-busy` event on the frame at start and finish (detail
1110
- `{ frameId, busy }`).
1111
-
1112
- **Self-loading (`src` + `loading`).** A frame can fetch its OWN content:
1113
- `<webjs-frame id="comments" src="/posts/42/comments" loading="lazy">` self-fetches
1114
- that URL as a frame nav and applies the matching `<webjs-frame id>` subtree into
1115
- itself, through the same frame-swap path (so the busy lifecycle + navigation-error
1116
- recovery + frame-missing fallback all apply). `loading="eager"` (or absent)
1117
- fetches on connect; `loading="lazy"` fetches on viewport entry. The request sends
1118
- the `x-webjs-frame` header, so the SERVER returns ONLY the matched subtree (not
1119
- the full page), falling back to the full page when the frame is absent. A `src` is
1120
- JS-DEPENDENT (the browser does not natively fetch a `<webjs-frame src>`), so with
1121
- JS off the frame shows only the children rendered into it; use it for DEFERRED
1122
- content (comments, a recommendations rail) where a no-JS placeholder is fine, and
1123
- render content server-side into the frame when it must exist without JS.
1124
-
1125
- **View Transitions + persistent elements (opt-in).** Add
1126
- `<meta name="view-transition" content="same-origin">` to the page head and the
1127
- router wraps every swap (the layout-marker swap, the `<webjs-frame>` swap, and
1128
- the full-body fallback) in `document.startViewTransition` for an animated
1129
- crossfade. OFF by default (no animation surprise); a browser without the API
1130
- falls back to the identical synchronous swap. To keep a live element running
1131
- across a navigation (a playing `<audio>` / `<video>`, a map, a stateful
1132
- widget), mark it `data-webjs-permanent` AND give it an `id`: the router keeps
1133
- the SAME DOM node by identity across the swap instead of recreating it (Turbo's
1134
- permanent-element behaviour). Inert with JS off.
1135
-
1136
- When a frame nav's response lacks the matching `<webjs-frame id>` (e.g. an
1137
- auth redirect), the router fires a cancelable, bubbling `webjs:frame-missing`
1138
- event (detail `{ frameId, url, document }`) and leaves the frame unchanged
1139
- rather than silently swapping the whole page; call `preventDefault()` to take
1140
- over the outcome (e.g. `location.assign(e.detail.url)`).
1141
-
1142
- ### 5. Stream actions for surgical element-level updates
1143
-
1144
- `<webjs-stream>` is webjs's take on **Turbo Streams** (Hotwire Turbo); the
1145
- action set (`append` / `prepend` / `before` / `after` / `replace` / `update` /
1146
- `remove`) mirrors `<turbo-stream>`, so that muscle memory transfers directly.
1147
- It is the ONLY surgical single-element update primitive AND the live-channel
1148
- applier (`connectWS` / `broadcast` -> `renderStream`); a region swap or a
1149
- `<webjs-frame>` reload redraws a whole region, so reach for `<webjs-stream>`
1150
- when only one element changes.
1151
-
1152
- When a region swap is too coarse (append ONE comment, remove ONE row, bump a
1153
- count, insert a toast), a server response can declare per-element actions as
1154
- plain HTML, a `<webjs-stream action target>` wrapping one `<template>`:
1155
-
1156
- ```html
1157
- <webjs-stream action="append" target="comments">
1158
- <template><li>Nice post!</li></template>
1159
- </webjs-stream>
1160
- ```
1161
-
1162
- Actions (Turbo's set): `append` / `prepend` (last / first child of the target
1163
- id), `before` / `after` (sibling), `replace` (the target element), `update`
1164
- (its children), `remove` (delete it). The `<webjs-stream>` element self-applies
1165
- on connect and removes itself. ONE applier serves two paths:
1166
-
1167
- - **A content-negotiated `<form>`.** The router adds `Accept:
1168
- text/vnd.webjs-stream.html` on a JS-driven submission, so the server returns a
1169
- stream only then (apply it surgically) and a JS-OFF form gets a normal
1170
- render/redirect. Additive and progressive-enhancement-safe.
1171
- - **A live channel.** `renderStream(message)` from a `connectWS` handler applies
1172
- a `broadcast()`ed payload, so chat / notifications reuse the same applier.
1173
-
1174
- Build the payload server-side and apply it client-side:
1175
-
1176
- ```ts
1177
- // app/posts/[id]/route.ts
1178
- import { stream, streamResponse, acceptsStream, broadcast } from '@webjsdev/server';
1179
- export async function POST(req: Request, { params }) {
1180
- const c = await addComment(params.id, await req.formData());
1181
- const html = stream.append('comments', `<li>${escapeHtml(c.text)}</li>`);
1182
- broadcast(`post:${params.id}`, html); // fan out to other viewers
1183
- if (acceptsStream(req)) return streamResponse(html); // JS client: surgical
1184
- return Response.redirect(`/posts/${params.id}`, 303); // no-JS: normal render
1185
- }
1186
- ```
1187
-
1188
- ```ts
1189
- // a component, for the live channel
1190
- import { connectWS, renderStream } from '@webjsdev/core';
1191
- connectWS(`/posts/${id}/feed`, { onMessage: (m) => renderStream(m) });
1192
- ```
1193
-
1194
- `stream.*` escapes the target id but NOT the content (server-authored HTML, like
1195
- an `html` hole, so escape any user substring yourself). `renderStream` is
1196
- auto-registered by the client router.
1197
-
1198
- **Failed navigations recover in place, never a destructive full reload.** A
1199
- successful swap and an HTML error body of any status (e.g. a `422` re-rendered
1200
- form) both apply in place. For the remaining failure cases (a non-HTML error
1201
- response like a `500` with a JSON body, or a transport/parse failure) the
1202
- router fires a cancelable, bubbling `webjs:navigation-error` event on
1203
- `document` (detail `{ url, status, error }`, where `status` is the HTTP status
1204
- or `null`, and `error` is the `Error` or `null`). `preventDefault()` hands
1205
- recovery to you and leaves the page exactly as it is (shell, scroll, focus,
1206
- client state preserved); otherwise the router renders a minimal in-place
1207
- `<div role="alert">` into the deepest layout children slot (outer chrome
1208
- preserved), only hard-loading as a last resort when there is no shared layout
1209
- marker. An AbortError (a superseding nav) is a normal supersede and never fires
1210
- the event.
1211
-
1212
- ### 5. `loading.ts` for per-segment skeletons
1213
-
1214
- Drop a `loading.ts` in any route segment. The framework auto-wraps the
1215
- sibling `page.ts` in a Suspense boundary with `loading.ts`'s default
1216
- export as the fallback. On navigation, the client router clones the
1217
- deepest matching loading template into the swap slot immediately -
1218
- the user sees a skeleton during the fetch, then the real content.
1219
-
1220
- ### 6. `error.ts` for per-segment error boundaries
1221
-
1222
- Drop an `error.ts` in any route segment. Render-time exceptions in
1223
- that segment's tree are caught and rendered through `error.ts`'s
1224
- default export, scoped to that boundary (outer layouts stay alive).
1225
-
1226
- ### What you do NOT need to write
1227
-
1228
- - Manual fetch / DOM-swap code for SPA-style navigation
1229
- - An "active link" highlight handler. Use `aria-current="page"`
1230
- derived from the request URL on the server.
1231
- - Loading spinners on `<a>` clicks. `loading.ts` handles it.
1232
- - Cancellation when the user clicks faster than the network. The
1233
- router's nav-token + AbortController combo guarantees stale
1234
- responses never overwrite a newer settled page.
1235
- - Scroll-position save/restore for back/forward. The snapshot cache
1236
- handles window scroll. Inner scrollables persist via DOM identity.
1237
-
1238
- Full reference: see the [Client Router docs](https://docs.webjs.dev/docs/client-router) and the framework AGENTS.md "Client navigation" section.
1239
-
1240
- ## Offline support (opt-in service worker)
1241
-
1242
- The UI scaffolds (full-stack and saas) ship a progressive-enhancement service
1243
- worker at `public/sw.js` plus a `public/offline.html` fallback (the api template
1244
- has no UI, so it omits them). They are **dormant until you register them**, so
1245
- the JS-disabled baseline is unchanged. To enable offline support, add the opt-in
1246
- registration snippet to the root layout `<head>`:
1247
-
1248
- ```html
1249
- <script>
1250
- if ('serviceWorker' in navigator) {
1251
- addEventListener('load', () => {
1252
- const tag = document.querySelector('script[type="importmap"]');
1253
- const build = (tag && tag.dataset.webjsBuild) || '';
1254
- navigator.serviceWorker.register('/sw.js' + (build ? '?v=' + build : ''));
1255
- });
1256
- }
1257
- </script>
1258
- ```
1259
-
1260
- Navigations become network-first (fresh server HTML, with an offline fallback to
1261
- a cached page or `/offline.html`); same-origin assets are stale-while-revalidate.
1262
- The cache version ties to the deploy via the `?v=<build>` id, so a new deploy
1263
- evicts the old cache automatically. `sw.js` is YOUR file, so edit the strategy as
1264
- needed. Full reference: `agent-docs/service-worker.md`.
1265
-
1266
- ## Metadata (per-page)
1267
-
1268
- The `metadata` export is Next.js-compatible. Common fields shown below;
1269
- the full surface includes `title.template / .default / .absolute`,
1270
- `metadataBase`, `alternates: { canonical, languages, media, types }`,
1271
- `robots`, `keywords`, `authors`, `creator`, `publisher`, `verification`,
1272
- `icons`, `manifest`, `appleWebApp`, `formatDetection`, `itunes`, and
1273
- the typed `other: { '<meta-name>': value }` escape hatch.
1274
-
1275
- ```ts
1276
- export const metadata = {
1277
- title: 'My page',
1278
- // OR: title: { template: '%s | {{APP_NAME}}', default: '{{APP_NAME}}' }
1279
- description: 'A page in {{APP_NAME}}',
1280
- metadataBase: 'https://example.com', // base for relative URLs below
1281
- openGraph: { type: 'website', image: '/og.png' },
1282
- twitter: { card: 'summary_large_image' },
1283
- icons: { icon: '/favicon.svg', apple: '/apple.png' },
1284
- alternates: { canonical: '/post' }, // → <link rel="canonical">
1285
- robots: { index: true, follow: true },
1286
- cacheControl: 'public, max-age=60', // opt into caching (default: no-store)
1287
- };
1288
- ```
1289
-
1290
- Use `generateMetadata(ctx)` when you need request-scoped values (e.g.
1291
- absolute URLs from `ctx.url`):
1292
-
1293
- ```ts
1294
- export function generateMetadata(ctx: { url: string }) {
1295
- return { metadataBase: new URL(ctx.url).origin, title: 'Hello' };
1296
- }
1297
- ```
1298
-
1299
- Viewport may be split into its own export (Next.js 14+ pattern):
1300
-
1301
- ```ts
1302
- export const viewport = {
1303
- width: 'device-width',
1304
- initialScale: 1,
1305
- themeColor: '#1c1613',
1306
- colorScheme: 'light dark',
1307
- };
1308
- ```
1309
-
1310
- ## Document shell (`<html>` / `<head>` / `<body>`)
1311
-
1312
- The framework owns the shell by default. The SSR pipeline auto-emits
1313
- `<!doctype html><html lang="en"><head>…</head><body>` around every
1314
- composition, and auto-hoists `<link>` / `<style>` / `<meta>` / `<script>`
1315
- tags returned anywhere in a layout/page into the real `<head>`. The
1316
- `metadata` export drives `<title>` and `<meta>` tags.
1317
-
1318
- **Only `app/layout.ts` (the root layout)** may optionally write its
1319
- own `<!doctype><html><head>…</head><body>` shell to override `<html lang>`,
1320
- `<html dir>`, `<html data-*>`, `<body class>`, or add a custom
1321
- `<link rel="preconnect">` etc. When the root layout supplies a shell,
1322
- the framework respects it and splices its required tags into the
1323
- user's `<head>`.
1324
-
1325
- ```ts
1326
- // app/layout.ts (root, optionally owning the shell)
1327
- export default function RootLayout({ children }) {
1328
- return html`
1329
- <!doctype html>
1330
- <html lang="es" data-theme="dark">
1331
- <head>
1332
- <link rel="preconnect" href="https://cdn.example.com">
1333
- </head>
1334
- <body class="min-h-screen bg-bg">
1335
- <main>${children}</main>
1336
- </body>
1337
- </html>
1338
- `;
1339
- }
1340
- ```
1341
-
1342
- **Non-root layouts** (`app/<segment>/layout.ts`) and **pages**
1343
- (`app/**/page.ts`) **must NOT** write `<!doctype>` / `<html>` / `<head>`
1344
- / `<body>`. The framework auto-emits the wrapper around the whole
1345
- composition, so a nested shell ends up dropped by the HTML parser.
1346
- `webjs check` enforces this via the `shell-in-non-root-layout` rule.
1347
-
1348
- ## Invariants (do not violate)
1349
-
1350
- 1. Custom element tags must contain a hyphen. Pass the tag to `.register('tag-name')` at the bottom of the file. The tag is not a static field.
1351
- 2. **Server-only code goes in `.server.{js,ts}` files, `route.ts`
1352
- handlers, or `middleware.ts`. Never in pages, layouts, or
1353
- components.** Direct imports of a DB driver (`pg`),
1354
- `node:*`, or any server-only dependency from a page, layout, loading.ts,
1355
- error.ts, not-found.ts, or component will crash the browser at module load.
1356
- Wrap the access in a `.server.{js,ts}` file; the framework
1357
- rewrites that import into an RPC stub for the browser. Server-only
1358
- infra lives in `db/*.server.ts` (the DB) and `lib/*.server.ts`
1359
- (`lib/session.server.ts`); browser-safe utilities live in
1360
- `lib/utils/cn.ts` with `cn`, design-
1361
- system helpers). Server-only `lib/*` files must only be imported
1362
- from `.server.ts`/`route.ts`/`middleware.ts`; browser-safe `lib/*`
1363
- files (like `lib/utils/cn.ts`) can be imported anywhere. A TYPE-ONLY
1364
- import is the exception: `import type { Todo } from
1365
- '#db/schema.server.ts'` is fine in a page or component, because the
1366
- TypeScript stripper erases it before it reaches the browser, so
1367
- sharing a derived row type is safe and is not flagged.
1368
- 3. Event / property / boolean holes in `` html`` `` are unquoted:
1369
- `@click=${fn}`, not `@click="${fn}"`.
1370
- 4. Component state lives in signals. Import `signal` from
1371
- `@webjsdev/core`, read with `signal.get()` inside `render()`, and
1372
- write with `signal.set(value)`. Module-scope signals share state
1373
- across components; instance signals (created in the constructor)
1374
- carry component-local state. Reactive properties (`static
1375
- properties = { ... }` with a sibling `declare`) are for values
1376
- that ride an HTML attribute or `.prop=${...}` SSR hydration.
1377
- 5. Pages / layouts / metadata routes default-export a server-only function.
1378
- 6. One exported function per action / query file. Name the file after it.
1379
- 7. **Components must render meaningful HTML on first paint** (SSR
1380
- uses constructor defaults + attributes, while `connectedCallback` is
1381
- browser-only). Never fetch initial data in `connectedCallback` /
1382
- `firstUpdated`. Fetch in the page function (server) and pass it as
1383
- a prop. See *Component pattern* above.
1384
- 8. **Erasable TypeScript only.** The runtime strips types at the runtime
1385
- layer (Node 24+'s built-in `module.stripTypeScriptTypes`, or `amaro`
1386
- on Bun, which is byte-identical), with whitespace replacement so
1387
- line and column positions are byte-exact and no sourcemap ships to
1388
- the browser. Your `tsconfig.json` sets `erasableSyntaxOnly: true`, so
1389
- the TS compiler rejects: `enum`, `namespace` with values,
1390
- constructor parameter properties, legacy decorators with
1391
- `emitDecoratorMetadata`, and `import = require`. Use the erasable
1392
- equivalents:
1393
-
1394
- ```ts
1395
- // ❌ enum
1396
- enum Color { Red, Green, Blue }
1397
-
1398
- // ✅ const object + union type
1399
- const Color = { Red: 'Red', Green: 'Green', Blue: 'Blue' } as const;
1400
- type Color = typeof Color[keyof typeof Color];
1401
-
1402
- // ❌ parameter property
1403
- class Foo { constructor(public x: number) {} }
1404
-
1405
- // ✅ explicit field + assignment
1406
- class Foo {
1407
- x: number;
1408
- constructor(x: number) { this.x = x; }
1409
- }
1410
- ```
1411
-
1412
- If you turn `erasableSyntaxOnly` off and use non-erasable syntax,
1413
- the dev server fails at strip time and returns a 500 naming the
1414
- file and pointing at the `no-non-erasable-typescript` lint rule.
1415
- WebJs is buildless end-to-end and has no bundler fallback. The
1416
- `erasable-typescript-only` convention check warns when the flag
1417
- is missing or set to false.
1418
- 9. **No em-dashes (U+2014) anywhere, and no hyphen or semicolon used
1419
- as a pause-punctuation substitute.** Prose, comments, code, JSON
1420
- descriptions, commit messages. Rewrite the sentence so no
1421
- pause-punctuation crutch is needed. Banned as pause punctuation:
1422
- the em-dash (`-`), a plain hyphen used in place of one (` - `), and
1423
- a semicolon used in place of one (` ; `). Use a period, comma,
1424
- colon, parentheses, or a restructured phrasing. Plain hyphens stay
1425
- fine in compound words (`AI-first`), CLI flags (`--http2`),
1426
- filenames, and ranges. Semicolons stay fine inside code.
1427
-
1428
- ## Workflow expectations for AI agents
1429
-
1430
- 1. Branch before editing. Never push to `main` directly. **If more than one
1431
- agent may work this repo at once, give each task its own git worktree, not a
1432
- shared checkout** (`git worktree add -b <branch> ../<repo>-<slug> origin/main`,
1433
- `cd` in, work there, `git worktree remove` after merge). Two agents in one
1434
- working directory collide: a `git checkout` in one moves `HEAD` under the
1435
- other, so the next commit lands on the wrong branch. Git enforces
1436
- one-branch-per-worktree, so worktrees prevent it; a lone agent in a clean
1437
- checkout may use a plain branch.
1438
- 2. Every code change comes with a test, AGENTS.md / docs updates if the
1439
- feature surface changed, `webjs check` passing. A unit test is not
1440
- always enough: a component, hydration, the client router, or a server
1441
- action called from the client needs a browser test
1442
- (`webjs test --browser`) asserting the behaviour in a real browser. For
1443
- Claude Code, a commit that stages app code (`app/`, `modules/`,
1444
- `components/`, `lib/`) with no test WARNS via
1445
- `.claude/hooks/require-tests-with-src.sh` (every change should still ship
1446
- with a test, but that is a convention, not a hard gate). A project that
1447
- wants the strict floor opts into a hard block by setting
1448
- `WEBJS_TEST_GATE=block` (in `.claude/settings.json` env, your shell, or
1449
- CI). The real enforcement is CI: the test suite runs in
1450
- `.github/workflows/ci.yml`, not in the pre-commit hook, so `git commit`
1451
- stays fast and the gate cannot be skipped with a local `--no-verify`.
1452
- 3. Commit and push **per logical unit**, not at the end. A logical unit is one
1453
- feature, one fix, one rename, one doc rewrite. If you have 5+ unstaged files
1454
- spanning different concerns, commit the current group before continuing.
1455
- For Claude Code, its `CLAUDE.md` explicitly OVERRIDES Claude Code's built-in
1456
- never-commit default, so it commits per unit without waiting to be asked. The
1457
- framework also ships a `nudge-uncommitted` hook for several agents that fires
1458
- at threshold 4:
1459
-
1460
- | Agent | Hook path | Doc |
1461
- |---|---|---|
1462
- | Claude Code | `.claude/hooks/nudge-uncommitted.sh` (`PostToolUse`) | `.claude/settings.json` |
1463
- | Gemini CLI | `.gemini/hooks/nudge-uncommitted.sh` (`AfterTool`) | `.gemini/settings.json` |
1464
- | Cursor 1.7+ | `.cursor/hooks/nudge-uncommitted.sh` (`afterFileEdit`) | `.cursor/hooks.json` |
1465
- | OpenCode | `.opencode/plugins/nudge-uncommitted.ts` (`tool.execute.after`) | `.opencode/plugins/` |
1466
- | Antigravity (Google) | text rule only (post-write hooks not yet exposed) | `.agents/rules/workflow.md` |
1467
- | GitHub Copilot | text rule only (no hooks API) | `.github/copilot-instructions.md` |
1468
-
1469
- Claude Code adds two more backstops of its own. A `commit-before-stop.sh`
1470
- Stop hook refuses to end a turn with a pile of uncommitted work on a feature
1471
- branch (loop-safe, disable with `WEBJS_NO_COMMIT_STOP=1`), and a
1472
- `cleanup-merged-worktree.sh` PostToolUse hook removes a merged branch's
1473
- worktree after a `gh pr merge`.
1474
-
1475
- The `.hooks/pre-commit` hook blocks commits to main and nothing else;
1476
- `webjs test` + `webjs check` run in CI (`.github/workflows/ci.yml`) on
1477
- every PR and push to main, regardless of which agent (or human) made
1478
- the commit. No AI attribution trailers in commit messages.
1479
- 4. Run the **pre-merge self-review loop** before signaling the PR is
1480
- ready. After committing the work, trigger a fresh-context review
1481
- pass (a new chat / composer tab / subagent / Cascade thread
1482
- depending on your tool) and iterate fix-then-review rounds until
1483
- one round finds zero issues. Minimum two rounds; rotate focus each
1484
- round so the reviewer does not rediscover the same surface twice.
1485
- Skip the loop only for one-line trivial changes; skipping on a
1486
- change that touches logic, public surface, build, security, or
1487
- multiple files is the exact failure mode the loop exists to
1488
- prevent. The full rule, prompt template, and reporting contract
1489
- live in the **Pre-merge self-review loop** section of
1490
- `CONVENTIONS.md`.
1491
- 5. When unsure how a framework feature works, `grep` or `cat` the
1492
- relevant `node_modules/@webjsdev/*/src/` file before asking the user.
1493
-
1494
- Project conventions live in [CONVENTIONS.md](./CONVENTIONS.md) (guidance
1495
- you follow by judgment). `webjs check` is separate: correctness checks
1496
- only, always on, no per-project disabling.
3
+ This is a WebJs app: AI-first, web-components-first, no build step. Read this
4
+ before editing any file. It is deliberately short. The framework knowledge
5
+ lives in one place that every AI tool can read.
6
+
7
+ ## Building features
8
+
9
+ Read `.agents/skills/webjs/SKILL.md` first. It is the guide to building a WebJs
10
+ app: it helps you choose the right layer, reach for the right export, and avoid
11
+ the WebJs-specific mistakes that Next.js or Lit habits cause. It routes to
12
+ focused references under `.agents/skills/webjs/references/` that you load only
13
+ when a task needs them. The full hosted docs are at https://docs.webjs.dev.
14
+
15
+ ## Grow this app in place
16
+
17
+ This scaffold is a starting point. It ships a gallery index home
18
+ (`app/page.ts`), a root layout with a neutral design-token palette
19
+ (`app/layout.ts`), a database wired up (`db/`), and a densely-commented feature
20
+ gallery: single-concept demos under `app/features/` plus the `app/examples/todo`
21
+ app, with logic in `modules/`. The gallery is reference to learn the idioms
22
+ from, not part of your product.
23
+
24
+ **Building a real app? Learn from the gallery FIRST, then clear it, then build.**
25
+ The order matters:
26
+
27
+ 1. **Gather context.** Skim the demos relevant to your task under
28
+ `app/features/<x>` (and `app/examples/todo`) for the runnable idiom. You do
29
+ not have to read all of it, and you never lose it: the skill at
30
+ `.agents/skills/webjs/` teaches the same patterns and SURVIVES the clear, so
31
+ clearing is not a knowledge-loss event, the gallery is just a runnable bonus.
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.
36
+ 3. **Build.** Regenerate the database (`npm run db:generate` then `npm run
37
+ db:migrate`), then grow the app in place: routes under `app/`, components
38
+ 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`.
41
+
42
+ If you are only exploring, keep the gallery and browse it.
43
+
44
+ ## Commands
45
+
46
+ ```sh
47
+ npm install
48
+ npm run gallery:clear # shed the demo gallery before building a real app
49
+ npm run dev # dev server at http://localhost:8080
50
+ npm run start # production server
51
+ npm test # unit + browser tests
52
+ npm run typecheck
53
+ npx webjsdev check # correctness checks
54
+ npx webjsdev ui add <name> # add a ui-* component on demand
55
+ ```
56
+
57
+ ## Data
58
+
59
+ Use the wired-up database (Drizzle). Define real models in
60
+ `db/schema.server.ts`, then `npm run db:generate` and `npm run db:migrate`.
61
+ Never store app data in JSON files, in-memory arrays, or localStorage.