@webjsdev/cli 0.8.1

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.
@@ -0,0 +1,816 @@
1
+ # AGENTS.md for {{APP_NAME}}
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/vivek7405/webjs/blob/main/AGENTS.md and the
6
+ full hosted documentation (every API, recipe, and example) lives at
7
+ **https://docs.webjs.com**. Treat this file as the app-scoped
8
+ companion and reach for docs.webjs.com 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 `prisma/schema.prisma`, 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.
18
+
19
+ **Non-negotiables for every webjs app:**
20
+
21
+ 1. **Use Prisma + SQLite for persistence.** It's already wired up
22
+ (`prisma/schema.prisma`, `lib/prisma.server.ts`, `npm run db:migrate`,
23
+ `predev` hook running `prisma generate`). For any data the app
24
+ stores (todos, posts, messages, products, comments, anything),
25
+ define a Prisma model and persist there.
26
+ - **NEVER** store app data in JSON files (`data/todos.json`,
27
+ `db.json`, …). The convention check `no-json-data-files` flags
28
+ this and the user's prompt explicitly forbids it.
29
+ - **NEVER** use in-memory arrays or `Map`s as a substitute for the
30
+ database. They vanish on every dev-server reload and aren't
31
+ shared across processes.
32
+ - **NEVER** use `localStorage` to persist app data. It's per-browser
33
+ and doesn't reach the server.
34
+ 2. **One of three scaffolds only.** The CLI exposes exactly three:
35
+ `full-stack` (default), `--template api`, `--template saas`. Don't
36
+ reach for a `--template blog` / `--template todo` / `--template
37
+ ecommerce`. They don't exist and the CLI will reject them.
38
+ 3. **First step after scaffolding:** edit `prisma/schema.prisma` to the
39
+ app's real domain models (delete the example `User` model unless the
40
+ app actually needs users), run `webjs db migrate <name>`, then build
41
+ pages / actions / queries against those models.
42
+
43
+ **Picking the right scaffold from the user's prompt** (you do this BEFORE
44
+ running `webjs create`; if you're reading this you've already scaffolded.
45
+ Verify the choice was correct, otherwise re-scaffold in a fresh dir):
46
+
47
+ | User asks for… | Scaffold |
48
+ |---|---|
49
+ | Todo app, blog, notes, dashboard, marketplace, social feed, e-commerce, any product with a UI | `webjs create <name>` (default full-stack) |
50
+ | HTTP/JSON API only, no UI | `webjs create <name> --template api` |
51
+ | Anything with login / signup / accounts / protected pages / SaaS | `webjs create <name> --template saas` |
52
+
53
+ When in doubt, **full-stack is the default**. Pick `api` only if the user
54
+ is explicit about wanting a backend-only API. Pick `saas` only if the user
55
+ is explicit about auth / accounts / SaaS.
56
+
57
+ ## Framework source is in `node_modules/`
58
+
59
+ No build step, no bundler, no minification. What you read is what
60
+ runs. When in doubt, grep the framework:
61
+
62
+ ```
63
+ node_modules/@webjsdev/
64
+ core/ renderer, WebComponent, directives, client router,
65
+ Task, context, testing helpers
66
+ src/component.js ← lifecycle, properties, light vs shadow DOM
67
+ src/render-client.js ← client-side DOM patching + hydration
68
+ src/render-server.js ← renderToString / renderToStream
69
+ src/router-client.js ← Turbo-Drive-style client navigation
70
+ src/directives.js ← unsafeHTML, live
71
+ src/context.js ← Context Protocol
72
+ src/task.js ← async data with states
73
+ server/ dev + prod server, SSR, file router, actions,
74
+ auth, sessions, cache, rate-limit, WebSocket
75
+ src/ssr.js ← how metadata becomes <head> tags
76
+ src/router.js ← file convention → route table
77
+ src/actions.js ← .server.ts scanner, RPC, expose()
78
+ src/auth.js, session.js, cache.js, rate-limit.js, csrf.js
79
+ cli/ webjs CLI (dev / start / build / test / check / create / db)
80
+ ts-plugin/ tsserver plugin: go-to-definition + diagnostic suppression
81
+ + attribute auto-complete for Class.register('tag') elements
82
+ ```
83
+
84
+ Reaching straight for the source is the fastest way to resolve "why
85
+ doesn't X work?" with no documentation guesswork and no stale blog posts.
86
+
87
+ ## Editor TS plugin: `@webjsdev/ts-plugin`
88
+
89
+ This scaffold's `tsconfig.json` lists a single tsserver plugin. It is
90
+ editor-only, not required for the framework to run.
91
+
92
+ ```jsonc
93
+ // tsconfig.json (already wired by the scaffold)
94
+ "plugins": [
95
+ { "name": "@webjsdev/ts-plugin" }
96
+ ]
97
+ ```
98
+
99
+ `@webjsdev/ts-plugin` bundles `ts-lit-plugin` internally (it's a runtime
100
+ dependency of the plugin) and loads it programmatically, so users
101
+ list one entry, not two. You get the full stack of template-literal
102
+ intelligence (type-checking, diagnostics, go-to-def inside
103
+ `` html`…` `` and `` css`…` `` templates) **plus** webjs-aware behaviour
104
+ layered on top:
105
+
106
+ - "Unknown tag/attribute" diagnostics are silenced for elements
107
+ registered via `Class.register('tag-name')`.
108
+ - Attribute auto-complete sourced from each component's
109
+ `static properties`.
110
+ - Attribute-value type-check against `declare propName: T` annotations.
111
+
112
+ See [docs.webjs.com → Editor setup](https://docs.webjs.com/docs/editor-setup)
113
+ for the full walkthrough.
114
+
115
+ ## UI components: Webjs UI (preinstalled)
116
+
117
+ This scaffold ships with the standard Webjs UI component kit
118
+ **already installed at `components/ui/`**. The kit is **AI-first** and
119
+ splits into two tiers. Internalise the split. Picking the wrong tier
120
+ produces broken markup.
121
+
122
+ ### Tier 1: class-helper functions (the majority)
123
+
124
+ Pure functions that return Tailwind class strings. You apply them to
125
+ **raw native HTML elements** that you write yourself. Examples:
126
+ `button`, `card`, `input`, `label`, `alert`, `badge`, `separator`,
127
+ `skeleton`, `kbd`, `table`, `breadcrumb`, `pagination`, `native-select`,
128
+ `avatar`, `checkbox`, `switch`, `radio-group`, `textarea`, `toggle`,
129
+ `aspect-ratio`.
130
+
131
+ ```ts
132
+ import {
133
+ cardClass, cardHeaderClass, cardTitleClass,
134
+ cardContentClass, cardFooterClass,
135
+ } from '../../components/ui/card.ts';
136
+ import { inputClass } from '../../components/ui/input.ts';
137
+ import { labelClass } from '../../components/ui/label.ts';
138
+ import { buttonClass } from '../../components/ui/button.ts';
139
+
140
+ return html`
141
+ <div class=${cardClass()}>
142
+ <div class=${cardHeaderClass()}>
143
+ <h3 class=${cardTitleClass()}>Profile</h3>
144
+ </div>
145
+ <div class=${cardContentClass()}>
146
+ <label class=${labelClass()} for="name">Name</label>
147
+ <input class=${inputClass()} id="name" name="name">
148
+ </div>
149
+ <div class=${cardFooterClass()}>
150
+ <button class=${buttonClass()}>Save</button>
151
+ </div>
152
+ </div>
153
+ `;
154
+ ```
155
+
156
+ Helpers with variants take an options object:
157
+ `buttonClass({ variant: 'outline', size: 'sm' })`.
158
+
159
+ ### Tier 2: stateful custom elements
160
+
161
+ For things the browser doesn't provide natively (focus traps, portaled
162
+ overlays, keyboard-navigated lists): `dialog`, `alert-dialog`, `popover`,
163
+ `tooltip`, `hover-card`, `tabs`, `accordion`, `collapsible`,
164
+ `dropdown-menu`, `progress`, `sonner`, `toggle-group`. These ARE custom
165
+ elements. Import them once (typically in `app/layout.ts`) and use
166
+ `<ui-X>` tags:
167
+
168
+ ```ts
169
+ // app/layout.ts (registers the custom elements for every page)
170
+ import '../components/ui/dialog.ts';
171
+ import '../components/ui/tabs.ts';
172
+ ```
173
+
174
+ ```ts
175
+ // app/some-page/page.ts (uses the registered elements)
176
+ import { buttonClass } from '../../components/ui/button.ts';
177
+
178
+ return html`
179
+ <ui-dialog>
180
+ <ui-dialog-trigger>
181
+ <button class=${buttonClass({ variant: 'outline' })}>Edit</button>
182
+ </ui-dialog-trigger>
183
+ <ui-dialog-content>
184
+ <h2>Edit profile</h2>
185
+ ...
186
+ </ui-dialog-content>
187
+ </ui-dialog>
188
+ `;
189
+ ```
190
+
191
+ ### Adding more components
192
+
193
+ ```sh
194
+ webjs ui add dialog dropdown-menu tabs progress
195
+ ```
196
+
197
+ Each `webjs ui add` call fetches the component source from
198
+ `https://ui.webjs.dev/registry/<name>.json`, copies it into
199
+ `components/ui/`, and installs any required npm deps. Run
200
+ `webjs ui list` to browse the catalogue or visit
201
+ [https://ui.webjs.dev](https://ui.webjs.dev).
202
+
203
+ ### AI agents, picking the right tier
204
+
205
+ For forms, dashboards, settings pages, marketing layouts: **call the
206
+ Tier-1 class helpers on raw native elements**. You get accessibility,
207
+ visual consistency, and form submission semantics for free.
208
+ `<input class=${inputClass()}>` is a real `<input>` with native
209
+ autofill, browser validation, and `<form>` submission unchanged.
210
+
211
+ Because Tier-1 helpers wrap *real* HTML elements, a `buttonClass()`
212
+ button inside a `<form action="/posts" method="post">` participates
213
+ in the client router's partial-swap submission automatically. No JS
214
+ handler, no `fetch`. See *Client navigation patterns* below for the
215
+ full form-submission + 4xx-HTML-render-in-place pattern.
216
+
217
+ For modals, dropdowns, tooltips, tab strips, accordions: use the
218
+ Tier-2 `<ui-X>` custom element tags after importing the corresponding
219
+ module.
220
+
221
+ The composition style is deliberately **not** shadcn's
222
+ component-everything React API. We use native elements + class helpers
223
+ for the visual stuff because hiding a `<button>` inside a `<Button>`
224
+ wrapper adds zero value and obscures the real element from inspection,
225
+ form submission, and screen readers. Custom elements are reserved for
226
+ behavior the browser can't deliver natively.
227
+
228
+ ## File conventions
229
+
230
+ ```
231
+ app/ thin route adapters (import from modules/)
232
+ page.ts → /
233
+ layout.ts root layout, wraps every page
234
+ error.ts error boundary (render failures → user-friendly)
235
+ loading.ts Suspense fallback for sibling page
236
+ not-found.ts custom 404 page
237
+ middleware.ts global request middleware
238
+ [slug]/page.ts dynamic route segment
239
+ [...rest]/page.ts catch-all
240
+ (group)/ route group (parens not in URL)
241
+ _private/ underscore = not routable
242
+ api/
243
+ <path>/route.ts GET / POST / PUT / DELETE / WS handlers
244
+ sitemap.ts metadata route → /sitemap.xml
245
+ robots.ts metadata route → /robots.txt
246
+ opengraph-image.ts metadata route → /opengraph-image
247
+ components/ web components (extend WebComponent, call .register())
248
+ modules/<feature>/
249
+ actions/*.server.ts server actions (one function per file)
250
+ queries/*.server.ts data reads (one function per file)
251
+ components/*.ts feature-scoped components
252
+ utils/*.ts feature-scoped helpers
253
+ types.ts feature types
254
+ lib/
255
+ prisma.ts PrismaClient singleton (import from here, never `new PrismaClient()`)
256
+ ... other cross-cutting infra (session, auth config, etc.)
257
+ prisma/
258
+ schema.prisma Prisma schema, SQLite by default, switch provider for Postgres/MySQL
259
+ dev.db SQLite file (gitignored); run `npm run db:migrate` to create
260
+ migrations/ generated migration SQL
261
+ public/ static assets, served at /public/*
262
+ test/<feature>/ feature-scoped tests, one folder per concern
263
+ <name>.test.ts node unit / integration test (node --test)
264
+ browser/<name>.test.js real-browser test (web-test-runner)
265
+ e2e/<name>.test.ts end-to-end test (full app boot, opt in via WEBJS_E2E=1)
266
+ smoke/<name>.test.ts fast post-deploy sanity check
267
+ middleware.ts root middleware (optional, outermost)
268
+ ```
269
+
270
+ ## Database (Prisma + SQLite by default)
271
+
272
+ Every scaffold includes a Prisma setup pointed at a local SQLite file.
273
+ First-run workflow:
274
+
275
+ ```sh
276
+ cp .env.example .env # DATABASE_URL is pre-filled for SQLite
277
+ npm run db:migrate # creates prisma/dev.db + migration
278
+ npm run dev # webjs dev + prisma generate via predev
279
+ ```
280
+
281
+ ### Always `npm run dev` / `npm start`, never `webjs dev` / `webjs start` directly
282
+
283
+ `webjs dev` and `webjs start` are framework primitives, they only run
284
+ the webjs server. They do **not** run `prisma generate`, do **not** run
285
+ `prisma migrate deploy`, do **not** spawn the Tailwind watcher, do
286
+ **not** run any other per-app process this `package.json` composes.
287
+
288
+ `npm run dev` and `npm start` are the app-level entrypoints. They run
289
+ the webjs server **plus** every other process the app needs, wired
290
+ together via `predev` / `prestart` hooks and (where present)
291
+ `concurrently` for parallel watchers. Skipping the npm wrapper produces
292
+ silent breakage: a stale Prisma client, missing `public/tailwind.css`,
293
+ an unmigrated database in production, etc.
294
+
295
+ Same split Rails 7+ uses: `bin/rails server` is the framework
296
+ primitive, `bin/dev` is the orchestrator. webjs uses npm scripts +
297
+ hooks for the same role, because as a no-build framework Tailwind /
298
+ Prisma / etc. cannot be bundler plugins.
299
+
300
+ In Docker / Railway, prefer `npm start` (or `node node_modules/.bin/npm
301
+ start`) as the CMD over `node ... webjs.js start ...`. The npm form
302
+ fires `prestart`; the direct binary form skips it.
303
+
304
+ Scripts:
305
+
306
+ - `npm run db:migrate`: `prisma migrate dev` (dev-time schema changes + migration + generate)
307
+ - `npm run db:generate`: `prisma generate` (regenerate client only)
308
+ - `npm run db:studio`: `prisma studio` (GUI)
309
+ - `predev` hook auto-runs `prisma generate` before `npm run dev`
310
+ - `prestart` hook runs `prisma migrate deploy` before `npm start` (idempotent in prod)
311
+
312
+ Always import the client from `lib/prisma.server.ts` (never `new PrismaClient()` directly -
313
+ the singleton avoids opening a new connection on every dev-server reload):
314
+
315
+ ```ts
316
+ import { prisma } from '../../../lib/prisma.server.ts';
317
+ const users = await prisma.user.findMany();
318
+ ```
319
+
320
+ To switch to Postgres or MySQL: change `provider` in `prisma/schema.prisma`
321
+ and the `DATABASE_URL` in `.env`.
322
+
323
+ ## Imports
324
+
325
+ ```ts
326
+ import { html, css, WebComponent } from '@webjsdev/core';
327
+ import '@webjsdev/core/client-router'; // enable SPA nav
328
+ import { unsafeHTML, live } from '@webjsdev/core/directives';
329
+ import { createContext } from '@webjsdev/core/context';
330
+ import { Task } from '@webjsdev/core/task';
331
+ import { fixture, waitForUpdate } from '@webjsdev/core/testing';
332
+
333
+ import { rateLimit, cache, createAuth, Credentials, Session } from '@webjsdev/server';
334
+ ```
335
+
336
+ ## Environment variables (server vs browser)
337
+
338
+ 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.
339
+
340
+ ```sh
341
+ # .env
342
+ DATABASE_URL=postgres://... # server-only
343
+ AUTH_SECRET=... # server-only
344
+ WEBJS_PUBLIC_API_URL=https://x.com # browser too
345
+ ```
346
+
347
+ ```ts
348
+ // Server-side (page function, action, middleware, route handler):
349
+ const dburl = process.env.DATABASE_URL; // works
350
+
351
+ // Browser-side (component render method, client-only utilities):
352
+ const url = process.env.WEBJS_PUBLIC_API_URL; // works
353
+ const secret = process.env.AUTH_SECRET; // undefined (fail-closed)
354
+ ```
355
+
356
+ `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.com/docs/configuration).
357
+
358
+ ## Component pattern
359
+
360
+ ```ts
361
+ import { WebComponent, html, css } from '@webjsdev/core';
362
+
363
+ export class Counter extends WebComponent {
364
+ static properties = { count: { type: Number } };
365
+ static styles = css`button { padding: 8px 12px; }`; // shadow-DOM only
366
+ // static shadow = true; // opt into shadow DOM (default: light DOM)
367
+ // static lazy = true; // download JS only when scrolled into view
368
+ declare count: number; // TypeScript-only typed accessor
369
+
370
+ constructor() {
371
+ super();
372
+ this.count = 0; // SSR-meaningful default, see below
373
+ }
374
+
375
+ render() {
376
+ return html`
377
+ <button @click=${() => { this.count = this.count + 1; }}>
378
+ ${this.count}
379
+ </button>
380
+ `;
381
+ }
382
+ }
383
+ Counter.register('my-counter');
384
+ ```
385
+
386
+ **Progressive-enhancement rule for components.** Every webjs component
387
+ is SSR'd. The server constructs the component, applies attributes,
388
+ and runs `render()`. With JS disabled, the component's initial HTML
389
+ still paints (an unstyled counter still shows the number, and only
390
+ the click handler is inert). Two consequences for how you write code:
391
+
392
+ 1. **Defaults for the first paint go in `constructor()`** (after
393
+ `super()`), never as class-field initializers (which break
394
+ reactivity) and never in `connectedCallback` (which the server
395
+ doesn't run). For Web Component properties with `declare`, set the
396
+ default in the constructor.
397
+ 2. **`connectedCallback` is browser-only.** Use it for
398
+ `localStorage`, viewport size, online status, or anything that
399
+ genuinely can't be known on the server. Read the value, then
400
+ assign it to a reactive property (`this.items = stored`) or write
401
+ to a signal to refine the render. The SSR'd first paint shows the
402
+ constructor default. The browser refines after hydration.
403
+ 3. **Server-known data goes through the page function**, not into
404
+ `connectedCallback`. Fetch in the page (which runs on the server),
405
+ pass the result down via `.prop=${value}` (custom elements) or
406
+ `attr=${string}` (native elements). For custom elements, the wire
407
+ serializer round-trips Array / Object / Date / Map / Set / BigInt
408
+ through the SSR `data-webjs-prop-*` side-channel, so the
409
+ component's first paint already has the rich-typed value with no
410
+ flash. The framework owns the attribute, applies it on
411
+ `connectedCallback`, then strips it from the live DOM. For native
412
+ elements use `value=${v}` / `checked=${b}` etc.; `.value` on a
413
+ native element drops at SSR (the property form is for client-only
414
+ re-render scenarios like controlled inputs via `.value=${live(v)}`).
415
+ 4. **For write-paths, prefer `<form>` + server action over `fetch`.**
416
+ Plain forms POST without JS; the client router upgrades them to
417
+ partial-swaps automatically when scripts are active. One
418
+ implementation covers both.
419
+
420
+ See [Progressive Enhancement](https://docs.webjs.dev/docs/progressive-enhancement) for the full design rationale.
421
+
422
+ ## Lit muscle-memory gotchas (read if you have written lit before)
423
+
424
+ Webjs's runtime API matches lit. The `WebComponent` base class,
425
+ `static properties`, the lifecycle hooks, ReactiveControllers, the
426
+ directive set, `html` / `css` tagged templates. The **rendering
427
+ model**, however, is different. Pure-lit patterns that work fine in a
428
+ client-only lit app break in webjs's SSR pipeline or its reactivity
429
+ system. Read this section before reaching for lit idioms.
430
+
431
+ ### Mental model. JS opt-in per behavior, not per component
432
+
433
+ Lit hydrates per component. You decide at the component boundary
434
+ whether JS ships and runs for that island.
435
+
436
+ Webjs ships JS per **interactive behavior**, not per component. Every
437
+ component is server-rendered. JavaScript is requested by the specific
438
+ holes you write in the template.
439
+
440
+ - `@click=${...}`, `@input=${...}`, any event binding requests JS.
441
+ - A reactive property assignment (`this.count = …`) or a signal
442
+ `set()` that the component reads requests JS for reactive updates.
443
+ - `.prop=${richObject}` requests JS for property hydration.
444
+ - A controller like `Task` requests JS for that async behavior.
445
+ - A plain `<a href>`, a `<form action method>` submission, or a
446
+ purely display-time component (no event listeners, no property
447
+ mutations, no signal subscriptions, no property bindings) does
448
+ **not** request JS.
449
+
450
+ A single component can mix both. A product card with server-rendered
451
+ title, price, image, plus a "View" link (no JS) and an "Add to cart"
452
+ button with a `@click` (JS for that one behavior) is correct webjs
453
+ style. The framework loads JS for the component because of the
454
+ `@click` and runs it, while the rest of the card stays exactly as the
455
+ server painted it.
456
+
457
+ Practical consequences for agents writing webjs code.
458
+
459
+ 1. Never reach for `fetch()` plus a `@click` handler when a `<form>`
460
+ plus a server action would do. The form is free (no JS), the
461
+ server action is typed and CSRF-protected, the result reaches the
462
+ page through normal navigation.
463
+ 2. Never make first paint depend on hydration. A blank skeleton until
464
+ JS runs means the feature was written wrong.
465
+ 3. Don't think binary about "static vs interactive components." Pick
466
+ interactive primitives per behavior. A page with ten components
467
+ can ship zero JS for eight of them and handlers only for the two
468
+ that need it.
469
+
470
+ ### Gotchas at a glance
471
+
472
+ | Lit pattern | What breaks in webjs | Webjs equivalent |
473
+ |---|---|---|
474
+ | Fetch in `connectedCallback` / `firstUpdated` | Empty first paint (neither hook runs in SSR) | Fetch in the page function, pass as props |
475
+ | `Task` for initial-paint data | SSR ships the pending state, flashes to resolved on hydration | Page function fetch, pass as props (`Task` is fine for client-time async) |
476
+ | `window.X` / `document.X` in constructor or `render()` | SSR crash | Move to `connectedCallback` |
477
+ | Top-level `import` of a browser-only library | SSR crash | Dynamic `import()` inside `connectedCallback` |
478
+ | Class-field initializer for a reactive property (`student: Student = {...}`) | Silently breaks reactivity (overwrites the framework accessor) | `declare student: Student` plus constructor default |
479
+ | `@property()` decorator | Banned by invariant 10 (erasable TS) | `static properties = { ... }` plus `declare` |
480
+ | `static styles = css` block without `static shadow = true` | Styles leak globally; the framework warns at runtime | Add `static shadow = true`, or use Tailwind utilities |
481
+ | `willUpdate` computing SSR-visible derived state | Field is `undefined` in SSR HTML (hook is client-only) | Compute inline in `render()` |
482
+ | `ContextProvider` for server-known data | Default value during SSR, content shift on hydration | Pass via props from the page function |
483
+
484
+ The full annotated catalog with code examples lives in the framework
485
+ repo at
486
+ [`agent-docs/lit-muscle-memory-gotchas.md`](https://github.com/vivek7405/webjs/blob/main/agent-docs/lit-muscle-memory-gotchas.md).
487
+
488
+ ## Server action pattern
489
+
490
+ ```ts
491
+ // modules/posts/actions/create-post.server.ts
492
+ 'use server';
493
+ import { prisma } from '../../../lib/prisma.server.ts';
494
+
495
+ export async function createPost(input: { title: string; body: string }) {
496
+ if (!input.title) return { success: false, error: 'title required', status: 400 };
497
+ const post = await prisma.post.create({ data: input });
498
+ return { success: true, data: post };
499
+ }
500
+ ```
501
+
502
+ Import it from a client component. The framework rewrites it into a
503
+ type-safe RPC stub automatically.
504
+
505
+ ## Client navigation patterns (auto-magic)
506
+
507
+ The client router enables itself when the scaffolded root layout imports
508
+ `@webjsdev/core/client-router`. After that, **every `<a href>` and
509
+ `<form action>` on the page is enhanced into a partial-swap navigation
510
+ or submission automatically**. You don't call a router API. Write
511
+ standard HTML; the swap happens.
512
+
513
+ What this changes for how you write apps:
514
+
515
+ ### 1. Put shared chrome in `layout.ts`, not in every page
516
+
517
+ When you navigate from `/posts` to `/posts/123`, the framework swaps
518
+ only the deepest layout's `${'${children}'}` slot. Outer layouts stay
519
+ mounted. The sidenav's scroll position, an open `<details>`, a focused
520
+ input, and an inflight `<video>` are all preserved across the navigation
521
+ without you writing any code.
522
+
523
+ The rule: anything that should persist across navigations within a
524
+ section lives in that section's `layout.ts`. Page-specific content
525
+ lives in `page.ts`. Don't duplicate a sidenav into every page.
526
+
527
+ ### 2. Forms POST through `<form action>` (no `fetch` for write-paths)
528
+
529
+ A `<form action=${'${createPost}'} method="post">` works as a plain
530
+ HTML form when JS is disabled and as a partial-swap submission when JS
531
+ is active. **The same form covers both paths.** Don't reach for
532
+ `fetch` + a click handler unless you genuinely need to.
533
+
534
+ ### 3. Server-side validation: re-render the form with errors
535
+
536
+ The router applies any `text/html` response to the DOM regardless of
537
+ status code (4xx, 422, etc.). This is the Rails / Django / Phoenix
538
+ server-side validation pattern. Pair a `<form action="/posts" method="post">`
539
+ with a `route.ts` POST handler:
540
+
541
+ ```ts
542
+ // app/posts/route.ts
543
+ import { redirect, html } from '@webjsdev/core';
544
+ import { createPost } from '../../modules/posts/actions/create-post.server.ts';
545
+
546
+ export async function POST(req: Request) {
547
+ const form = await req.formData();
548
+ const result = await createPost({
549
+ title: String(form.get('title') ?? ''),
550
+ body: String(form.get('body') ?? ''),
551
+ });
552
+ if (!result.success) {
553
+ // Re-render the form page with the user's input + inline errors.
554
+ // The client router applies this HTML in place, no full reload.
555
+ return new Response(renderNewPostForm(result.errors, form), {
556
+ status: 422,
557
+ headers: { 'content-type': 'text/html; charset=utf-8' },
558
+ });
559
+ }
560
+ // Success → PRG redirect; fetch follows, history records /posts/<id>
561
+ redirect(`/posts/${result.data.id}`);
562
+ }
563
+ ```
564
+
565
+ ```html
566
+ <!-- The form: standard HTML, no JS handler needed -->
567
+ <form action="/posts" method="post">
568
+ <input name="title" required />
569
+ <textarea name="body" required></textarea>
570
+ <button>Publish</button>
571
+ </form>
572
+ ```
573
+
574
+ With JS active: router intercepts the submit, sends the POST, applies
575
+ the response in place (2xx + redirect for success, 4xx HTML for
576
+ errors). With JS disabled: browser performs the same POST as a normal
577
+ form submission and renders the response page. Same code, both paths.
578
+
579
+ (For RPC-style server actions that return typed values to client
580
+ components. See *Server action pattern* above. The HTML-form pattern
581
+ here is for the "submit → server processes → render new page" flow.)
582
+
583
+ ### 4. `<webjs-frame id="...">` for non-layout swap regions
584
+
585
+ For a widget that should swap on click but isn't a route boundary
586
+ (e.g. a tab strip inside a page), wrap it:
587
+
588
+ ```ts
589
+ return html`
590
+ <nav>
591
+ <a href=${'${path + "?tab=overview"}'}>Overview</a>
592
+ <a href=${'${path + "?tab=stats"}'}>Stats</a>
593
+ </nav>
594
+ <webjs-frame id="tab-content">
595
+ ${'${tab === "stats" ? renderStats() : renderOverview()}'}
596
+ </webjs-frame>
597
+ `;
598
+ ```
599
+
600
+ The router's `closest('webjs-frame')` detection takes precedence over
601
+ layout markers. Only the frame's content swaps. Use this sparingly -
602
+ folder-based layouts handle 99% of cases.
603
+
604
+ ### 5. `loading.ts` for per-segment skeletons
605
+
606
+ Drop a `loading.ts` in any route segment. The framework auto-wraps the
607
+ sibling `page.ts` in a Suspense boundary with `loading.ts`'s default
608
+ export as the fallback. On navigation, the client router clones the
609
+ deepest matching loading template into the swap slot immediately -
610
+ the user sees a skeleton during the fetch, then the real content.
611
+
612
+ ### 6. `error.ts` for per-segment error boundaries
613
+
614
+ Drop an `error.ts` in any route segment. Render-time exceptions in
615
+ that segment's tree are caught and rendered through `error.ts`'s
616
+ default export, scoped to that boundary (outer layouts stay alive).
617
+
618
+ ### What you do NOT need to write
619
+
620
+ - Manual fetch / DOM-swap code for SPA-style navigation
621
+ - An "active link" highlight handler. Use `aria-current="page"`
622
+ derived from the request URL on the server.
623
+ - Loading spinners on `<a>` clicks. `loading.ts` handles it.
624
+ - Cancellation when the user clicks faster than the network. The
625
+ router's nav-token + AbortController combo guarantees stale
626
+ responses never overwrite a newer settled page.
627
+ - Scroll-position save/restore for back/forward. The snapshot cache
628
+ handles window scroll. Inner scrollables persist via DOM identity.
629
+
630
+ Full reference: see the [Client Router docs](https://docs.webjs.dev/docs/client-router) and the framework AGENTS.md "Client navigation" section.
631
+
632
+ ## Metadata (per-page)
633
+
634
+ The `metadata` export is Next.js-compatible. Common fields shown below;
635
+ the full surface includes `title.template / .default / .absolute`,
636
+ `metadataBase`, `alternates: { canonical, languages, media, types }`,
637
+ `robots`, `keywords`, `authors`, `creator`, `publisher`, `verification`,
638
+ `icons`, `manifest`, `appleWebApp`, `formatDetection`, `itunes`, and
639
+ the typed `other: { '<meta-name>': value }` escape hatch.
640
+
641
+ ```ts
642
+ export const metadata = {
643
+ title: 'My page',
644
+ // OR: title: { template: '%s | {{APP_NAME}}', default: '{{APP_NAME}}' }
645
+ description: 'A page in {{APP_NAME}}',
646
+ metadataBase: 'https://example.com', // base for relative URLs below
647
+ openGraph: { type: 'website', image: '/og.png' },
648
+ twitter: { card: 'summary_large_image' },
649
+ icons: { icon: '/favicon.svg', apple: '/apple.png' },
650
+ alternates: { canonical: '/post' }, // → <link rel="canonical">
651
+ robots: { index: true, follow: true },
652
+ cacheControl: 'public, max-age=60', // opt into caching (default: no-store)
653
+ };
654
+ ```
655
+
656
+ Use `generateMetadata(ctx)` when you need request-scoped values (e.g.
657
+ absolute URLs from `ctx.url`):
658
+
659
+ ```ts
660
+ export function generateMetadata(ctx: { url: string }) {
661
+ return { metadataBase: new URL(ctx.url).origin, title: 'Hello' };
662
+ }
663
+ ```
664
+
665
+ Viewport may be split into its own export (Next.js 14+ pattern):
666
+
667
+ ```ts
668
+ export const viewport = {
669
+ width: 'device-width',
670
+ initialScale: 1,
671
+ themeColor: '#1c1613',
672
+ colorScheme: 'light dark',
673
+ };
674
+ ```
675
+
676
+ ## Document shell (`<html>` / `<head>` / `<body>`)
677
+
678
+ The framework owns the shell by default. The SSR pipeline auto-emits
679
+ `<!doctype html><html lang="en"><head>…</head><body>` around every
680
+ composition, and auto-hoists `<link>` / `<style>` / `<meta>` / `<script>`
681
+ tags returned anywhere in a layout/page into the real `<head>`. The
682
+ `metadata` export drives `<title>` and `<meta>` tags.
683
+
684
+ **Only `app/layout.ts` (the root layout)** may optionally write its
685
+ own `<!doctype><html><head>…</head><body>` shell to override `<html lang>`,
686
+ `<html dir>`, `<html data-*>`, `<body class>`, or add a custom
687
+ `<link rel="preconnect">` etc. When the root layout supplies a shell,
688
+ the framework respects it and splices its required tags into the
689
+ user's `<head>`.
690
+
691
+ ```ts
692
+ // app/layout.ts (root, optionally owning the shell)
693
+ export default function RootLayout({ children }) {
694
+ return html`
695
+ <!doctype html>
696
+ <html lang="es" data-theme="dark">
697
+ <head>
698
+ <link rel="preconnect" href="https://cdn.example.com">
699
+ </head>
700
+ <body class="min-h-screen bg-bg">
701
+ <main>${children}</main>
702
+ </body>
703
+ </html>
704
+ `;
705
+ }
706
+ ```
707
+
708
+ **Non-root layouts** (`app/<segment>/layout.ts`) and **pages**
709
+ (`app/**/page.ts`) **must NOT** write `<!doctype>` / `<html>` / `<head>`
710
+ / `<body>`. The framework auto-emits the wrapper around the whole
711
+ composition, so a nested shell ends up dropped by the HTML parser.
712
+ `webjs check` enforces this via the `shell-in-non-root-layout` rule.
713
+
714
+ ## Invariants (do not violate)
715
+
716
+ 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.
717
+ 2. **Server-only code goes in `.server.{js,ts}` files, `route.ts`
718
+ handlers, or `middleware.ts`. Never in pages, layouts, or
719
+ components.** Direct imports of `@prisma/client`, `node:*`, or any
720
+ server-only dependency from a page, layout, loading.ts, error.ts,
721
+ not-found.ts, or component will crash the browser at module load.
722
+ Wrap the access in a `.server.{js,ts}` file; the framework
723
+ rewrites that import into an RPC stub for the browser. `lib/`
724
+ holds both server-only infra (`lib/prisma.server.ts`, `lib/session.server.ts`)
725
+ and browser-safe utilities (`lib/utils/cn.ts` with `cn`, design-
726
+ system helpers). Server-only `lib/*` files must only be imported
727
+ from `.server.ts`/`route.ts`/`middleware.ts`; browser-safe `lib/*`
728
+ files (like `lib/utils/cn.ts`) can be imported anywhere.
729
+ 3. Event / property / boolean holes in `` html`` `` are unquoted:
730
+ `@click=${fn}`, not `@click="${fn}"`.
731
+ 4. Component state lives in signals. Import `signal` from
732
+ `@webjsdev/core`, read with `signal.get()` inside `render()`, and
733
+ write with `signal.set(value)`. Module-scope signals share state
734
+ across components; instance signals (created in the constructor)
735
+ carry component-local state. Reactive properties (`static
736
+ properties = { ... }` with a sibling `declare`) are for values
737
+ that ride an HTML attribute or `.prop=${...}` SSR hydration.
738
+ 5. Pages / layouts / metadata routes default-export a server-only function.
739
+ 6. One exported function per action / query file. Name the file after it.
740
+ 7. **Components must render meaningful HTML on first paint** (SSR
741
+ uses constructor defaults + attributes, while `connectedCallback` is
742
+ browser-only). Never fetch initial data in `connectedCallback` /
743
+ `firstUpdated`. Fetch in the page function (server) and pass it as
744
+ a prop. See *Component pattern* above.
745
+ 8. **Erasable TypeScript only.** Node 24+ strips types via
746
+ `module.stripTypeScriptTypes` (whitespace replacement, byte-exact
747
+ line and column position preservation, no sourcemap shipped to the
748
+ browser). Your `tsconfig.json` sets `erasableSyntaxOnly: true`, so
749
+ the TS compiler rejects: `enum`, `namespace` with values,
750
+ constructor parameter properties, legacy decorators with
751
+ `emitDecoratorMetadata`, and `import = require`. Use the erasable
752
+ equivalents:
753
+
754
+ ```ts
755
+ // ❌ enum
756
+ enum Color { Red, Green, Blue }
757
+
758
+ // ✅ const object + union type
759
+ const Color = { Red: 'Red', Green: 'Green', Blue: 'Blue' } as const;
760
+ type Color = typeof Color[keyof typeof Color];
761
+
762
+ // ❌ parameter property
763
+ class Foo { constructor(public x: number) {} }
764
+
765
+ // ✅ explicit field + assignment
766
+ class Foo {
767
+ x: number;
768
+ constructor(x: number) { this.x = x; }
769
+ }
770
+ ```
771
+
772
+ If you turn `erasableSyntaxOnly` off and use non-erasable syntax,
773
+ the dev server falls back to esbuild and emits inline sourcemaps
774
+ for those specific files: roughly 3x wire bytes per request, and
775
+ stack-trace positions are no longer byte-exact. The
776
+ `erasable-typescript-only` convention check warns when the flag
777
+ is missing or set to false.
778
+ 9. **No em-dashes (U+2014) anywhere, and no hyphen or semicolon used
779
+ as a pause-punctuation substitute.** Prose, comments, code, JSON
780
+ descriptions, commit messages. Rewrite the sentence so no
781
+ pause-punctuation crutch is needed. Banned as pause punctuation:
782
+ the em-dash (`-`), a plain hyphen used in place of one (` - `), and
783
+ a semicolon used in place of one (` ; `). Use a period, comma,
784
+ colon, parentheses, or a restructured phrasing. Plain hyphens stay
785
+ fine in compound words (`AI-first`), CLI flags (`--http2`),
786
+ filenames, and ranges. Semicolons stay fine inside code.
787
+
788
+ ## Workflow expectations for AI agents
789
+
790
+ 1. Branch before editing. Never push to `main` directly.
791
+ 2. Every code change comes with: unit test(s), AGENTS.md / docs updates if
792
+ the feature surface changed, `webjs check` passing.
793
+ 3. Commit and push **per logical unit**, not at the end. A logical unit is one
794
+ feature, one fix, one rename, one doc rewrite. If you have 5+ unstaged files
795
+ spanning different concerns, commit the current group before continuing.
796
+ The framework ships a `nudge-uncommitted` hook for several agents that
797
+ fires at threshold 4:
798
+
799
+ | Agent | Hook path | Doc |
800
+ |---|---|---|
801
+ | Claude Code | `.claude/hooks/nudge-uncommitted.sh` (`PostToolUse`) | `.claude/settings.json` |
802
+ | Gemini CLI | `.gemini/hooks/nudge-uncommitted.sh` (`AfterTool`) | `.gemini/settings.json` |
803
+ | Cursor 1.7+ | `.cursor/hooks/nudge-uncommitted.sh` (`afterFileEdit`) | `.cursor/hooks.json` |
804
+ | OpenCode | `.opencode/plugins/nudge-uncommitted.ts` (`tool.execute.after`) | `.opencode/plugins/` |
805
+ | Windsurf | text rule only (post-write hooks cannot inject context) | `.windsurfrules` |
806
+ | GitHub Copilot | text rule only (no hooks API) | `.github/copilot-instructions.md` |
807
+ | Google Antigravity | text rule only (no hooks API) | `AGENTS.md` |
808
+
809
+ Tool-agnostic fallback: `.hooks/pre-commit` runs `webjs test` + `webjs check`
810
+ on every commit, regardless of which agent (or human) made it. No AI
811
+ attribution trailers in commit messages.
812
+ 4. When unsure how a framework feature works, `grep` or `cat` the
813
+ relevant `node_modules/@webjsdev/*/src/` file before asking the user.
814
+
815
+ Project-specific conventions and overrides live in
816
+ [CONVENTIONS.md](./CONVENTIONS.md).