@webjsdev/mcp 0.1.16 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,10 +41,18 @@ subcommand) delegates to this same server, so both routes run identical code.
41
41
  guided workflows).
42
42
  - **`source` tool**: reads the framework's own no-build source from
43
43
  `node_modules/@webjsdev/*/src` (read-only, traversal-guarded).
44
- - **`ui` tool**: the `@webjsdev/ui` kit inventory (no args) or one component's
45
- helper signatures, paste-ready structural example, a11y header, and deps (pass
46
- `name`). Kit-scoped (not `appDir`-scoped); shares one projector with
47
- `webjsui view`.
44
+ - **`ui` tool**: the `@webjsdev/ui` kit. With no args it returns the
45
+ component inventory plus the block and chart NAMES grouped by category (a
46
+ block is a ready-made view you install and then edit: a login or signup
47
+ page, a sidebar shell, a dashboard, a chart). `type` (`ui` / `block` /
48
+ `chart` / `all`) and `category` (`login`, `sidebar`, `area`, ...) list the
49
+ matches with one-line descriptions. `name` returns one component's helper
50
+ signatures, paste-ready structural example, a11y header and deps, or one
51
+ block's description, dependencies, install command
52
+ (`npx @webjsdev/ui add <name>`) and files with their targets; `source: true`
53
+ adds a block's sources. Everything it returns is baked plain Tailwind, never
54
+ a `cn-*` name, and a test caps the no-args payload size. Kit-scoped (not
55
+ `appDir`-scoped); shares one projector with `webjsui view`.
48
56
 
49
57
  ## Which docs you get
50
58
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/mcp",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "type": "module",
5
5
  "description": "The Model Context Protocol server for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Gives AI coding agents live app introspection over routes, actions, components, and convention checks, plus a knowledge layer of docs, recipes, and framework source.",
6
6
  "bin": {
@@ -25,8 +25,8 @@
25
25
  "resources"
26
26
  ],
27
27
  "dependencies": {
28
- "@webjsdev/server": "^0.8.0",
29
- "@webjsdev/ui": "^0.3.9"
28
+ "@webjsdev/server": "^0.9.0",
29
+ "@webjsdev/ui": "^0.4.0"
30
30
  },
31
31
  "publishConfig": {
32
32
  "access": "public"
@@ -56,19 +56,19 @@ cd ../<repo>-<slug> # do ALL work for the task here
56
56
 
57
57
  **A fresh worktree has NO `node_modules`** (git worktrees do not copy it), so running an app from one (`webjs dev` / `webjs start`, the test runner, a scaffolded app) fails to resolve `@webjsdev/*` until you install or link it. `webjs doctor` warns for this exact case (#954) and `webjs dev` / `webjs start` print the cause + remedy instead of a raw `ERR_MODULE_NOT_FOUND`.
58
58
 
59
- Fix it with **`npm run worktree:link`** from inside the worktree. A full `npm install` there is NOT an alternative once the link is standing, and the reason is below. **Do NOT hand-symlink only the root `node_modules`.** That is the obvious move and it produces a worktree that looks set up and then fails dozens of tests for reasons that point nowhere near the real cause. Three things beyond the root tree are needed, and the script handles all three:
59
+ Fix it with **`npm run worktree:link`** from the worktree root (or `node scripts/link-worktree-deps.mjs` from any directory in it, since the script acts on the git toplevel). A full `npm install` there is NOT an alternative once the link is standing, and the reason is below. **Do NOT hand-symlink only the root `node_modules`.** That is the obvious move and it produces a worktree that looks set up and then fails dozens of tests for reasons that point nowhere near the real cause. Three things beyond the root tree are needed, and the script handles all three:
60
60
 
61
61
  - **Every NESTED `node_modules`, not just the root.** npm hoists what it can, but a workspace whose range conflicts with the hoisted copy keeps its own nested tree. `packages/server` is the live example: the root carries `ws@7` (hoisted for another dependent) while `packages/server` declares `^8.20.0` and keeps `ws@8` nested. Link only the root and `WebSocketServer`, a ws@8-only named export, resolves up to ws@7 and throws at module load. That single miss failed hundreds of assertions across the server, integration, and smoke suites, none of them naming ws.
62
- - **`packages/core/dist`**, which is built rather than committed, so a fresh worktree has none and every test importing the built bundle fails to resolve it.
62
+ - **`packages/core/dist`**, which is built rather than committed, so a fresh worktree has none and every test importing the built bundle fails to resolve it. The script BUILDS it in the worktree from the worktree's own sources, after the links are in place (esbuild resolves through the linked `node_modules`), and rebuilds it on every run so it never lags the branch. It is never linked from the primary (#1671): a link there served the primary's bundle, built at another commit, and any build in the worktree, core's `prepare` during `npm ci` included, wrote this branch's bundle THROUGH it into the primary. A build failure WARNS and never fails the link. Skip it with `WEBJS_NO_WORKTREE_BUILD=1`.
63
63
  - **`examples/blog`'s seeded SQLite database.** `db/dev.db` is gitignored, so a worktree has none, and an empty `posts` table fails three blog tests plus their enclosing progressive-enhancement suite with nothing in the output naming the database (#1323). The script runs the blog's `db:migrate` then `db:seed` when the `posts` table is missing or empty, which takes about two and a half seconds and is a no-op once there are rows. A file-existence check would not do, because `webjs dev` and `webjs start` both run `webjs db migrate` as a before-step and leave the file there with no rows. Seeding failures WARN and never fail the link. Skip it with `WEBJS_NO_WORKTREE_SEED=1`.
64
64
 
65
- The script discovers the `node_modules` set from the primary checkout rather than hardcoding a list (it changes whenever a package gains a nested tree), never overwrites an existing path, and never creates a dangling link, so it is safe to re-run and safe in a worktree where you already ran a real `npm install`. The seed step keeps the same contract: it only ever applies pending migrations and inserts demo rows that are not there, so a database with rows in it is left untouched.
65
+ The script discovers the `node_modules` set from the primary checkout rather than hardcoding a list (it changes whenever a package gains a nested tree), never overwrites an existing path other than the dist it builds, and never creates a dangling link, so it is safe to re-run and safe in a worktree where you already ran a real `npm install`. The seed step keeps the same contract: it only ever applies pending migrations and inserts demo rows that are not there, so a database with rows in it is left untouched.
66
66
 
67
- **Know what this does NOT give you.** The worktree then runs the PRIMARY checkout's framework source through every bare `@webjsdev/*` specifier, because `<primary>/node_modules/@webjsdev/core` is a relative symlink into `<primary>/packages/core` and resolving through the linked root lands there. Relative imports (`../../../src/x.js`) and the browser suite, which web-test-runner serves from the worktree, do use the worktree's own files. So linking makes the suite RUNNABLE, not self-testing: if you are editing `packages/core/src` or `packages/server/src` and need a bare-specifier consumer to exercise YOUR copy, delete EVERY `node_modules` SYMLINK first, not only the root one, because the link script plants one per workspace that carries its own tree (`find . -maxdepth 5 -type l -name node_modules -delete`, they are only links and nothing else is lost) and then install, or repoint the individual `@webjsdev/<pkg>` entries at it. CI always builds from the branch, so it is unaffected either way.
67
+ **Know what this does NOT give you.** The worktree then runs the PRIMARY checkout's framework source through every bare `@webjsdev/*` specifier, because `<primary>/node_modules/@webjsdev/core` is a relative symlink into `<primary>/packages/core` and resolving through the linked root lands there. Relative imports (`../../../src/x.js`) and the browser suite, which web-test-runner serves from the worktree, do use the worktree's own files. So linking makes the suite RUNNABLE, not self-testing: if you are editing `packages/core/src` or `packages/server/src` and need a bare-specifier consumer to exercise YOUR copy, run **`npm run worktree:unlink`** first and then install, or repoint the individual `@webjsdev/<pkg>` entries at it. The unlink script removes EVERY `node_modules` link the link script planted, which is more than the root one, because it plants one per workspace that carries its own tree. It acts on the worktree root from any subdirectory, removes only links that point into the primary, and leaves real directories and links you made yourself alone (they are only links and nothing else is lost). A worktree linked before #1671 may still carry `packages/core/dist` as a link at the primary's dist. That is harmless: the core build swaps a symlinked out dir for a real directory once it succeeds and never writes into the link's target, and the next `npm run worktree:link` replaces it the same way. CI always builds from the branch, so it is unaffected either way.
68
68
 
69
- **NEVER install while the `node_modules` symlink is standing (#1442).** This is the trap the two paragraphs above used to walk you into, and the damage lands on a checkout you are not working in, so the failure surfaces in someone else's session with nothing naming the cause. Measured on npm 11.19.0 and bun 1.3.14: `npm ci` DELETES the primary's whole `node_modules` through the link before any lifecycle script runs, `bun install` writes packages and `.bin` entries straight into the primary through it, and `npm install` silently replaces the link with a real tree, detaching the worktree from the shared source. No `preinstall` script can prevent any of it, because npm removes the symlink before `preinstall` runs, `npm ci` has already emptied the primary by then, and Bun runs it in time but ignores a non-zero exit. So the layers are: Claude Code BLOCKS the command through `.claude/hooks/block-install-in-linked-worktree.sh`, which covers every manager's install aliases plus the REMOVE verbs (`npm rm` in a linked worktree deletes from the owning checkout), judges a COMMAND rather than a token so `git commit -m "fix: npm install ..."` and `grep -rn "npm ci"` are unaffected, and never blocks a GLOBAL `-g` install such as the post-release `npm update -g webjsdev` (escape hatch `WEBJS_NO_WORKTREE_INSTALL_GATE=1`), the root `preinstall` REPORTS it for every other tool without ever blocking, `npm run worktree:link` REPAIRS an already-damaged primary, and `npm run check:worktree-links` reports what it would repair without changing anything, exiting non-zero when there is work. `WEBJS_NO_WORKTREE_REPAIR=1` suppresses the repair WRITE, so it has no effect on `--check`, which never writes and always inspects. Tests: `test/hooks/block-install-in-linked-worktree.test.mjs`, `test/repo-health/warn-worktree-install.test.mjs`, `test/repo-health/link-worktree-deps.test.mjs`.
69
+ **NEVER install while the `node_modules` symlink is standing (#1442).** This is the trap the two paragraphs above used to walk you into, and the damage lands on a checkout you are not working in, so the failure surfaces in someone else's session with nothing naming the cause. Measured on npm 11.19.0 and bun 1.3.14: `npm ci` DELETES the primary's whole `node_modules` through the link before any lifecycle script runs, `bun install` writes packages and `.bin` entries straight into the primary through it, and `npm install` silently replaces the link with a real tree, detaching the worktree from the shared source. No `preinstall` script can prevent any of it, because npm removes the symlink before `preinstall` runs, `npm ci` has already emptied the primary by then, and Bun runs it in time but ignores a non-zero exit. So the layers are: Claude Code BLOCKS the command through `.claude/hooks/block-install-in-linked-worktree.sh`, which covers every manager's install aliases plus the REMOVE verbs (`npm rm` in a linked worktree deletes from the owning checkout), judges a COMMAND rather than a token so `git commit -m "fix: npm install ..."` and `grep -rn "npm ci"` are unaffected, and never blocks a GLOBAL `-g` install such as the post-release `npm update -g webjsdev` (escape hatch `WEBJS_NO_WORKTREE_INSTALL_GATE=1`), the root `preinstall` REPORTS it for every other tool without ever blocking, `npm run worktree:link` REPAIRS an already-damaged primary, `npm run worktree:unlink` removes every `node_modules` link the link script planted before a deliberate real install, and `npm run check:worktree-links` reports what it would repair without changing anything, exiting non-zero when there is work. `WEBJS_NO_WORKTREE_REPAIR=1` suppresses the repair WRITE, so it has no effect on `--check`, which never writes and always inspects. Tests: `test/hooks/block-install-in-linked-worktree.test.mjs`, `test/repo-health/warn-worktree-install.test.mjs`, `test/repo-health/link-worktree-deps.test.mjs`.
70
70
 
71
- Note the `webjs doctor` / `webjs dev` remedy message suggests the root-only symlink. That advice is correct for a scaffolded APP worktree, which has no nested trees and no built `dist/`, and wrong only for this monorepo. It stays app-generic on purpose, because it ships in the published CLI and `webjs dev` prints it verbatim to someone whose app has none of this repo's scripts; it names `npm run worktree:link` only when it finds a package.json actually declaring that script, so in this repo you get the monorepo path and in a scaffolded app you do not.
71
+ Note the `webjs doctor` / `webjs dev` remedy message suggests the root-only symlink. That advice is correct for a scaffolded APP worktree, which has no nested trees and no built `dist/`, and wrong only for this monorepo. It stays app-generic on purpose, because it ships in the published CLI and `webjs dev` prints it verbatim to someone whose app has none of this repo's scripts. It names `worktree:link`, and `worktree:unlink` in place of the `find` recipe, only when the app's own package.json or its checkout root's declares the script, and spells the command so it works from where doctor ran: `npm run <script>` for the app's own, `npm --prefix <root> run <script>` for the root's (a plain `npm run` in `website/` resolves the app's package.json and fails with Missing script). So in this repo you get the monorepo path and in a scaffolded app you do not.
72
72
 
73
73
  Git enforces one-branch-per-worktree, so separate worktrees make the collision impossible. There is NO lone-agent exception: every task cuts a worktree, and the primary checkout stays an untouched mirror of main (tracked-file edits there are hook-blocked). The repo's `.hooks/pre-commit` additionally BLOCKS a published-library (`core`/`server`/`cli`/`mcp`/`ui`/`intellisense`) version bump on any non-`chore/release-*` branch, the canonical wrong-branch-release symptom.
74
74
 
@@ -76,7 +76,7 @@ Git enforces one-branch-per-worktree, so separate worktrees make the collision i
76
76
 
77
77
  ### Skills are routed deterministically, never skipped
78
78
 
79
- A Skill is model-invoked, so it fires only when the model judges a match. The `.claude/hooks/route-skills.sh` `UserPromptSubmit` hook makes routing deterministic: it keyword-matches each prompt against every skill's documented triggers and injects a directive to invoke the matched skill before other work. Check the available skills and invoke a matching one before starting. The skills themselves are committed under `.claude/skills/` (alongside the hooks), so a fresh clone has both the router and the skills it routes to (no machine-local dependency). Tests in `test/hooks/route-skills.test.mjs`, which also asserts every skill the hook references is committed in-repo.
79
+ A Skill is model-invoked, so it fires only when the model judges a match. The `.claude/hooks/route-skills.sh` `UserPromptSubmit` hook makes routing deterministic: it keyword-matches each prompt against every skill's documented triggers and injects a directive to invoke the matched skill before other work. Check the available skills and invoke a matching one before starting. The skills themselves are committed under `.claude/skills/` (alongside the hooks), so a fresh clone has both the router and the skills it routes to (no machine-local dependency). When the same script is wired as a user-level hook it runs in every repo, so it stays silent in a checkout that has a different `.claude/hooks/route-skills.sh` of its own: that repo's router and skills apply there instead. Tests in `test/hooks/route-skills.test.mjs`, which also asserts every skill the hook references is committed in-repo.
80
80
 
81
81
  ### Talking to GitHub: `gh api` over REST, never the porcelain
82
82
 
@@ -132,7 +132,7 @@ verification commands in `references/module-structure.md`.
132
132
  Every code change MUST include, automatically:
133
133
 
134
134
  1. **Tests, every applicable layer (not just unit).** Ship the tests that prove the change across EVERY layer it touches: **unit** (`packages/*/test/**`, `test/**`, including the counterfactual that fails when reverted), **browser** (`*/test/**/browser/*` via `npm run test:browser`, for hydration / DOM / slots / client router / custom-element upgrade), **e2e** (`test/e2e/*.test.mjs` via `WEBJS_E2E=1`, including network probes / navigation / streaming), and **smoke** (`test/examples/*/smoke/*`). A unit test is NECESSARY BUT NOT SUFFICIENT for any client-router / component / browser-facing change (the headline behaviour is a browser/e2e assertion). **Bun parity is part of the task, not an afterthought:** WebJs runs on Node 24+ AND Bun (#508), so a change to a runtime-sensitive surface (the serializer, the node:http vs `Bun.serve` listener + request path, SSR / action / CSRF dispatch, streams, `node:crypto`, the TS stripper, auth / session / cors) MUST be proven on Bun (`node scripts/run-bun-tests.js` + the touched `test/bun/*.mjs` under `bun`) AND ship an added/updated `test/bun/<feature>.mjs` cross-runtime assertion. `npm test` does NOT run browser, e2e, or Bun; run them yourself and report the result (`npm run ci` at the root runs every layer as one command, #1471). Never report work done with failing or missing tests. See `references/testing.md`. Enforced by `.claude/hooks/require-tests-with-src.sh` (the scaffold variant WARNS unless `WEBJS_TEST_GATE=block`) and `.claude/hooks/require-bun-parity-with-runtime-src.sh` (BLOCKS a commit that stages runtime-sensitive source with no `test/bun/**` test; escape hatch `WEBJS_BUN_VERIFIED=1`).
135
- 2. **Documentation, part of the definition of done (not optional).** A task is NOT done until EVERY doc surface its change touches is in sync: `AGENTS.md` + the skill at `.agents/skills/webjs/` (SKILL.md + references/) for new API surface, `CONVENTIONS.md` (and per-package `AGENTS.md`) for new conventions, the docs site (`website/app/docs/<topic>`), the marketing `website/`, the scaffold templates (`packages/cli/templates/` per-agent rule files), and `README.md` for a headline capability. Updating `AGENTS.md` alone reproduces the #488 gap (docs site left stale). Invoke the `webjs-doc-sync` skill to sync every applicable surface. Enforced by `.claude/hooks/require-docs-with-src.sh`, which BLOCKS a commit that stages public `packages/*/src` source with no doc surface alongside it (a genuinely internal refactor / CI / release / perf change with no behaviour change bypasses with `WEBJS_NO_DOC_GATE=1`).
135
+ 2. **Documentation, part of the definition of done (not optional).** A task is NOT done until EVERY doc surface its change touches is in sync: `AGENTS.md` + the skill at `.agents/skills/webjs/` (SKILL.md + references/) for new API surface, `CONVENTIONS.md` (and per-package `AGENTS.md`) for new conventions, the docs site (`website/app/(site)/docs/<topic>`), the marketing `website/`, the scaffold templates (`packages/cli/templates/` per-agent rule files), and `README.md` for a headline capability. Updating `AGENTS.md` alone reproduces the #488 gap (docs site left stale). Invoke the `webjs-doc-sync` skill to sync every applicable surface. Enforced by `.claude/hooks/require-docs-with-src.sh`, which BLOCKS a commit that stages public `packages/*/src` source with no doc surface alongside it (a genuinely internal refactor / CI / release / perf change with no behaviour change bypasses with `WEBJS_NO_DOC_GATE=1`).
136
136
  3. **Scaffold + skill sync (when a feature changes what apps should do).** The scaffold `webjs create` emits is a gallery index home + a root layout + db wiring, a densely-commented feature gallery (`gallery/**`, single-concept demos under `app/features/` plus the `app/examples/todo` app, shipped in every UI template) and the api backend-features showcase (`packages/cli/lib/api-gallery.js`), plus the one cross-agent skill at `packages/cli/templates/.agents/skills/webjs/` (SKILL.md + references). So when a WebJs feature is added or changed, ask: does the generator (`packages/cli/lib/{create,api-gallery}.js`), a gallery demo (`gallery/`), or the agent skill (`.agents/skills/webjs/SKILL.md` + its `references/`) need to move so a freshly scaffolded app and the skill teach the new reality? Verify by generating an app and running `generate + boot + webjs check` (the generators emit strings, so an escaping bug only shows in a freshly generated app). See `framework-dev.md`.
137
137
  4. **Convention validation.** Run `webjs check` and fix violations. Run it from INSIDE an app, never from the repo root: the root is a workspace, not an app, so the command refuses there with exit 1 rather than reporting the cross-app collisions no single runtime ever sees (#1301). In this repo that means `( cd gallery && npx webjs check )`, `( cd examples/blog && npx webjs check )` and `( cd website && npx webjs check )`. Run `webjs doctor` too when you touched an in-repo app (`gallery`, `examples/blog`, `website`): the required `conventions` CI job runs it over all three, and it fails on a hard toolchain check or on whatever that app's `webjs.doctor.gate` marks `error` (today `UNMARKED_ASSET_LINKS` in `website` and `examples/blog`), so a clean `webjs check` alone is not enough to predict that job (#1257). **`npm run ci` is the one-command form of all of this (#1471, #1474):** at the repo root it runs the `webjs.ci` list that runs everything the GitHub jobs run (per-app `webjs check` and `webjs doctor`, the source invariants, `npm test`, the app suites, browser, both e2e suites, the Bun proofs and matrix, the Postgres round-trip, the Docker build; `npm run ci -- --only Conventions` for one group), and inside `gallery` / `examples/blog` / `website` it runs that app's own list. Run it before pushing, from a real install (a linked worktree resolves bare `@webjsdev/*` specifiers into the primary checkout); `npm run ci -- --signoff` additionally posts a green `signoff` status on the pushed head, which the Actions-based protection does not require until `scripts/protect-main.sh` is run. In a linked worktree invoke the branch's CLI by path (`node ../packages/cli/bin/webjs.js ci` inside an app) because the hoisted bin resolves into the primary checkout; the root script already does. Details in `framework-dev.md` under "Local CI".
138
138
 
@@ -144,7 +144,7 @@ Always work on a feature branch; commit + push freely there. The only gate is me
144
144
  2. **Commit per logical unit** (one feature, fix, rename, doc rewrite) as soon as it is complete and tests pass (`webjs test`); 5+ unstaged files across concerns means you waited too long. Push after each commit.
145
145
  3. **Meaningful commit messages** (imperative, under 72 chars, body explains *why*). **No AI attribution** (no `Co-Authored-By: Claude`, `Generated by AI`).
146
146
  4. **PRs via `gh`, always.** "merge to main" is ALWAYS `gh pr create`, confirm, `scripts/ci-merge.sh <N>`, never a local `git merge` + push.
147
- 5. **GitHub Actions is the only CI.** `ci.yml` runs on every PR into `main` and every push to `main`; `main` requires its six job checks and no review. There is no local CI battery and no pre-push hook: run the layer you touched by hand while you work, and let Actions run the whole list on the PR. Merge with `scripts/ci-merge.sh <N>`: it waits for the required checks on the PR head and squash-merges with `--delete-branch` only when they are green. A release additionally needs `node scripts/release-gate.mjs`, which `scripts/release.sh` runs on the merged commit. Never merge with a bare `gh pr merge`.
147
+ 5. **GitHub Actions is the only CI.** `ci.yml` runs on every PR into `main` and every push to `main`; `main` requires its six job checks and no review. There is no local CI battery and no pre-push hook: run the layer you touched by hand while you work, and let Actions run the whole list on the PR. Merge with `scripts/ci-merge.sh <N>`: it waits for the required checks on the PR head and squash-merges with `--delete-branch` only when they are green. A release additionally needs `node scripts/release-gate.mjs` passing on the release PR's head before it merges; once CI passes on the merge commit, `release.yml` publishes it on its own (#1650). Never merge with a bare `gh pr merge`.
148
148
  6. **Never merge without permission.** Ask exactly "Ready to merge `<branch>` into `<target>`? After merging, should `<branch>` be deleted or kept?" and wait for both answers.
149
149
  7. **A PR merges only with green CI, never over a red check.** `scripts/ci-merge.sh` merges only when the required GitHub CI checks pass on the PR head. This holds however fast the surrounding work is moving, and overrides any "skip the CI wait" speed preference an agent carries from another project. Never `gh pr merge --admin` to route around a failing job: that is how `main` went red with several broken checks at once, and every later PR then inherits the failure. If `main` itself is red, fix `main` first. If a test is wrong, fix the test in its own commit and say why; never skip or weaken it. Claude Code enforces the `--admin` half via `.claude/hooks/block-admin-merge.sh` (test `test/hooks/block-admin-merge.test.mjs`, owner-approved emergency escape hatch `WEBJS_ALLOW_ADMIN_MERGE=1`).
150
150
 
@@ -197,7 +197,7 @@ Self-check: `page.ts` / `layout.ts` should NOT appear in the network tab or the
197
197
 
198
198
  ## Framework source: where to find it
199
199
 
200
- Plain JS with JSDoc lives in `node_modules/@webjsdev/` (`core/`, `server/`, `cli/`, `mcp/`, `intellisense/`, `ui/`); what you read is what runs. Starting points: SSR `@webjsdev/server/src/ssr.js`, client hydration `@webjsdev/core/src/render-client.js`, client router `@webjsdev/core/src/router-client.js`, convention rules `@webjsdev/server/src/check.js`. **Each of those four is now a BARREL** (#1365 split ten monoliths into sibling module directories, keeping every original path): the file re-exports, and the code lives one level down in the same-named directory (`server/src/ssr/`, `core/src/render-client/`, `core/src/router-client/`, `server/src/check/`). So the paths above still resolve and still import, but read the directory, not the barrel. The same holds for `server/src/dev.js`, `server/src/vendor.js`, `core/src/render-server.js`, `core/src/component.js`, `core/src/slot.js`, and `cli/lib/doctor.js`. For UI debugging use the Playwright MCP server; for live introspection the scaffold wires the read-only `@webjsdev/mcp` server (`npx @webjsdev/mcp`, also reachable as `webjs mcp`): `list_routes`, `list_actions`, `list_components`, `list_elision` (what the browser never downloads, and why each shipped module ships), `check`, `ui` (the `@webjsdev/ui` kit inventory + a component's helpers / paste-ready example / a11y header), plus a knowledge layer (docs / recipes / framework source). That knowledge layer reads the docs corpus installed in the app itself when there is one, so it is version-matched to the framework you are editing rather than to whenever the server was published, and `init` names the corpus it served and warns when a global install looks stale against the app.
200
+ Plain JS with JSDoc lives in `node_modules/@webjsdev/` (`core/`, `server/`, `cli/`, `mcp/`, `intellisense/`, `ui/`); what you read is what runs. Starting points: SSR `@webjsdev/server/src/ssr.js`, client hydration `@webjsdev/core/src/render-client.js`, client router `@webjsdev/core/src/router-client.js`, convention rules `@webjsdev/server/src/check.js`. **Each of those four is now a BARREL** (#1365 split ten monoliths into sibling module directories, keeping every original path): the file re-exports, and the code lives one level down in the same-named directory (`server/src/ssr/`, `core/src/render-client/`, `core/src/router-client/`, `server/src/check/`). So the paths above still resolve and still import, but read the directory, not the barrel. The same holds for `server/src/dev.js`, `server/src/vendor.js`, `core/src/render-server.js`, `core/src/component.js`, `core/src/slot.js`, and `cli/lib/doctor.js`. For UI debugging use the Playwright MCP server; for live introspection the scaffold wires the read-only `@webjsdev/mcp` server (`npx @webjsdev/mcp`, also reachable as `webjs mcp`): `list_routes`, `list_actions`, `list_components`, `list_elision` (what the browser never downloads, and why each shipped module ships), `check`, `ui` (the `@webjsdev/ui` kit inventory + a component's helpers / paste-ready example / a11y header, plus the blocks and charts: ready-made views you install and then edit, listed by category, and for one block its install command and file targets), plus a knowledge layer (docs / recipes / framework source). That knowledge layer reads the docs corpus installed in the app itself when there is one, so it is version-matched to the framework you are editing rather than to whenever the server was published, and `init` names the corpus it served and warns when a global install looks stale against the app.
201
201
 
202
202
  ---
203
203
 
@@ -268,7 +268,7 @@ The bare `@webjsdev/core` specifier resolves to a BROWSER bundle dropping server
268
268
 
269
269
  | Export | Purpose |
270
270
  |---|---|
271
- | `html` / `css` | Tagged template literals. `css` goes in `static styles`. |
271
+ | `html` / `css` / `svg` | Tagged template literals. `css` goes in `static styles`. `svg` (lit's tag) is for a fragment that sits INSIDE an `<svg>` element but is built in its own template (a `<rect>` per data point, a helper returning a `<path>`): the client parses an `html` template as HTML, so a `<rect>` there never paints. A whole `<svg>` element stays in `html`. The server renders both alike. |
272
272
  | `WebComponent` | Base class for interactive components. Called as `WebComponent({ ... })` it returns a typed base declaring reactive properties. |
273
273
  | `prop(type?, opts?)` | Declares one reactive property inside the `WebComponent({ ... })` factory with options (`reflect`, `state`, `attribute`, `default`, `converter`, `hasChanged`) and a narrowable TS type (`prop<Student>(Object)`). |
274
274
  | `render(v, el)` | Client-side render into a DOM element. |
@@ -291,7 +291,6 @@ lit-html parity: `repeat` (keyed lists), `unsafeHTML(str)` (trusted raw HTML, **
291
291
 
292
292
  ### `html` expression prefixes
293
293
 
294
- `<div>${x}</div>` text child; `class=${x}` attribute (escaped); `action=${importedAction}` on a `<form>` or `formaction=${importedAction}` on a submitter binds a server action (#1155, #1207, #1307: the function-valued attribute holes where the attribute is omitted/rewritten and a `__webjs_action` identity is emitted, plus `formmethod` / `formenctype` on a bound submitter so it needs no bound form around it; submitters must be actual submit controls and cannot carry their own `name`, `value`, `form`, or static `formaction` attributes; a quoted `action="${fn}"` / `formaction="${fn}"` or `action=${fn}` on un-supported tags is a render error, since stringifying a function would leak a server action's source); `@click=${fn}` event listener (client-only, drops at SSR; the handler is NOT bound to your component (`this` is a framework-internal event part, so a read yields `undefined` and a write lands on internals rather than throwing), so pass an arrow (`@click=${(e) => this.onClick(e)}`) or an arrow class field rather than a bare method); `.value=${v}` DOM property (round-trips through SSR on custom elements via `data-webjs-prop-*`, drops on native elements); `?disabled=${b}` boolean attribute. Event/property/boolean holes **must be unquoted** (invariant 4). Every hole is identical server and client except `@event` and `.prop` on native elements. A plain (unquoted, whole-value) attribute hole follows one rule in both renderers (#1573): `null` / `undefined` OMIT the attribute, `false` omits it too EXCEPT on an `aria-*` name, where it is written as `"false"`, `true` on an HTML boolean attribute (`checked`, `selected`, `disabled`, `required`, `readonly`, `hidden`, `multiple`, `open`, and the rest of the spec's list) writes the empty value exactly like `?attr=${true}` (#1579), and anything else is `String(value)`. So `<option selected=${i === 0}>` and `<input type="radio" checked=${isDefault}>` select only the intended one; before 0.7.63 the server served `selected="false"`, which HTML reads as SELECTED. So `aria-current=${active ? 'page' : null}` is the idiom for a conditional attribute, and `aria-expanded=${open}` serves `"true"` / `"false"` from both renderers, which matters because for a tri-state ARIA attribute `"false"` and absent are NOT the same thing. A quoted or mixed hole (`title="${x}"`, `class="a ${x}"`) keeps the attribute the author wrote statically and reads a nullish piece as empty text. Use `?attr=${bool}` for a genuine boolean attribute. (Before #1573 the server stringified `null` to `attr=""` and `false` to `attr="false"` while the client removed both, which is why some kit components still branch their templates.)
295
294
 
296
295
  ---
297
296
 
@@ -316,15 +315,15 @@ class MyThing extends WebComponent({
316
315
  MyThing.register('my-thing');
317
316
  ```
318
317
 
319
- **Signals are the default state primitive.** Import `signal` / `computed` from `@webjsdev/core`, read with `signal.get()` inside `render()`, and the built-in `SignalWatcher` re-renders on change. Module-scope signals share state across components and survive navigations; instance signals (constructor) are component-local.
318
+ **Signals.** Import `signal` / `computed` from `@webjsdev/core`, read with `signal.get()` inside `render()` (or any synchronous update hook: `willUpdate`, `updated`, `firstUpdated`), and the built-in `SignalWatcher` re-renders on change. Module-scope signals share state across components and survive navigations; instance signals are component-local. A signal is written with `.set()`, never assigned (`no-signal-field-assignment` flags that). For a component's own state a `state: true` prop works too, exactly as in lit.
320
319
 
321
- **Reactive properties ride HTML attributes, reflect to them, or arrive via SSR hydration.** They are declared ONLY by passing the property shape into the base-class factory `WebComponent({ ... })`. There is no `static properties` block and no `declare` line. The bare form takes a type constructor (`count: Number`, `label: String`, `open: Boolean`); the `prop()` helper carries options (`count: prop(Number, { reflect: true })`, `name: prop(String, { attribute: 'show-close-button' })`, internal state `prop({ state: true })` with no type), and narrows the TS type (`student: prop<Student>(Object)`, `mode: prop<'a' | 'b'>(String, { reflect: true })`). The types flow automatically to `this.<prop>`. Set defaults in the constructor after `super()` (a declarative `default` option also exists for lit-parity, a function default running per instance for a fresh object / array, but the constructor is the recommended way). NEVER use a class-field declaration OR initializer (e.g., `count = 0` or `todos!: Todo[]`), which compiles to define an own property on the instance and clobbers the reactive accessor (caught by `reactive-props-no-class-field`). Declaring your own `static properties` THROWS at runtime and is flagged by `no-static-properties`. Declare an array-typed prop with the `Array` constructor, not `Object` (`items: prop<Tag[]>(Array)`): the two share one JSON converter so neither crashes, but `Array` states the prop's shape and `array-prop-uses-array-type` flags the `Object` form. Property options: `type` (default `String`), `reflect`, `state`, `hasChanged`, `converter`, `default`, `attribute`. A `converter.fromAttribute` runs on BOTH attribute readers (the SSR one and the client one) through one shared implementation, ahead of type coercion, so it executes server-side and must not touch browser globals, and a throw from it is not caught on either side. A source attribute is read identically by the SSR reader and the browser, because both resolve it against the same name list `observedAttributes` is built from: it is matched by its LOWERCASED name, a camelCase prop answers to its kebab name AND its lowercased name (lit's default), so `quizId=`, `quizid=` and `quiz-id=` all reach `quizId` (prefer kebab-case), a prop that renames its attribute answers to the new name ONLY, a `state: true` prop is skipped entirely, a name matching no declared property is ignored, and HTML character references are decoded before the value is coerced (including the legacy semicolon-less forms, wherever a browser decodes them). A `reflect: true` property whose value has no attribute representation REMOVES its attribute and warns rather than writing one, which covers a function (or an array carrying one, whose source would otherwise ship to every visitor) and a JSON-typed value `JSON.stringify` cannot serialize (a cycle, a `BigInt`, a throwing `toJSON`); a value merely CARRYING a function still reflects whole, since `JSON.stringify` drops it to `null` without losing the rest.
320
+ **Reactive properties ride HTML attributes, reflect to them, or arrive via SSR hydration.** They are declared ONLY by passing the property shape into the base-class factory `WebComponent({ ... })`. There is no `static properties` block and no `declare` line. The bare form takes a type constructor (`count: Number`, `label: String`, `open: Boolean`); the `prop()` helper carries options (`count: prop(Number, { reflect: true })`, `name: prop(String, { attribute: 'show-close-button' })`, internal state `prop<boolean>({ state: true })`, which is `unknown` without the type argument), and narrows the TS type (`student: prop<Student>(Object)`, `mode: prop<'a' | 'b'>(String, { reflect: true })`). The types flow automatically to `this.<prop>`. Set defaults in the constructor after `super()` (a declarative `default` option also exists for lit-parity, a function default running per instance for a fresh object / array, but the constructor is the recommended way). NEVER use a class-field declaration OR initializer (e.g., `count = 0` or `todos!: Todo[]`), which compiles to define an own property on the instance and clobbers the reactive accessor (caught by `reactive-props-no-class-field`). Declaring your own `static properties` THROWS at runtime and is flagged by `no-static-properties`. Declare an array-typed prop with the `Array` constructor, not `Object` (`items: prop<Tag[]>(Array)`): the two share one JSON converter so neither crashes, but `Array` states the prop's shape and `array-prop-uses-array-type` flags the `Object` form. Property options: `type` (default `String`), `reflect`, `state`, `hasChanged`, `converter`, `default`, `attribute`. `state: true` is lit's internal state (no attribute, not reflected, reassign to re-render, reported in `changedProperties`), and `attribute: false` is lit's public property-only input (no attribute, set with `.prop=${…}`). A `converter.fromAttribute` runs on BOTH attribute readers (the SSR one and the client one) through one shared implementation, ahead of type coercion, so it executes server-side and must not touch browser globals, and a throw from it is not caught on either side. A source attribute is read identically by the SSR reader and the browser, because both resolve it against the same name list `observedAttributes` is built from: it is matched by its LOWERCASED name, a camelCase prop answers to its kebab name AND its lowercased name (lit's default), so `quizId=`, `quizid=` and `quiz-id=` all reach `quizId` (prefer kebab-case), a prop that renames its attribute answers to the new name ONLY, a `state: true` or `attribute: false` prop is skipped entirely, a name matching no declared property is ignored, and HTML character references are decoded before the value is coerced (including the legacy semicolon-less forms, wherever a browser decodes them). A `reflect: true` property whose value has no attribute representation REMOVES its attribute and warns rather than writing one, which covers a function (or an array carrying one, whose source would otherwise ship to every visitor) and a JSON-typed value `JSON.stringify` cannot serialize (a cycle, a `BigInt`, a throwing `toJSON`); a value merely CARRYING a function still reflects whole, since `JSON.stringify` drops it to `null` without losing the rest.
322
321
 
323
- **Lifecycle (lit-aligned), in order:** `shouldUpdate`, `willUpdate`, controllers' `hostUpdate()`, `update` (calls `render()` + commits), controllers' `hostUpdated()`, `firstUpdated`, `updated`, `updateComplete`, each receiving a `changedProperties` Map. **SSR runs only the constructor, attribute application, the pre-render hooks (`willUpdate` / `hostUpdate`), `reflect: true` reflection, and `render()`; it does NOT call `connectedCallback`, `firstUpdated`, `updated`, or any browser-only hook.** So defaults for first paint go in the constructor; browser-only data (localStorage, viewport, `navigator.*`) goes in `connectedCallback` writing a signal; server-known data arrives via the page function. Never ship a placeholder first paint that fetches in `connectedCallback`. A browser-only global in the constructor/`render()` throws at SSR (flagged by `no-browser-globals-in-render`; attribute methods and `closest()` are shimmed).
322
+ **Lifecycle (lit-aligned), in order:** `shouldUpdate`, `willUpdate`, controllers' `hostUpdate()`, `update` (calls `render()` + commits), controllers' `hostUpdated()`, `firstUpdated`, `updated`, `updateComplete`, each receiving a `changedProperties` Map. **SSR runs only the constructor, attribute application, the pre-render hooks (`willUpdate` / `hostUpdate`), `reflect: true` reflection, and `render()`; it does NOT call `connectedCallback`, `firstUpdated`, `updated`, or any browser-only hook.** The host's attributes are served as the element left them after that pass, as lit-ssr does: one it set is added, one it changed carries its new value, one it removed is dropped, and one it left alone keeps its source text byte for byte (a `.prop` binding's `data-webjs-prop-*` carrier is always kept). So a component that moves its host's class onto the element it renders is served with one box, not two. So defaults for first paint go in the constructor; browser-only data (localStorage, viewport, `navigator.*`) goes in `connectedCallback` writing a signal; server-known data arrives via the page function. Never ship a placeholder first paint that fetches in `connectedCallback`. A browser-only global in the constructor/`render()` throws at SSR (flagged by `no-browser-globals-in-render`; attribute methods and `closest()` are shimmed).
324
323
 
325
- **Async render (`async render()`), bare-await data fetch (#469).** A component may write `async render() { const u = await getUser(this.id); return html\`<h3>${u.name}</h3>\`; }`. Writing `await` makes the function async by JS rule, and every render path awaits a promise-returning `render()` automatically (no flag). This co-locates the fetch in the leaf component (no prop-drilling). The model is decoupled into three separate concerns. (1) **SSR always blocks**, so the resolved DATA is in the first paint with no fallback markup (PE-safe, JS-off reads it). (2) **The client re-fetch default is stale-while-revalidate**: when a prop / dependency change re-runs `async render()`, the current content stays until the new render resolves (no blank, no flash). (3) **`renderFallback()` is the OPTIONAL re-fetch loading UI**, a prop-aware method shown ONLY during a client re-fetch, NEVER on the first paint, and it does NOT trigger SSR streaming. **Errors are isolated per component by default** (no user code): a thrown `await getData()` renders a component-scoped error state while siblings render, and `renderError()` optionally customizes it (dev surfaces the message, prod stays silent). `getData()` is already isomorphic (a `'use server'` action is the real function during SSR and an RPC stub on the client), so the same line works both sides. Use `async render()` for request-time-known SERVER data that should be in the first paint; keep `Task` / signals for genuinely client-only data (a `Task` shows its pending state at SSR, losing first-paint data). A **bare** async-render component (an `async render()` with no other client signal, light DOM) is **elided** like any display-only component (#474): its SSR'd HTML is the complete output, so the framework drops the module AND the redundant on-hydration re-fetch. It SHIPS only when it also carries an independent signal (an `@event`, a non-`state` reactive prop, a signal / reactive import, a lifecycle hook including `renderFallback()`, the dynamic slot READ surface (`slotchange` / `assignedNodes` / `assignedElements` / `assignedSlot`; merely RENDERING a `<slot>` does not ship, since the SSR output carries the placed children), `static shadow = true`, `static interactive = true`, cross-module observation, or a transitively-reachable interactive child). Two carve-outs always ship: `static shadow = true` (Declarative Shadow DOM attaches only during HTML parsing, so a streamed or soft-navigated shadow component needs its module to re-run `attachShadow`) and `static interactive = true` (the explicit author override that forces a ship when the analyser cannot see a component's interactivity statically: an OBSERVER that computes the tag it waits for, a `:defined` rule in an external stylesheet outside the module graph, or a consumer reaching the element through a string selector; a component's OWN registration tag must be a literal per invariant 3, and a computed one is invisible to the scanner, so it gets no verdict and the override cannot rescue it). **For SLOW data where blocking the first byte hurts, wrap the region in `<webjs-suspense .fallback=${html\`…\`}>` to STREAM it** (the fallback flushes on the first byte, the data streams in; multiple boundaries fetch concurrently). This is the only way to show a first-paint fallback, a deliberate choice for slow regions, and it streams progressively on soft navigation too. A throwing component inside a boundary is isolated (renders its error state, siblings stream). **The on-hydration re-fetch is itself eliminated by SSR action seeding (#472):** each `'use server'` action result invoked during a (non-streamed) SSR render is serialized into the page, and the generated RPC stub reads that seed on its first client call, so a shipping async component does NOT re-issue the RPC on hydration (a later refetch / arg-change still goes to the network). Keyed by action-hash + fn + serialized args, consume-once, fail-open (a miss degrades to a normal RPC). A hit returns the value the SSR render that produced THIS page computed for exactly that key, so it cannot disagree with the HTML on screen (a page navigation evicts whatever the outgoing page left unconsumed, in the DOM and in the store, so a departed render's value can never be served); on an HTML-cached page (#241) the seed rides inside the cached bytes and is as fresh as they are. The one shape where a hit can differ from the paint is an action returning a DIFFERENT result for the SAME arguments twice in one render (the seed carries the last, the first component painted the first), which dev warns about once per action function. Captured via a transparent server-side `'use server'` facade (no source transform, no build step; the browser source tab and on-disk files are unchanged), default on, opt out with `"webjs": { "seed": false }` or `WEBJS_SEED=0`. **A miss is otherwise invisible, so dev makes it observable (#1309):** every dev page response carries `X-Webjs-Seed` (`off` / `html-cache` / `collected=<m>, emitted=<n>` / `collected=<m>, emitted=0, streamed`), folded into the access-log line as a `seed` field, and the browser logs ONE warning per page view when a hydration call missed AND the cause is provable (a streamed page, a serializer drop, or seeds present but unmatched), staying silent otherwise, including on a page that emitted no seeds, where a miss is not evidence of a defect because a mutation / `Task` / `connectedCallback` call routes through the same lookup and could never be seeded. `seedStats()` from `@webjsdev/core` exposes the counters. Nothing reaches production: the client's dev gate is a server-stamped `data-webjs-dev` marker on the seed block, never `process.env.NODE_ENV`, which esbuild folds to a constant in the built core bundle. See `references/data-and-actions.md`.
324
+ **Async render (`async render()`), bare-await data fetch (#469).** A component may write `async render() { const u = await getUser(this.id); return html\`<h3>${u.name}</h3>\`; }`. Writing `await` makes the function async by JS rule, and every render path awaits a promise-returning `render()` automatically (no flag). This co-locates the fetch in the leaf component (no prop-drilling). The model is decoupled into three separate concerns. (1) **SSR always blocks**, so the resolved DATA is in the first paint with no fallback markup (PE-safe, JS-off reads it). (2) **The client re-fetch default is stale-while-revalidate**: when a prop / dependency change re-runs `async render()`, the current content stays until the new render resolves (no blank, no flash). (3) **`renderFallback()` is the OPTIONAL re-fetch loading UI**, a prop-aware method shown ONLY during a client re-fetch, NEVER on the first paint, and it does NOT trigger SSR streaming. **Errors are isolated per component by default** (no user code): a thrown `await getData()` renders a component-scoped error state while siblings render, and `renderError()` optionally customizes it (dev surfaces the message, prod stays silent). `getData()` is already isomorphic (a `'use server'` action is the real function during SSR and an RPC stub on the client), so the same line works both sides. Use `async render()` for request-time-known SERVER data that should be in the first paint; keep `Task` / signals for genuinely client-only data (a `Task` shows its pending state at SSR, losing first-paint data). A **bare** async-render component (an `async render()` with no other client signal, light DOM) is **elided** like any display-only component (#474): its SSR'd HTML is the complete output, so the framework drops the module AND the redundant on-hydration re-fetch. It SHIPS only when it also carries an independent signal (an `@event`, a declared reactive prop, a signal / reactive import, a lifecycle hook including `renderFallback()`, the dynamic slot READ surface (`slotchange` / `assignedNodes` / `assignedElements` / `assignedSlot`; merely RENDERING a `<slot>` does not ship, since the SSR output carries the placed children), `static shadow = true`, `static interactive = true`, cross-module observation, or a transitively-reachable interactive child). Two carve-outs always ship: `static shadow = true` (Declarative Shadow DOM attaches only during HTML parsing, so a streamed or soft-navigated shadow component needs its module to re-run `attachShadow`) and `static interactive = true` (the explicit author override that forces a ship when the analyser cannot see a component's interactivity statically: an OBSERVER that computes the tag it waits for, a `:defined` rule in an external stylesheet outside the module graph, or a consumer reaching the element through a string selector; a component's OWN registration tag must be a literal per invariant 3, and a computed one is invisible to the scanner, so it gets no verdict and the override cannot rescue it). **For SLOW data where blocking the first byte hurts, wrap the region in `<webjs-suspense .fallback=${html\`…\`}>` to STREAM it** (the fallback flushes on the first byte, the data streams in; multiple boundaries fetch concurrently). This is the only way to show a first-paint fallback, a deliberate choice for slow regions, and it streams progressively on soft navigation too. A throwing component inside a boundary is isolated (renders its error state, siblings stream). **The on-hydration re-fetch is itself eliminated by SSR action seeding (#472):** each `'use server'` action result invoked during a (non-streamed) SSR render is serialized into the page, and the generated RPC stub reads that seed on its first client call, so a shipping async component does NOT re-issue the RPC on hydration (a later refetch / arg-change still goes to the network). Keyed by action-hash + fn + serialized args, consume-once, fail-open (a miss degrades to a normal RPC). A hit returns the value the SSR render that produced THIS page computed for exactly that key, so it cannot disagree with the HTML on screen (a page navigation evicts whatever the outgoing page left unconsumed, in the DOM and in the store, so a departed render's value can never be served); on an HTML-cached page (#241) the seed rides inside the cached bytes and is as fresh as they are. The one shape where a hit can differ from the paint is an action returning a DIFFERENT result for the SAME arguments twice in one render (the seed carries the last, the first component painted the first), which dev warns about once per action function. Captured via a transparent server-side `'use server'` facade (no source transform, no build step; the browser source tab and on-disk files are unchanged), default on, opt out with `"webjs": { "seed": false }` or `WEBJS_SEED=0`. **A miss is otherwise invisible, so dev makes it observable (#1309):** every dev page response carries `X-Webjs-Seed` (`off` / `html-cache` / `collected=<m>, emitted=<n>` / `collected=<m>, emitted=0, streamed`), folded into the access-log line as a `seed` field, and the browser logs ONE warning per page view when a hydration call missed AND the cause is provable (a streamed page, a serializer drop, or seeds present but unmatched), staying silent otherwise, including on a page that emitted no seeds, where a miss is not evidence of a defect because a mutation / `Task` / `connectedCallback` call routes through the same lookup and could never be seeded. `seedStats()` from `@webjsdev/core` exposes the counters. Nothing reaches production: the client's dev gate is a server-stamped `data-webjs-dev` marker on the seed block, never `process.env.NODE_ENV`, which esbuild folds to a constant in the built core bundle. See `references/data-and-actions.md`.
326
325
 
327
- **Light DOM (default) vs Shadow DOM.** Light DOM applies global CSS and Tailwind directly (default; for Tailwind/global CSS + simple composition). Shadow DOM (`static shadow = true`) is for `static styles` scoped CSS and third-party isolation; `<slot>` works in either. **Light-DOM slots ARE the native DOM slot API (#1021, full shadow parity):** `<slot>` works identically in light and shadow DOM, so post-mount native writes are LIVE (`appendChild`, `insertBefore`, `removeChild`, `el.remove()`, `innerHTML`, `el.slot=` flips, `HTMLSlotElement.assign()`) and the reads (`assignedNodes` / `assignedElements` / `{flatten}` / `assignedSlot` / `slotchange`, with native async-coalesced timing) match. Flip `static shadow` and nothing else changes; there is NO WebJs-specific slot API. The one write that does NOT flip is `assign()`: the light-DOM version is an extension (element-bound overlay alongside name matching), while native shadow `assign()` needs `slotAssignment: 'manual'`, which WebJs does not set, so avoid `assign()` in a component meant to flip modes. A FORWARDED slot (a template forwarding `<slot>` into a nested component) projects its content on the client and through hydration (#1023): the renderer stamps each slot with its template owner (`SLOT_OWNER`, carried across SSR as `data-wj-slot-owner`) so it routes to the OUTER host that rendered it, and a layout's `${children}` inside a slotted shell keeps its named slots in sync across a soft-nav swap (#1024, the swap resyncs every own slot of the enclosing host). Four inherent gaps, all a consequence of light DOM having no shadow boundary: structural host reads (`host.children` / the `innerHTML` getter show the rendered template, not the authored children, so read slotted content with `assignedNodes()`), `assignedChild.parentNode` is the `<slot>`, `::slotted()` CSS (style slotted content with normal selectors / Tailwind), and initial-projection lifecycle timing (`firstUpdated` sees the `<slot>` element with EMPTY `assignedNodes()`, the projection lands one microtask later; read assigned content from `slotchange` or after a microtask). Live writes need the component's JS on the page, so a display-only slotted wrapper elides (its writes are inert like anything elided; force a ship with `static interactive = true` for an imperative consumer the analyser cannot see). A light-DOM component authoring custom CSS MUST prefix every class selector with its tag name (invariant 7); prefer Tailwind. **Light-DOM component hosts default to `display: block`**: a custom element is `display: inline` in plain CSS, so the framework marks every LIGHT-DOM host `data-wj-host` and injects one head rule in a low-priority cascade layer, `@layer webjs-host { :where([data-wj-host]) { display: block } }`, so a container component does not collapse; the layer keeps it overridable by any author style INCLUDING Tailwind utilities (`class="flex"`/`grid`/`hidden` win, because their layer is ordered after `webjs-host`), a `[hidden]` carve-out keeps `?hidden` working, and an inline light component opts out with `my-tag { display: inline }`. **Shadow-DOM hosts are NOT marked** (a document rule would override the shadow tree's `:host`), so a shadow component sets its host display via `:host { display: block }` in `static styles` (respected because the host is unmarked; set it for a shadow block container). See the even-grid / no-reflow layout recipes in `references/styling.md`. **Never interpolate into a component's `<style>` or `<script>` body** (`html\`<style>${css}</style>\``): the server emits it but the client drops the raw-text hole, so it paints at SSR then wipes to empty on hydrate. Use `static styles` or Tailwind instead (flagged by `no-interpolation-in-raw-text-element`). A page/layout, which never hydrates, may interpolate a `css` result into `<style>`. Install the `WebJs` VSCode extension (`packages/editors/vscode`, published as `WebJs.WebJs` on the VS Marketplace, and installed in Cursor / Antigravity / Windsurf by downloading its `.vsix` from that listing until an Open VSX listing exists) or `webjs.nvim` (`packages/editors/nvim`, via lazy.nvim) for template highlighting + editor intelligence with no Lit plugin, or add the standalone `@webjsdev/intellisense` to `tsconfig.json` `plugins` manually (JetBrains). Full deep-dive in `references/components.md` + `references/muscle-memory-gotchas.md`.
326
+ **Light DOM (default) vs Shadow DOM.** Light DOM applies global CSS and Tailwind directly (default; for Tailwind/global CSS + simple composition). Shadow DOM (`static shadow = true`) is for `static styles` scoped CSS and third-party isolation; `<slot>` works in either. **Light-DOM slots ARE the native DOM slot API (#1021, full shadow parity):** `<slot>` works identically in light and shadow DOM, so post-mount native writes are LIVE (`appendChild`, `insertBefore`, `removeChild`, `el.remove()`, `innerHTML`, `el.slot=` flips, `HTMLSlotElement.assign()`) and the reads (`assignedNodes` / `assignedElements` / `{flatten}` / `assignedSlot` / `slotchange`, with native async-coalesced timing) match. Flip `static shadow` and nothing else changes; there is NO WebJs-specific slot API. The one write that does NOT flip is `assign()`: the light-DOM version is an extension (element-bound overlay alongside name matching), while native shadow `assign()` needs `slotAssignment: 'manual'`, which WebJs does not set, so avoid `assign()` in a component meant to flip modes. A FORWARDED slot (a template forwarding `<slot>` into a nested component) projects its content on the client and through hydration (#1023): the renderer stamps each slot with its template owner (`SLOT_OWNER`, carried across SSR as `data-wj-slot-owner`) so it routes to the OUTER host that rendered it, and a layout's `${children}` inside a slotted shell keeps its named slots in sync across a soft-nav swap (#1024, the swap resyncs every own slot of the enclosing host). Four inherent gaps, all a consequence of light DOM having no shadow boundary: structural host reads (`host.children` / the `innerHTML` getter show the rendered template, not the authored children, so read slotted content with `assignedNodes()`), `assignedChild.parentNode` is the `<slot>`, `::slotted()` CSS (style slotted content with normal selectors / Tailwind), and initial-projection lifecycle timing on a client-only first mount (`firstUpdated` sees the `<slot>` element with EMPTY `assignedNodes()`, the projection lands one microtask later; read assigned content from `slotchange` or after a microtask). A host HYDRATED from SSR keeps its projected children in the document throughout (#1666), so they are already assigned at `firstUpdated`, and a projected iframe never navigates twice nor a nested component reconnects. Live writes need the component's JS on the page, so a display-only slotted wrapper elides (its writes are inert like anything elided; force a ship with `static interactive = true` for an imperative consumer the analyser cannot see). A light-DOM component authoring custom CSS MUST prefix every class selector with its tag name (invariant 7); prefer Tailwind. **Light-DOM component hosts default to `display: block`**: a custom element is `display: inline` in plain CSS, so the framework marks every LIGHT-DOM host `data-wj-host` and injects one head rule in a low-priority cascade layer, `@layer webjs-host { :where([data-wj-host]) { display: block } }`, so a container component does not collapse; the layer keeps it overridable by any author style INCLUDING Tailwind utilities (`class="flex"`/`grid`/`hidden` win, because their layer is ordered after `webjs-host`), a `[hidden]` carve-out keeps `?hidden` working, and an inline light component opts out with `my-tag { display: inline }`. **Shadow-DOM hosts are NOT marked** (a document rule would override the shadow tree's `:host`), so a shadow component sets its host display via `:host { display: block }` in `static styles` (respected because the host is unmarked; set it for a shadow block container). See the even-grid / no-reflow layout recipes in `references/styling.md`. **Never interpolate into a component's `<style>` or `<script>` body** (`html\`<style>${css}</style>\``): the server emits it but the client drops the raw-text hole, so it paints at SSR then wipes to empty on hydrate. Use `static styles` or Tailwind instead (flagged by `no-interpolation-in-raw-text-element`). A page/layout, which never hydrates, may interpolate a `css` result into `<style>`. Install the `WebJs` VSCode extension (`packages/editors/vscode`, published as `WebJs.WebJs` on the VS Marketplace, and installed in Cursor / Antigravity / Windsurf by downloading its `.vsix` from that listing until an Open VSX listing exists) or `webjs.nvim` (`packages/editors/nvim`, via lazy.nvim) for template highlighting + editor intelligence with no Lit plugin, or add the standalone `@webjsdev/intellisense` to `tsconfig.json` `plugins` manually (JetBrains). Full deep-dive in `references/components.md` + `references/muscle-memory-gotchas.md`.
328
327
 
329
328
  ---
330
329
 
@@ -369,7 +368,7 @@ Optional app-root file default-exporting a **schema object** (env-var names to a
369
368
 
370
369
  The server-only-utility row (`.server.ts`, no `'use server'`) is a runtime trap: its browser stub throws at module load, so a page / layout / component that ends up SHIPPING to the browser and transitively imports one crashes the moment the module loads, while `webjs typecheck` and the rest of `webjs check` pass. `webjs check`'s `no-server-import-in-browser-module` rule catches this statically by reusing the build's elision verdict (it only flags modules that genuinely ship; a display-only page the framework elides is fine, because the framework strips its server import). A `'use server'` action is exempt: its browser stub is a working RPC, which is the intended way to call the server from a shipping module. A TYPE-ONLY `import type { Row } from './x.server.ts'` is exempt too, because the TypeScript stripper erases it before it can reach the browser, so sharing a derived row type from a `.server.ts` into a shipping component is safe and is not flagged (#805).
371
370
 
372
- Server actions export named async functions whose args + returns round-trip through the serializer. **Importing from a client component IS the API** (rewritten to an RPC stub; never hand-write `fetch()`). **REST over HTTP is a `route.ts`** that imports and calls the action (optionally via the `route(action, opts?)` adapter from `@webjsdev/server`, which merges query + route params + JSON body into one input object and JSON-responds). **Input validation (#245)** is declared via the `export const validate` config export (read on the RPC boundary); a `route()` endpoint passes the same validator as its `{ validate }` option. The framework only CALLS the validator (ships no validation library) and reads its return (`{ success: true, data? }` runs the action, `{ success: false, fieldErrors }` returns a `422` without running the body, a THROW is a sanitized error, any other value is transformed input). The validator stays server-side and receives the action's first argument. Full reference in `references/data-and-actions.md`.
371
+ Server actions export named async functions whose args + returns round-trip through the serializer. **Importing from a client component IS the API** (rewritten to an RPC stub; never hand-write `fetch()`). **REST over HTTP is a `route.ts`** that imports and calls the action (optionally via the `route(action, opts?)` adapter from `@webjsdev/server`, which merges query + route params + JSON body into one input object and JSON-responds). **Input validation (#245)** is declared via the `export const validate` config export (read on the RPC boundary); a `route()` endpoint passes the same validator as its `{ validate }` option. The framework only CALLS the validator (ships no validation library) and reads its return (`{ success: true, data? }` runs the action, `{ success: false, fieldErrors }` returns a `422` without running the body, a THROW is a sanitized error, any other value is transformed input). The validator stays server-side and receives the action's first argument, which reads by property on every boundary (a form submission arrives as an object of its fields that also answers the `FormData` readers, #1664). Full reference in `references/data-and-actions.md`.
373
372
 
374
373
  **HTTP-verb actions via config exports (#488).** A `'use server'` action declares its HTTP semantics through RESERVED sibling exports the framework reads statically, the same way a page declares `export const revalidate`: `export const method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'` (absent = POST, so existing actions are unchanged), `export const cache = 60` (seconds, or `{ maxAge, swr, public }`, default `private`, where `swr` appends `stale-while-revalidate=<swr>` seconds so an expired response is still served instantly while the browser revalidates it in the background; **`public: true` SHARES the response across users keyed only by URL + args, so use it ONLY for data identical for every visitor, never for a session / per-user read, the same safety rule as a page's `export const revalidate`**), `export const tags = (id) => [\`user:${id}\`]` (a GET's cache tags), `export const invalidates = (id) => [...]` (a mutation's tags to evict), `export const validate = (input) => ...` (the boundary validator), and `export const middleware = [mw1, mw2]` (#490: per-action middleware, each `async (ctx, next) => result`; a middleware short-circuits by returning an `ActionResult` instead of calling `next()`, and accumulates context the action reads via `actionContext()` from `@webjsdev/server`, no signature change). The declared middleware runs automatically on the RPC boundary. On the `route.ts` boundary it applies when the `route()` adapter is given the action's MODULE NAMESPACE (`import * as postActions from '...'; export const POST = route(postActions)`), which reads the declared `middleware` + `validate`; the bare-function form (importing just the function, `import { createPost }` then `route(createPost)`) runs only what you pass in `route(fn, { middleware })`, because a function reference cannot reach its sibling config exports (#876). The function stays a plain `export async function`; **one function per file** (a configured file with more than one callable function is a `webjs check` error). The call site never changes (`await getUser(7)`); the verb only changes the transport: a **GET** rides args in the URL (`?a=`, with a POST fallback over a 4KB cap), is CSRF-exempt, and carries a weak ETag (answering `If-None-Match` with a 304); with a `cache` export it also carries `Cache-Control` + `X-Webjs-Tags`, and without one it is `no-store` (caching is opted into by `cache`, never granted by the verb); a **mutation** (POST/PUT/PATCH/DELETE) sends the rich body (DELETE rides the URL), is CSRF-protected, and on completion (the action did not throw) evicts its `invalidates` tags from the server `cache()` (`revalidateTags`) and reports them via `X-Webjs-Invalidate` so the client browser-cache coordinator revalidates a later read. A mismatched request method is a `405` + `Allow`. Why WebJs needs this and Next does not: WebJs has no RSC server/client split, so reads and writes both flow through the one action mechanism (Next's reads are Server Component fetches, so its actions stay POST-only). `validate` is a BOUNDARY concern (the RPC endpoint and a `route.ts`), not a direct server-to-server call. A public REST endpoint is a `route.ts` that imports and calls the action (optionally via the `route()` adapter). **Cancellation (#492):** an action reads the request's `AbortSignal` via `actionSignal()` (from `@webjsdev/server`) to stop work on a client disconnect / abort (a never-aborting signal outside an action keeps the line safe server-to-server); on the client, a superseded `async render()` automatically ABORTS the previous render's in-flight action fetch (not just drops it), via a per-render `AbortController` the stub binds each fetch to. **Streaming results (#489):** an action that RETURNS a `ReadableStream` / async iterable / async generator (any verb) streams its chunks over the single RPC response instead of buffering; the call site does `for await (const chunk of await streamTokens(8))` and each rich-serialized chunk arrives as it is yielded (back-pressure respected, the source generator cancelled on a client disconnect / superseded render). Detection is purely on the return value (no config export); a streamed result is never cached / ETagged / seeded (a mutation still emits `X-Webjs-Invalidate`). A mid-stream throw surfaces as an error from the iterable (the HTTP status is already 200), the author message in prod.
375
374
 
@@ -415,13 +414,13 @@ Derive the type at every boundary: a DB row from the schema (`typeof todos.$infe
415
414
 
416
415
  ## Styling: Tailwind-first
417
416
 
418
- **Tailwind is the strong default for pages AND light-DOM components.** The lit reflex to scope CSS in a shadow root with `static styles` is the habit to resist in light DOM. When a class bundle repeats, extract it into a helper returning an `` html`...` `` fragment (SSR-time), NOT a CSS class (no `@apply`); it lives in `modules/<feature>/utils/ui/` when one feature consumes it and `lib/utils/ui.ts` when the whole app does. Choose between a fragment and a display-only component on STRUCTURE, not bytes: a component is a tag in the DOM, so reach for the fragment where that extra wrapper cannot exist at all (a `<table>` child, which the parser foster-parents out per the HTML spec, and string output such as a `<webjs-stream>` payload) or where it lands wrong (a `<ul>` / `<dl>` / `<select>` child, which breaks `ul > li` and `select > option`, and a grid or flex child, which makes the wrapper the item), and prefer the component everywhere else. On cost they tie under a page (inert page, elided component) and both ship under an island, where the component additionally carries an element class, its registration, a per-instance upgrade, and the display-only components it renders. Reserve raw CSS for what utilities cannot express (design tokens / `@theme`, `@property` + `@keyframes`, scrollbar, `prefers-reduced-motion`, complex `color-mix()` / gradients); in light DOM the tag-prefix invariant (#7) still holds, and shadow-DOM components legitimately use `static styles = css\`\``. **Pin a header with `position: fixed`, never `position: sticky`** (a sticky header flickers on iOS WebKit during a client-router nav, #610, because the preserved header plus the scroll-to-top trips a WebKit sticky-repaint bug that the GPU-promotion hacks do NOT fix, so reserve the header height on the content with a `--header-height` offset). See `references/styling.md`.
417
+ **Tailwind is the strong default for pages AND light-DOM components.** The lit reflex to scope CSS in a shadow root with `static styles` is the habit to resist in light DOM. When a class bundle repeats, extract it into a helper returning an `` html`...` `` fragment (SSR-time), NOT a CSS class (no `@apply`); it lives in `modules/<feature>/utils/ui/` when one feature consumes it and `lib/utils/ui.ts` when the whole app does. Choose between a fragment and a display-only component on STRUCTURE, not bytes: a component is a tag in the DOM, so reach for the fragment where that extra wrapper cannot exist at all (a `<table>` child, which the parser foster-parents out per the HTML spec, and string output such as a `<webjs-stream>` payload) or where it lands wrong (a `<ul>` / `<dl>` / `<select>` child, which breaks `ul > li` and `select > option`, and a grid or flex child, which makes the wrapper the item), and prefer the component everywhere else. On cost they tie under a page (inert page, elided component) and both ship under an island, where the component additionally carries an element class, its registration, a per-instance upgrade, and the display-only components it renders. Reserve raw CSS for what utilities cannot express (design tokens / `@theme`, `@property` + `@keyframes`, scrollbar, `prefers-reduced-motion`, complex `color-mix()` / gradients); in light DOM the tag-prefix invariant (#7) still holds, and shadow-DOM components legitimately use `static styles = css\`\``. **Pin a header with `position: fixed`, never `position: sticky`** (a sticky header flickers on iOS WebKit during a client-router nav, #610, because the preserved header plus the scroll-to-top trips a WebKit sticky-repaint bug that the GPU-promotion hacks do NOT fix, so size the header with a `--header-h` token and pad the content by the same token, and never measure it with a `ResizeObserver`, which reads 0 from a header a client-router swap replaced, #1660). See `references/styling.md`.
419
418
 
420
419
  ---
421
420
 
422
421
  ## Client navigation: automatic, nothing to opt into
423
422
 
424
- The router auto-enables when `@webjsdev/core` loads (any page with a component), so there is nothing to opt INTO. An app that wants plain full-page (MPA) navigation can opt OUT app-wide with `{ "webjs": { "clientRouter": false } }` (#629), or per-moment at runtime with `disableClientRouter()`. SSR auto-emits KEYED boundary comment pairs around each layout's children AND the page itself (open `<!--wj:children:<segment>:<route-key>-->`, close `<!--/wj:children:<segment>-->`; the route-key is the resolved path with param values percent-encoded, #1015). The router strictly scans both DOMs (any truncated, mispaired, or duplicated boundary poisons the scan) and applies a two-tier swap with Next.js remount parity: a changed route-key REPLACES (remounts) at the PARENT of the shallowest changed boundary (the range that contains the changed layout's own markup, exact Next remount scope), an unchanged one MORPHS the deepest shared boundary in place (a searchParams-only nav preserves hydrated component state). A poisoned or disjoint scan degrades to a full page load, never a guessed recovery, so silent DOM corruption is structurally impossible; outer-layout DOM identity is preserved on every soft path. **Every degradation dispatches `webjs:navigation-fallback` on `document` in ALL environments** (detail `{ cause, href, willReload }`, not cancelable), so a full page load on a click is observable in production rather than silent (#1114). **A same-document fragment jump is the browser's on the CLICK** (#1437): a link whose path and query match the page it sits on is never intercepted (in every spelling, the bare `#` back-to-top included, though `href=""` carries no fragment and so is a normal navigation), and the popstate an in-page fragment CLICK produces is absorbed rather than re-navigated, so clicking `<a href="#section">` (or the bare `#` back-to-top idiom, or either one repeatedly) never re-fetches, never re-swaps, and leaves live DOM identity and hydrated state intact. The gate is PROVENANCE, the router marking the click it bowed out of, not a url comparison: a repeat click REPLACES its entry so it arrives with the url unchanged, and a Back between two entries that differ only by fragment can still need a re-render (the no-JS write path reaches that shape, since `getSubmitAction` prefers the raw `action` ATTRIBUTE, which carries no fragment, so a bound-submitter form declaring `action="/p"` pushes its 422 re-render at `/p` while the reader sits at `/p#sec`). So a popstate with no click behind it is left on the normal path, which means an ordinary Back or Forward between two fragment states still re-renders. Form submissions ride the same pipeline (`data-no-router` opts out). Wire bytes are minimized via the `X-Webjs-Have` header (`segment:route-key` entries, so a dynamic layout held for OTHER params is re-rendered rather than short-circuited; the server returns only the divergent fragment, served `private` so a shared cache can never store the reduced body and serve it to a full-page navigation, and additionally marked `Vary: X-Webjs-Have` for caches that honour it (#1140; `Vary` alone was not enough, since Cloudflare honours only `Accept-Encoding`)); scroll is restored on back/forward. **The link-prefetch cache is ANCHOR-VALIDATED** (#1114): a reduced fragment begins at the boundary the server short-circuited on, and on consume the router checks that boundary is still live with the same route-key. A root-anchored fragment therefore survives an unrelated navigation (still a cache hit), while one anchored deeper is discarded once that layout is gone, because applying it would share no boundary with the live DOM and force a full page load. The router also never prefetches the page it is already on (#1106): that request can never serve a later navigation and only occupies a capped cache slot. **The cache also carries a FRAME dimension (#1407):** a link driving a `<webjs-frame>` is prefetched with the `X-Webjs-Frame` header its click will send, so the warm entry is the subtree the swap needs and the click costs no round trip. The key is URL plus frame id, so a page fragment can never be applied into a frame region nor a frame subtree into a page swap, and a frame entry is validated by its `<webjs-frame id>` still being live rather than by an anchor (a subtree carries no boundary comment). The server marks the sliced response `X-Webjs-Frame: <id>`, so an unmarked answer to a framed request is a whole document (a streamed render, or an id absent from the output) and its body is discarded rather than stored, while the REFUSAL is memoed outside the fragment cache so the link re-asks about once per TTL rather than on every hover (that memo set is capped too, so a page with many distinct refused frame links can re-ask sooner) (only a deploy or an in-place `refreshPage` drops those early, never `revalidate()`, which is the post-mutation api); a framed link to the CURRENT url is a refresh and is not prefetched at all. A non-GET `<form>` that BINDS a server action (`action=${fn}`) is the no-JS write-path (with JS the router posts the same body to the same url and applies the response in place: a `422` swaps without reload, a `303` is followed via fetch). **The router ENCODES that body per the declared `enctype`** (#1307), resolved with native precedence (a submitter's `formenctype` over the form's, decided on PRESENCE rather than on the value being non-empty, #1322: a `formenctype=""` or `formmethod=""` overrides the form and then falls to its OWN invalid-value default, urlencoded and GET respectively, while `formtarget=""` is a plain string reflection with no enumerated states, so it overrides the form and means the current browsing context): `multipart/form-data` sends `FormData`, and `application/x-www-form-urlencoded`, the HTML default and therefore what a plain `<form method="post">` means, sends `URLSearchParams` (a `File` serializes as its name, as the platform does). It previously built `FormData` for everything, so an ordinary POST form sent a urlencoded body with JS off and a multipart body with JS on. A `text/plain` POST is the one encoding the server cannot parse, so the router declines it and lets the browser submit natively, which keeps both paths doing the same thing. A failed navigation recovers in place (a cancelable `webjs:navigation-error` event, else a minimal in-place alert), never a destructive full reload.
423
+ The router auto-enables when `@webjsdev/core` loads (any page with a component), so there is nothing to opt INTO. An app that wants plain full-page (MPA) navigation can opt OUT app-wide with `{ "webjs": { "clientRouter": false } }` (#629), or per-moment at runtime with `disableClientRouter()`. SSR auto-emits KEYED boundary comment pairs around each layout's children AND the page itself (open `<!--wj:children:<segment>:<route-key>-->`, close `<!--/wj:children:<segment>-->`; the route-key is the resolved path with param values percent-encoded, #1015). The router strictly scans both DOMs (any truncated, mispaired, or duplicated boundary poisons the scan) and applies a two-tier swap with Next.js remount parity: a changed route-key REPLACES (remounts) at the PARENT of the shallowest changed boundary (the range that contains the changed layout's own markup, exact Next remount scope), an unchanged one MORPHS the deepest shared boundary in place (a searchParams-only nav preserves hydrated component state). A poisoned or disjoint scan degrades to a full page load, never a guessed recovery, so silent DOM corruption is structurally impossible; outer-layout DOM identity is preserved on every soft path. **Every degradation dispatches `webjs:navigation-fallback` on `document` in ALL environments** (detail `{ cause, href, willReload }`, not cancelable), so a full page load on a click is observable in production rather than silent (#1114). **A same-document fragment jump is the browser's on the CLICK** (#1437): a link whose path and query match the page it sits on is never intercepted (in every spelling, the bare `#` back-to-top included, though `href=""` carries no fragment and so is a normal navigation), and the popstate an in-page fragment CLICK produces is absorbed rather than re-navigated, so clicking `<a href="#section">` (or the bare `#` back-to-top idiom, or either one repeatedly) never re-fetches, never re-swaps, and leaves live DOM identity and hydrated state intact. The gate is PROVENANCE, the router marking the click it bowed out of, not a url comparison: a repeat click REPLACES its entry so it arrives with the url unchanged, and a Back between two entries that differ only by fragment can still need a re-render (the no-JS write path reaches that shape, since `getSubmitAction` prefers the raw `action` ATTRIBUTE, which carries no fragment, so a bound-submitter form declaring `action="/p"` pushes its 422 re-render at `/p` while the reader sits at `/p#sec`). So a popstate with no click behind it is left on the normal path, which means an ordinary Back or Forward between two fragment states still re-renders. Form submissions ride the same pipeline (`data-no-router` opts out). Wire bytes are minimized via the `X-Webjs-Have` header (`segment:route-key` entries, so a dynamic layout held for OTHER params is re-rendered rather than short-circuited; the server returns only the divergent fragment, served `private` so a shared cache can never store the reduced body and serve it to a full-page navigation, and additionally marked `Vary: X-Webjs-Have` for caches that honour it (#1140; `Vary` alone was not enough, since Cloudflare honours only `Accept-Encoding`)); scroll is restored on back/forward. **The link-prefetch cache is ANCHOR-VALIDATED** (#1114): a reduced fragment begins at the boundary the server short-circuited on, and on consume the router checks that boundary is still live with the same route-key. A root-anchored fragment therefore survives an unrelated navigation (still a cache hit), while one anchored deeper is discarded once that layout is gone, because applying it would share no boundary with the live DOM and force a full page load. The router also never prefetches the page it is already on (#1106): that request can never serve a later navigation and only occupies a capped cache slot. **The cache also carries a FRAME dimension (#1407):** a link driving a `<webjs-frame>` is prefetched with the `X-Webjs-Frame` header its click will send, so the warm entry is the subtree the swap needs and the click costs no round trip. The key is URL plus frame id, so a page fragment can never be applied into a frame region nor a frame subtree into a page swap, and a frame entry is validated by its `<webjs-frame id>` still being live rather than by an anchor (a subtree carries no boundary comment). The server marks the sliced response `X-Webjs-Frame: <id>`, so an unmarked answer to a framed request is a whole document (a streamed render, or an id absent from the output) and its body is discarded rather than stored, while the REFUSAL is memoed outside the fragment cache so the link re-asks about once per TTL rather than on every hover (that memo set is capped too, so a page with many distinct refused frame links can re-ask sooner) (only a deploy or an in-place `refreshPage` drops those early, never `revalidate()`, which is the post-mutation api); a framed link to the CURRENT url is a refresh and is not prefetched at all. A non-GET `<form>` that BINDS a server action (`action=${fn}`) is the no-JS write-path (with JS the router posts the same body to the same url and applies the response in place: a `422` swaps without reload, a `303` is followed via fetch). **The router ENCODES that body per the declared `enctype`** (#1307), resolved with native precedence (a submitter's `formenctype` over the form's, decided on PRESENCE rather than on the value being non-empty, #1322: a `formenctype=""` or `formmethod=""` overrides the form and then falls to its OWN invalid-value default, urlencoded and GET respectively, while `formtarget=""` is a plain string reflection with no enumerated states, so it overrides the form and means the current browsing context): `multipart/form-data` sends `FormData`, and `application/x-www-form-urlencoded`, the HTML default and therefore what a plain `<form method="post">` means, sends `URLSearchParams` (a `File` serializes as its name, as the platform does). It previously built `FormData` for everything, so an ordinary POST form sent a urlencoded body with JS off and a multipart body with JS on. A `text/plain` POST is the one encoding the server cannot parse, so the router declines it and lets the browser submit natively, which keeps both paths doing the same thing. **A page's stylesheets load before it is swapped in (#1666):** a `<link rel="stylesheet">` the incoming page carries (head or body) that the live document lacks is loaded through a temporary `<head>` copy and awaited first (Turbo's 2s cap), held under `media="not all"` so it applies with the swap and never restyles the outgoing page (or a view transition's capture of it), then the copy is removed once the page's own link has loaded, so a layout or page that brings its own stylesheet paints styled on a soft navigation, and a page bringing none swaps at once. **A layout's stylesheet leaves with the layout (#1669):** the server hoists the `<link rel="stylesheet">` at the start of a nested layout's or page's output into `<head>` tagged `data-wj-style-owner="<segment>"`, and when a navigation REPLACES that segment's range and the incoming page does not link the same file, the router switches the sheet off (disabled, marked `data-wj-style-off`) inside the swap commit (on a replace or an in-place morph of that range, both of which re-render everything in it), switching it back on synchronously when a later page or a back / forward restore links it. A frame swap and the full-body fallback switch nothing off, and an untagged sheet is never touched, because a reduced head's absence is not a removal (#936). A sheet a layout and a page under it both link belongs to the layout while the layout is on screen, so it stays while the layout does. A failed navigation recovers in place (a cancelable `webjs:navigation-error` event, else a minimal in-place alert), never a destructive full reload.
425
424
 
426
425
  **A mutating form submission re-renders the layouts too (#1557).** A POST / PUT / PATCH / DELETE form the router submits sends no `X-Webjs-Have`, so the response (and the redirect `fetch` follows) renders the whole layout chain, and the swap then morphs the layout markup outside the page's range. So a header that reads the session cookie flips to "Sign out" right after a sign-in action, with no reload, and a hydrated layout component keeps its state. GET link navigation is unchanged. A mutation made over RPC (a component calling an action, then `navigate()`) is a GET navigation, so call `refreshPage('shell')` after it when the layout depends on what changed.
427
426
 
@@ -503,21 +502,21 @@ import { likePost } from '#actions/like-post.server.ts';
503
502
  class LikeButton extends WebComponent({ postId: prop(String) }) {
504
503
  // INSTANCE scope, one signal per element. A module-scope `signal()` is SHARED
505
504
  // by every instance (invariant 5), so a list of these would all flip together.
506
- private liked = signal(false);
505
+ readonly #liked = signal(false);
507
506
 
508
507
  private async toggle() {
509
- const next = !this.liked.get();
510
- return optimistic(this.liked, next, () => likePost(this.postId));
508
+ const next = !this.#liked.get();
509
+ return optimistic(this.#liked, next, () => likePost(this.postId));
511
510
  }
512
511
 
513
512
  render() {
514
- return html`<button @click=${() => this.toggle()}>${this.liked.get() ? 'Liked' : 'Like'}</button>`;
513
+ return html`<button @click=${() => this.toggle()}>${this.#liked.get() ? 'Liked' : 'Like'}</button>`;
515
514
  }
516
515
  }
517
516
  LikeButton.register('like-button');
518
517
  ```
519
518
 
520
- The call RETURNS the action's `ActionResult`, so a `{ success: false }` both rolls back the signal and comes back to you for its `error` / `fieldErrors`. Module scope is the right choice when the state genuinely IS app-wide (a theme, a cart count, a sidebar-open flag). Per-item state belongs on the instance. `liked` is a plain instance field rather than a reactive property, so the class-field ban does not apply to it.
519
+ The call RETURNS the action's `ActionResult`, so a `{ success: false }` both rolls back the signal and comes back to you for its `error` / `fieldErrors`. Module scope is the right choice when the state genuinely IS app-wide (a theme, a cart count, a sidebar-open flag). Per-item state belongs on the instance. `#liked` is a private field holding a signal rather than a reactive property, so the class-field ban does not apply to it. It is `readonly` because a signal field is written through `.set()`, never assigned (`no-signal-field-assignment`).
521
520
 
522
521
  ### When to skip optimistic UI
523
522
 
@@ -543,8 +542,8 @@ The call RETURNS the action's `ActionResult`, so a `{ success: false }` both rol
543
542
  1. **Server-only code goes in `.server.{js,ts}` files, `route.ts` handlers, or `middleware.ts`. Never in pages, layouts, or components.** The `.server.{js,ts}` extension is the path-level boundary (the file router refuses to serve the source); a `'use server'` directive additionally makes exports RPC-callable, else the file is a server-only utility whose browser import is a throw-at-load stub. Importing a DB driver (`pg`), `node:*`, or any server-only dep from a component or an `app/**` page / layout / loading / error / not-found file crashes the browser at module load.
544
543
  2. **Every `*.server.{js,ts}` file with `'use server'` exports must be `async` functions returning serializer-safe values.** Args and results round-trip via webjs's wire. Files without `'use server'` (server-only utilities) can export anything, including singletons.
545
544
  3. **Custom element tag names must contain a hyphen** (HTML spec). Pass the tag to `Class.register('tag-name')`, not a static field. Any short-string quote works: `'tag-name'`, `"tag-name"`, or `` `tag-name` `` (single-line, no interpolation).
546
- 4. **Event (`@`), property (`.`), boolean (`?`) holes in `html` must be unquoted**, e.g. `@click=${fn}`, never `@click="${fn}"`.
547
- 5. **Signals are the default state primitive.** Import `signal` / `computed` from `@webjsdev/core` and read via `signal.get()` inside `render()`; the built-in SignalWatcher tracks the reads and re-renders. Module-scope signals share state across components; instance-scope signals (constructor) are component-local. The factory `WebComponent({ ... })` is reserved for values riding an HTML attribute, reflected to one, or arriving via `.prop=${value}` SSR hydration. For fine-grained DOM swap use `${watch(signal)}` from `@webjsdev/core/directives`.
545
+ 4. **Event (`@`), property (`.`), boolean (`?`) bindings accept one expression, quoted or unquoted** (surrounding text or mixed expressions are invalid).
546
+ 5. **State works as in lit, alongside signals.** Import `signal` / `computed` from `@webjsdev/core` and read via `signal.get()` inside `render()` or a synchronous update hook; the built-in SignalWatcher tracks the reads and re-renders. Module-scope signals share state across components; instance signals are component-local and written with `.set()`, never assigned. A `state: true` prop is the lit-style alternative for a component's own state. The factory `WebComponent({ ... })` declares reactive properties, including lit's `state: true` (internal) and `attribute: false` (property-only). For fine-grained DOM swap use `${watch(signal)}` from `@webjsdev/core/directives`.
548
547
  6. **Page and layout default exports must be functions.** They return a value (usually `TemplateResult`). They do not call `render()` themselves.
549
548
  7. **Light-DOM components with custom CSS MUST prefix every class selector with their tag name.** Tailwind utilities are unique by construction, so prefer them.
550
549
  8. **Non-root layouts and pages MUST NOT** write `<!doctype>` / `<html>` / `<head>` / `<body>`. Only the root layout may.
@@ -553,7 +552,7 @@ The call RETURNS the action's `ActionResult`, so a `{ success: false }` both rol
553
552
 
554
553
  11. **No em-dashes (U+2014), no hyphen or semicolon used as pause-punctuation in prose, and no colon attached to a code-shaped LHS.** Banned as a pause: U+2014, a space-surrounded hyphen between words, a space-surrounded semicolon between words. Banned colon attachments: a colon-then-prose after `xyz()`, a `<my-tag>`, an `[expr]` subscript, or a `<code>foo()</code>` definition list (rephrase verb-led). Prefer a period, comma, a colon on a plain-noun LHS, parentheses, or a restructure. Plain hyphens stay fine in compound words, flags, filenames, ranges, and semicolons and colons stay fine inside code / TS / CSS and inside JSON SYNTAX. A JSON or front-matter `description`, `title`, or `displayName` VALUE is prose, not code, and is scanned like any other prose. The same hook also enforces brand casing with one simple rule: `WebJs` is a proper noun, so write it capitalized wherever it NAMES the project in prose, at a sentence start AND mid-sentence (`WebJs ships`, `Most WebJs apps`, `the WebJs serializer`). It stays lowercase `webjs` ONLY as a literal code token: a `webjs <subcommand>` CLI command (`webjs dev`, `webjs db migrate`), a `webjs.dev` domain, an `@webjsdev` package, a `"webjs"` config key, a `WEBJS_*` env var, the `webjsdev/webjs` org path, or anything inside a `` `code` `` span or fenced block. If you mean the literal config key or command in prose, wrap it in backticks. Enforced via `.claude/hooks/block-prose-punctuation.sh`, which scans only NEW content (you can still edit an existing line to fix a glyph or casing).
555
554
 
556
- 12. **A form that writes binds its action: `<form action=${importedAction}>`, and a form whose buttons run different actions binds each on its submitter, `<button formaction=${importedAction}>`** (#1155, #1207, #1307). Those are the two shapes the renderer reads, and every near-miss throws rather than producing a form that posts nowhere. The submitter identity rides the pressed button's own `name`/`value` pair (the one channel a browser submits for that button alone), so no `formaction` url is emitted, both entries reach the server, and the dispatcher takes the LAST `__webjs_action` in DOM order, which is the submitter's whenever one was pressed. **Refused on a form:** a quoted `action="${fn}"`, `action=${fn}` on any tag other than `<form>`, `method="get"` or an enctype the server cannot parse, a `.method` / `.enctype` / `.encoding` PROPERTY binding (a `.prop` drops at SSR and applies in the browser, so the form would submit differently with JS than without), a second `action` hole, a plain `action="/url"` alongside the bound hole (SSR keeps it and the client drops it), a whitespace-padded `method=" post "` (an enumerated attribute is matched against exact keywords, so a padded value falls to the invalid-value default and submits as a GET), and a function that is not a `'use server'` export. **Refused on a submitter:** a control that is not a submit control, an `<input type="image">` (it submits `name.x` / `name.y` coordinates, so the identity never arrives), an `<input type="submit">` (the identity has to occupy its `value`, which on that control is also its visible label, so it would render captioned with the action id and could never be labelled; a `<button>` has no such conflict), a submitter carrying its own `name`, `value`, `form`, or static `formaction`, a `.prop` spelling of any of those (`.name` / `.value` / `.formAction` / `.formMethod` / `.formEnctype` all REFLECT on a submitter, so the write is dropped at SSR and lands in the attribute in the browser), and a second `formaction` hole. **Refused on a BOUND submitter, as a same-element contradiction:** its own `formmethod` other than post, an unparseable `formenctype` such as `text/plain`, and `formmethod="dialog"` (which dismisses a `<dialog>` instead of submitting, so the bound action could never run). A PLAIN submitter's own `formmethod` / `formenctype` is NOT refused: native HTML says the submitter's override wins, the author typed it deliberately, and the form's action simply does not run, so the renderer honours it and the dev-time client guard reports at submit time when a submission holds an identity it cannot deliver. A plain `formaction="/url"` likewise retargets away from the bound action and is the author's business. WebJs supplies `method` and `enctype` (and, on a bound submitter, `formmethod` and `formenctype`) only where your template supplies neither, judged from the TEMPLATE rather than the rendered element: `?method=${false}` and `method=${null}` both emit nothing (#1573), so the method is supplied, while `method=${''}` emits `method=""` and is refused. An `encoding=` attribute is inert in HTML (only the `.encoding` PROPERTY aliases `enctype`), so both renderers ignore it. A page has no `action` export, so a bare `<form method="post">` is a `405`. **A bound submitter is SELF-SUFFICIENT and asks nothing of the form around it** (#1307). The renderer supplies submission attributes at the level where the action is BOUND, and never overrides what you wrote at that same level: a bound `<form>` gains `method="post"` plus `enctype`, and a bound `<button>` gains `formmethod="post"` plus `formenctype` ON THE BUTTON, which is what React does for a function `formAction`. So a per-button action works inside a bound form, an unbound form, a `method="get"` form, or a form with no method at all. That is why the refusal list above contains no rule about a submitter's NEIGHBOURS: the renderer refuses only a SAME-ELEMENT contradiction, which has no correct fallback, and never a cross-element rule, which always has one (whatever native HTML would do). It also removed a question neither renderer could answer honestly, since a COMPONENT renders its own template in a separate pass with no view of the host page and the client may reconcile a submitter whose form is not in the tree yet. One consequence: no `formaction` url is emitted (an empty one is a conformance error), so the submission targets whatever the FORM targets, and a form declaring `action="/x"` sends its buttons there. The action still runs if `/x` is a PAGE route, since the identity travels in the body; against a `route.ts` or another origin the identity is ignored and nothing runs, which the dev-time client guard reports at submit time.
555
+ 12. **A form that writes binds its action: `<form action=${importedAction}>`, and a form whose buttons run different actions binds each on its submitter, `<button formaction=${importedAction}>`** (#1155, #1207, #1307). Those are the two shapes the renderer reads, and every near-miss throws rather than producing a form that posts nowhere. The submitter identity rides the pressed button's own `name`/`value` pair (the one channel a browser submits for that button alone), so no `formaction` url is emitted, both entries reach the server, and the dispatcher takes the LAST `__webjs_action` in DOM order, which is the submitter's whenever one was pressed. **Refused on a form:** a quoted `action="${fn}"`, `action=${fn}` on any tag other than `<form>`, `method="get"` or an enctype the server cannot parse, a `.method` / `.enctype` / `.encoding` PROPERTY binding (a `.prop` drops at SSR and applies in the browser, so the form would submit differently with JS than without), a second `action` hole, a plain `action="/url"` alongside the bound hole (SSR keeps it and the client drops it), a whitespace-padded `method=" post "` (an enumerated attribute is matched against exact keywords, so a padded value falls to the invalid-value default and submits as a GET), and a function that is not a `'use server'` export. **Refused on a submitter:** a control that is not a submit control, an `<input type="image">` (it submits `name.x` / `name.y` coordinates, so the identity never arrives), an `<input type="submit">` (the identity has to occupy its `value`, which on that control is also its visible label, so it would render captioned with the action id and could never be labelled; a `<button>` has no such conflict), a submitter carrying its own `name`, `value`, `form`, or static `formaction`, a `.prop` spelling of any of those (`.name` / `.value` / `.formAction` / `.formMethod` / `.formEnctype` all REFLECT on a submitter, so the write is dropped at SSR and lands in the attribute in the browser), and a second `formaction` hole. **Refused on a BOUND submitter, as a same-element contradiction:** its own `formmethod` other than post, an unparseable `formenctype` such as `text/plain`, and `formmethod="dialog"` (which dismisses a `<dialog>` instead of submitting, so the bound action could never run). A PLAIN submitter's own `formmethod` / `formenctype` is NOT refused: native HTML says the submitter's override wins, the author typed it deliberately, and the form's action simply does not run, so the renderer honours it and the dev-time client guard reports at submit time when a submission holds an identity it cannot deliver. A plain `formaction="/url"` likewise retargets away from the bound action and is the author's business. WebJs supplies `method` and `enctype` (and, on a bound submitter, `formmethod` and `formenctype`) only where your template supplies neither, judged from the TEMPLATE rather than the rendered element: `?method=${false}` and `method=${nothing}` both emit nothing, so the method is supplied, while `method=${''}` emits `method=""` and is refused. An `encoding=` attribute is inert in HTML (only the `.encoding` PROPERTY aliases `enctype`), so both renderers ignore it. A page has no `action` export, so a bare `<form method="post">` is a `405`. **A bound submitter is SELF-SUFFICIENT and asks nothing of the form around it** (#1307). The renderer supplies submission attributes at the level where the action is BOUND, and never overrides what you wrote at that same level: a bound `<form>` gains `method="post"` plus `enctype`, and a bound `<button>` gains `formmethod="post"` plus `formenctype` ON THE BUTTON, which is what React does for a function `formAction`. So a per-button action works inside a bound form, an unbound form, a `method="get"` form, or a form with no method at all. That is why the refusal list above contains no rule about a submitter's NEIGHBOURS: the renderer refuses only a SAME-ELEMENT contradiction, which has no correct fallback, and never a cross-element rule, which always has one (whatever native HTML would do). It also removed a question neither renderer could answer honestly, since a COMPONENT renders its own template in a separate pass with no view of the host page and the client may reconcile a submitter whose form is not in the tree yet. One consequence: no `formaction` url is emitted (an empty one is a conformance error), so the submission targets whatever the FORM targets, and a form declaring `action="/x"` sends its buttons there. The action still runs if `/x` is a PAGE route, since the identity travels in the body; against a `route.ts` or another origin the identity is ignored and nothing runs, which the dev-time client guard reports at submit time.
557
556
 
558
557
  ---
559
558
 
@@ -578,16 +577,18 @@ webjs ci [-f|--fail-fast] [--only <title>]... [--json] [--signoff] # local
578
577
  webjs routes [--json] [--table] [--no-headers] # print the route table (path / owner file / methods, #975). Default tree; --json is byte-identical to the MCP list_routes tool; --no-headers drops the --table header for piping
579
578
  webjs elision [--json] [--verify] [--routes <paths>] # the elision verdict (#1308): every component module as elided or shipped (a shipped one naming the EVIDENCE that forced it and the module that did the forcing), every page/layout as inert / import-only / ships-whole, and every orphan class that gets no verdict at all (either no registration call, or a computed tag; the scanner matches only a literal one). --json is byte-identical to the MCP list_elision tool. --verify renders every static page route with elision on and off and diffs the observable SSR bytes (the framework's own differential guard, pointed at your app): exit 0 on parity, non-zero on a divergence OR on a corpus where nothing could be compared. It proves elision did not change the bytes you SERVE, NOT post-hydration behaviour (a wrongly dropped module is a dead click, not different bytes), so run your browser/e2e suite twice under WEBJS_ELIDE=1 / WEBJS_ELIDE=0 for that half. Dynamic routes are skipped by name; --routes adds real paths
580
579
  webjs mcp # read-only MCP: routes, actions (RPC hashes), components, elision (what the browser drops, and why each shipped module ships), check, ui kit
580
+ webjs status [path...] [--port N] [--json] [--timeout <s>] # wait for a running dev server to SETTLE after an edit (#1664: the change rebuilt and the app analysed again), request each path (the app root when none is given) plus every page with a recorded render error, and print every error (render, TS strip, rebuild, route handler, server action, middleware) with file, line and code frame; a render error stays listed across an unrelated rebuild until its page renders again; exit 1 on any problem, 2 when no dev server answers. Backed by the dev-only `GET /__webjs/dev/status?wait=<s>` (404 in production). Use it after each edit instead of `sleep` + `curl`
581
581
  webjs source <Export> [--pkg <name>] # one export's signature plus its doc comment from the installed @webjsdev/* (the typed declarations, the authored JSDoc as the fallback; overloads all print; a miss names the searched packages). The MCP `source` tool's `export` argument is the same lookup. Exit 1 on a miss
582
582
  webjs doctor [--json] [--strict] # project-health checklist (incl. a framework-resolve check that warns when @webjsdev/core can't be resolved from the app dir, the fresh-worktree-without-node_modules trap #954; a page/layout elision advisory PLUS the component-elision verdict, which warns only on an orphan (#1308); a `framework-links` check that warns when the `@webjsdev/core` entry is a symlink that DANGLES or resolves outside the checkout owning its `node_modules`, which is what an install inside a linked worktree leaves behind and what a plain resolve probe cannot see, #1442; a warning when a route module writes a `<link rel="stylesheet">` without `asset()`, #1095; a `WORKSPACE_OVERRIDES` warning when a workspace MEMBER's package.json carries `overrides` / `resolutions`, which npm and bun honour only at the workspace root, #1492); non-zero exit on a hard fail OR on a check the app gated `error`. --json emits `{ results, summary }` (results is the DoctorResult[], each carrying a stable code + its effective severity; summary counts pass/warn/fail/off), plus a third `configErrors` key on the one path where a rejected `webjs.doctor` config stops any check running; --strict additionally fails on every REMAINING warning (#975). Per-check severity is CONFIG, not a flag: `webjs.doctor.gate` maps a code to `off` / `warn` / `error` so CI gates a chosen subset (#1257)
583
583
  webjs audit [--json] # the dependency audit with a reviewable allowlist (#1492): runs `npm audit` or `bun audit` (nearest lockfile, so a workspace member uses the root's) and fails on any advisory at or above `webjs.audit.level` (default high) that `webjs.audit.ignore` does not list. Each ignore entry is `{ id, reason }`, the one place an accepted advisory is recorded; a malformed block exits 1 and an id the audit stopped reporting prints as stale. The scaffold's CI audit step runs it
584
584
  webjs types # generate .webjs/routes.d.ts (typed Route union + per-route params, #258)
585
+ webjs regenerate [--force] [--json] # build every webjs.dev.regenerate output that is not fresh and record it in .webjs/regenerate.json (#1672); the one way a build in dev.before / start.before / CI counts as fresh for the dev server and webjs doctor; exit 1 when a rule fails or does not write its output
585
586
  webjs version # print the installed @webjsdev/cli version (also: webjs --version / -v, #975)
586
587
  webjs help [command] # full usage banner, or per-command usage + Options + Examples (e.g. webjs help routes, #975). Flag forms: webjs --help / -h (banner), webjs <command> --help / -h (that command). typecheck/db/ui --help forward to their wrapped tool; an unknown topic exits 1
587
588
  webjs typecheck [tsc args...] # the project's own tsc --noEmit
588
589
  webjs create <name> [--template api] [--skip-ci] # --skip-ci omits the GitHub workflow (rails new parity, #1471); the local webjs.ci list and `ci` script always ship
589
590
  webjs db <generate|migrate|push|studio|seed|verb> [args] # wraps drizzle-kit by default (+ runs db/seed.server.ts). Bring your own ORM (#1468): a `"webjs": { "db": { "<verb>": "<command>" } }` block in package.json runs that shell command instead (node_modules/.bin on PATH, extra args appended), any key is a verb, an unmapped verb keeps its default, so `webjs db migrate` is one spelling across ORMs and the scaffolded start.before / Dockerfile / CI keep working after a swap
590
- webjs ui init | add <names...> | list | view <name> | diff | info | lint # lint is the opt-in design-system linter (#1478), configured by a `lint` block in components.json; no block reports nothing
591
+ webjs ui init | add <names...> | list [--type ui|block|chart|all] [--category <c>] [--json] | view <name> [--files-only] [--json] | diff | info | lint # add installs a component or a block (a ready-made view: its files land at their targets, imports rewritten to your aliases, never overwritten without --overwrite); lint is the opt-in design-system linter (#1478), configured by a `lint` block in components.json; no block reports nothing
591
592
  webjs vendor pin|unpin|list|audit|outdated|update [--from PROVIDER] # importmap pinning, .webjs/vendor/importmap.json
592
593
  ```
593
594
 
@@ -598,11 +599,11 @@ webjs vendor pin|unpin|list|audit|outdated|update [--from PROVIDER] # importma
598
599
  ## Environment, server config, caching, observability
599
600
 
600
601
  - **A taken port fails fast (#1527).** `webjs dev` and `webjs start` exit 98 with `port <n> is already in use by PID <pid> (<command>)` on Node and Bun alike, and the dev supervisor does not retry it. Bun's `Bun.serve` turns `SO_REUSEPORT` on by default outside its development mode, which let a second server bind silently and split the requests; the listener now passes `reusePort` explicitly, on only with `WEBJS_REUSE_PORT=1` (a deliberate multi-process start).
601
- - **Env vars.** `process.env.X` reads are server-only; `WEBJS_PUBLIC_`-prefixed names are exposed in the browser via an inline `<script>` (no build); `NODE_ENV` is defined both sides. See `references/built-ins.md`.
602
+ - **Env vars.** `process.env.X` reads are server-only; `WEBJS_PUBLIC_`-prefixed names are exposed in the browser via an inline `<script>` (no build); `NODE_ENV` is defined both sides. The public set is read ONCE, at the first render of a build (#1644), so set a `WEBJS_PUBLIC_*` value before boot (`.env`, the deploy environment, `env.js`), not by mutating `process.env` at runtime, which is not reflected until the next restart or dev rebuild. See `references/built-ins.md`.
602
603
  - **Dev source locations (`webjs.dev.sourceLocations: true` or `WEBJS_SOURCE_LOCATIONS=1`, #1499, #1504; the env var wins).** Under `webjs dev` only, every element opening tag in the app's `html` templates carries `data-webjs-src="<app-relative-file>:<line>"`, in SSR output and client renders alike, so a hosting tool can map a clicked element to its source line. A dev-only source transform (served modules plus an SSR load hook); `*.server.*` and `node_modules` are never touched, and production never sees it. See `references/built-ins.md`.
603
604
  - **Dev embed bridge (`webjs.dev.embedOrigins` or `WEBJS_EMBED_ORIGINS`, #1498, #1504; a set env var replaces the list).** Under `webjs dev` only, a comma-separated list of parent origins may frame the app (`X-Frame-Options` is dropped and `frame-ancestors` widened for them) and every document carries a nonce-signed inline script that posts `{ source: 'webjs-embed', type }` messages (`ready`, `navigate`, `console`, `error`, `network`, `server-error`, `select`) to the parent and takes `{ source: 'webjs-embed-host', type: 'navigate' | 'reload' | 'resume' | 'inspect' | 'hold' }` commands from it (`hold` pauses live reload during a build and applies one reload on release, #1532; a reload keeps the scroll position; `select` carries modifier keys). Unset adds zero bytes; production never sees it. Message shapes in `references/built-ins.md`.
604
605
  - **Dev live reload lets an idle host sleep (#1507).** The stream is silent between edits and open only while a tab is visible; for a host that counts an open request as activity, `webjs.dev.reloadIdle` (seconds, or `WEBJS_DEV_RELOAD_IDLE`) also closes it after that long with no edit and no interaction, reopening on the next one. Every reconnect re-checks the page against the server's state (#1516), so an edit whose reload signal was lost while the stream was being replaced still reloads. See `references/runtime.md`.
605
- - **The `package.json` `"webjs"` block.** Security headers (on by default, per-path `webjs.headers` overrides), CSP (opt-in nonce, `webjs.csp`), declarative `webjs.redirects` (#254), `webjs.trailingSlash` (#255), `webjs.basePath` (#256), ingress caps (`maxBodyBytes` / `maxMultipartBytes` / server timeouts), and dev/start task orchestration (`webjs.dev.before` / `webjs.dev.parallel` / `webjs.dev.regenerate` / `webjs.start.before`, #550 + #967, the orchestration `webjs dev`/`start` run so they match `npm run dev`/`start`; `regenerate` recompiles a stale served build output like `public/tailwind.css` ON REQUEST in dev, replacing a fragile `--watch`), the doctor severity gate (`webjs.doctor.gate`, #1257, mapping a stable doctor code to `off` / `warn` / `error` so CI fails on a chosen subset of project-health checks instead of on every warning), the local CI step list (`webjs.ci.steps`, #1471: the list `webjs ci` runs on a developer machine and the same list a cloud pipeline runs through `npm run ci`, so the two cannot drift; a string is a command, a `{ title, run, env }` object a titled command, a `{ title, steps, parallel }` group runs its steps N at a time with each step's output replayed whole, and a group nested in a parallel group takes one slot and runs sequentially, so it cannot declare `parallel` itself), the dependency-audit allowlist (`webjs.audit`, #1492: `level` plus `ignore: [{ id, reason }]`, read by `webjs audit`), and the `webjs db` verb map (`webjs.db`, #1468, bring your own ORM: `{ "migrate": "prisma migrate deploy" }` makes `webjs db migrate` run that command instead of drizzle-kit, so the spelling every deploy surface uses is stable across ORMs; Drizzle stays the default by omission and the scaffold emits no block). Type it with `WebjsConfig` + the JSON Schema. See `references/built-ins.md`.
606
+ - **The `package.json` `"webjs"` block.** Security headers (on by default, per-path `webjs.headers` overrides), CSP (opt-in nonce, `webjs.csp`), declarative `webjs.redirects` (#254), `webjs.trailingSlash` (#255), `webjs.basePath` (#256), ingress caps (`maxBodyBytes` / `maxMultipartBytes` / server timeouts), and dev/start task orchestration (`webjs.dev.before` / `webjs.dev.parallel` / `webjs.dev.regenerate` / `webjs.start.before`, #550 + #967, the orchestration `webjs dev`/`start` run so they match `npm run dev`/`start`; `regenerate` recompiles a stale served build output like `public/tailwind.css` ON REQUEST in dev, replacing a fragile `--watch`; fresh is a record of each build in `.webjs/regenerate.json`, the command plus fingerprints of its inputs and output, and `webjs regenerate` in `dev.before` / `start.before` / CI builds and records outside the dev server, #1672), the doctor severity gate (`webjs.doctor.gate`, #1257, mapping a stable doctor code to `off` / `warn` / `error` so CI fails on a chosen subset of project-health checks instead of on every warning), the local CI step list (`webjs.ci.steps`, #1471: the list `webjs ci` runs on a developer machine and the same list a cloud pipeline runs through `npm run ci`, so the two cannot drift; a string is a command, a `{ title, run, env }` object a titled command, a `{ title, steps, parallel }` group runs its steps N at a time with each step's output replayed whole, and a group nested in a parallel group takes one slot and runs sequentially, so it cannot declare `parallel` itself), the dependency-audit allowlist (`webjs.audit`, #1492: `level` plus `ignore: [{ id, reason }]`, read by `webjs audit`), and the `webjs db` verb map (`webjs.db`, #1468, bring your own ORM: `{ "migrate": "prisma migrate deploy" }` makes `webjs db migrate` run that command instead of drizzle-kit, so the spelling every deploy surface uses is stable across ORMs; Drizzle stays the default by omission and the scaffold emits no block). Type it with `WebjsConfig` + the JSON Schema. See `references/built-ins.md`.
606
607
  - **Caching + file storage** (`references/built-ins.md`). HTTP `Cache-Control`, the `cache()` query helper + `revalidateTag`, the server HTML response cache (`export const revalidate` + `revalidatePath`, #241), content-hash asset URLs (`?v=`, #243), conditional GET (ETag, #240), and `FileStore` + `diskStore` (streaming, traversal-safe, signed URLs, S3-pluggable, #247).
607
608
  - **Observability** (`references/built-ins.md`). Access log, `requestId()` + `X-Request-Id`, the `onError` APM hook, `GET /__webjs/version` (#239).
608
609
 
@@ -647,8 +648,8 @@ export default async function User({ params }: { params: { id: string } }) {
647
648
  ```ts
648
649
  // modules/feedback/actions/submit-feedback.server.ts
649
650
  'use server';
650
- export async function submitFeedback(formData: FormData) {
651
- const email = String(formData.get('email') || '').trim();
651
+ export async function submitFeedback(input: { email?: string }) {
652
+ const email = String(input.email || '').trim();
652
653
  if (!email.includes('@')) {
653
654
  return { success: false, fieldErrors: { email: 'Enter a valid email' }, values: { email } };
654
655
  }
@@ -669,7 +670,7 @@ export default function Feedback({ actionData }: { actionData?: { fieldErrors?:
669
670
  }
670
671
  ```
671
672
 
672
- Binding the action is the whole wiring: no `method`, no `enctype`, no adapter. Works with JS off; with JS the client router applies the 422 in place and follows the 303. On a failure `actionData.values` carries every submitted text field (the action's own `values` on top), and with JS the router also restores what was typed into any control the re-render did not explicitly set, so a forgotten refill no longer empties a field (#1581, opt out with `data-preserve-values="false"`).
673
+ Binding the action is the whole wiring: no `method`, no `enctype`, no adapter. The action receives the submitted fields as ONE object read by property (`input.email`), the same shape it gets over RPC, which also answers the `FormData` readers (`input.get('email')`); a name ending in `[]` arrives as an array and a file field is a real `File` (#1664). Works with JS off; with JS the client router applies the 422 in place and follows the 303. On a failure `actionData.values` carries every submitted text field (the action's own `values` on top), and with JS the router also restores what was typed into any control the re-render did not explicitly set, so a forgotten refill no longer empties a field (#1581, opt out with `data-preserve-values="false"`).
673
674
 
674
675
  ### Add a server action
675
676
 
@@ -699,3 +700,5 @@ Not in v1. Do not implement as part of other tasks:
699
700
  - **Vite-grade HMR with state preservation.** Custom elements only `define` once, so full reload is necessary; data reloads are near-instant via `fs.watch` to SSE. #1398 does not change this: it re-renders on the SERVER and swaps the result, and hot-swaps no module, so a component edit still reloads and `customElements.define` is still once-per-tag.
700
701
  - **React Server Components Flight.** Server actions + `Suspense` streaming cover the need.
701
702
  - **Edge-runtime bundling / full portability** (deployment guidance lives in the docs site at `/docs/deployment`), **i18n, image optimization** (layer libraries on top).
703
+
704
+ Component template bindings follow Lit JavaScript semantics; see `.agents/skills/webjs/references/components.md` for listeners, sentinels, attributes, and directive helpers.