@webjsdev/mcp 0.1.12 → 0.1.14

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
@@ -34,9 +34,11 @@ subcommand) delegates to this same server, so both routes run identical code.
34
34
  no verdict at all; identical to `webjs elision --json`),
35
35
  `check` (the structured `webjs check` violations). Each projects an existing
36
36
  `@webjsdev/server` data function and mutates nothing.
37
- - **Knowledge layer**: an `init` mental-model primer, a `docs` retrieval tool,
38
- MCP `resources` (the skill references + `SKILL.md` + `AGENTS.md` as `webjs-docs://*`),
39
- and `prompts` (the recipes as guided workflows).
37
+ - **Knowledge layer**: an `init` mental-model primer (which also names the docs
38
+ corpus it is serving, and warns when this server's copy looks stale against
39
+ the app's), a `docs` retrieval tool, MCP `resources` (the skill references +
40
+ `SKILL.md` + `AGENTS.md` as `webjs-docs://*`), and `prompts` (the recipes as
41
+ guided workflows).
40
42
  - **`source` tool**: reads the framework's own no-build source from
41
43
  `node_modules/@webjsdev/*/src` (read-only, traversal-guarded).
42
44
  - **`ui` tool**: the `@webjsdev/ui` kit inventory (no args) or one component's
@@ -44,7 +46,87 @@ subcommand) delegates to this same server, so both routes run identical code.
44
46
  `name`). Kit-scoped (not `appDir`-scoped); shares one projector with
45
47
  `webjsui view`.
46
48
 
47
- The docs corpus is bundled into the package at `prepack`, so `npx @webjsdev/mcp`
48
- is self-contained; in the monorepo it falls back to the live repo-root docs.
49
+ ## Which docs you get
50
+
51
+ The corpus is resolved in three rungs, highest first:
52
+
53
+ 1. `<appDir>/node_modules/@webjsdev/mcp/resources`, the app's own installed copy.
54
+ 2. `<pkg>/resources`, this server's bundled snapshot, so `npx @webjsdev/mcp` is
55
+ self-contained.
56
+ 3. Whatever the server resolves locally, which is the repo-root skill on the
57
+ monorepo dev path. This rung is an unconditional fallback rather than a
58
+ checkout probe, so a published install whose `resources/` bundle is missing
59
+ lands here too, with an empty corpus. That is why neither the corpus line nor
60
+ the warning asserts a checkout.
61
+
62
+ The app's copy wins because it is version-matched to the framework you are
63
+ editing, and so is the only corpus that can be correct about that app. This
64
+ matters most for a GLOBAL install (`npm i -g` / `bun add -g`), which otherwise
65
+ keeps serving the docs it was published with forever, contradicting the copy
66
+ sitting in your own `node_modules`. That is a real incident, not a hypothetical:
67
+ a server published one day before the client router stopped needing an explicit
68
+ `import '@webjsdev/core/client-router'` kept teaching that import for months, and
69
+ an agent following it wrote dead imports into three layouts, each of which the
70
+ elision analyser then correctly refused to strip.
71
+
72
+ `appDir` is a per-call argument, so the corpus follows it. `resources/list` and
73
+ `resources/read` carry no `appDir` in the MCP protocol and resolve from the
74
+ server's working directory, which is what a `tools/call` with no `appDir`
75
+ defaults to, so in the ordinary case every surface reads one corpus.
76
+
77
+ `init` names the corpus it served on its first line, and prepends a warning when
78
+ the app's installed `@webjsdev/mcp` is strictly newer than the running server's:
79
+
80
+ ```
81
+ Warning: this MCP server is @webjsdev/mcp@0.1.4, but this app has @webjsdev/mcp@0.1.12,
82
+ so the server may be stale. The docs below come from this app's own copy, so they
83
+ match it; update the server so its TOOLS match too. Update whichever copy runs this
84
+ server: a global install (npm i -g @webjsdev/mcp@latest, or bun add -g @webjsdev/mcp),
85
+ the package cache behind npx @webjsdev/mcp, @webjsdev/cli when the server is started
86
+ as webjs mcp, or the checkout it runs from.
87
+ Docs corpus: @webjsdev/mcp@0.1.12, copied from webjsdev/webjs e5806e2 on 2026-08-08.
88
+ ```
89
+
90
+ It warns rather than refusing to answer, because a knowledge tool that declines
91
+ sends the agent back to its training data, which is what the caveat exists to
92
+ correct. Equal versions, an older app, and an unreadable app manifest are all
93
+ silent.
94
+
95
+ Two details the wording is deliberate about. It does not name a global install as
96
+ the thing to fix, because nothing observes how the server was started and the two
97
+ shipped configurations are an `npx` invocation (whose staleness is a package
98
+ cache) and `webjs mcp` (whose staleness is the CLI's own dependency). And it does
99
+ not promise the docs below came from the app: the warning reads the app's
100
+ `package.json` while the corpus rung probes that install's `resources/`, so a
101
+ workspace-linked install can have a manifest to compare and no corpus to serve.
102
+ In that case the corpus falls through to the server's snapshot and the warning
103
+ says so, rather than contradicting the corpus line printed under it.
104
+
105
+ ## The build stamp
106
+
107
+ The bundle is a snapshot frozen at publish time, so a published tarball keeps
108
+ serving the docs as they read on the day it was cut. `prepack` stamps it with
109
+ `resources/corpus.json` so the snapshot can say which docs it holds:
110
+
111
+ ```json
112
+ {
113
+ "package": "@webjsdev/mcp",
114
+ "version": "0.1.12",
115
+ "sha": "e5806e2400000000000000000000000000000000",
116
+ "copiedAt": "2026-08-08T09:14:22.031Z"
117
+ }
118
+ ```
119
+
120
+ `sha` is the full commit the docs were copied from, so it resolves straight to a
121
+ GitHub diff. Every field is `null` rather than a plausible-looking default when
122
+ it cannot be established, so a consumer can always tell a real answer from no
123
+ answer: `sha` when the source tree is not itself a git checkout root, and
124
+ `package` / `version` when the manifest cannot be read. None of those fails the
125
+ publish. A dev checkout has no bundle and so no stamp.
126
+
127
+ The SHA is deliberately refused when the tree merely SITS inside some other
128
+ checkout, because `git rev-parse` walks up to an ancestor and would otherwise
129
+ report an unrelated repository's HEAD as the commit these docs came from. That
130
+ answer is a well-formed SHA, so nothing downstream could catch it.
49
131
 
50
132
  STDOUT is the JSON-RPC channel; every diagnostic goes to stderr.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/mcp",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
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": {
@@ -13,7 +13,7 @@ to these `references/`:
13
13
  | Reference | Topic |
14
14
  |---|---|
15
15
  | `references/routing-and-pages.md` | Pages, layouts, dynamic routes, route handlers, metadata, redirects, 404s |
16
- | `references/components.md` | Writing components: reactive props, signals, lifecycle, light vs shadow DOM, inherited members not to shadow |
16
+ | `references/components.md` | Writing components: what a component owns, reactive props, signals, lifecycle, light vs shadow DOM, inherited members not to shadow |
17
17
  | `references/data-and-actions.md` | Server actions, mutations, queries, validation, the `ActionResult` envelope, Drizzle surface, where server-derived types live |
18
18
  | `references/auth-and-sessions.md` | Sessions, login flows, route protection, `forbidden()` / `unauthorized()` |
19
19
  | `references/styling.md` | Tailwind, light-DOM tag-prefix rule, tokens, fixed headers, no-reflow layout |
@@ -33,7 +33,7 @@ itself): commands, repo-health git config, changelog flow, dev error overlay.
33
33
 
34
34
  ## AI-driven development: guardrails for all agents
35
35
 
36
- **WebJs is AI-first. These rules apply to ALL agents (Claude, Cursor, Copilot, Antigravity, Gemini, opencode) through a SINGLE cross-agent source the scaffold ships**: `AGENTS.md` (the open standard Cursor / opencode / Antigravity / the Copilot coding agent read natively) plus the skill at `.agents/skills/webjs/` and the workflow rules at `.agents/rules/workflow.md`. Tools that do not read `AGENTS.md` natively get a THIN bridge pointing at it (`CLAUDE.md` for Claude Code, `GEMINI.md` for Gemini CLI, `.github/copilot-instructions.md` for Copilot in VS Code), never a duplicated rule set. Claude Code additionally ships the protective enforcement hooks (`.claude/`, `.hooks/pre-commit`).
36
+ **WebJs is AI-first. These rules apply to ALL agents (Claude, Cursor, Copilot, Antigravity, Gemini, opencode) through a SINGLE cross-agent source the scaffold ships**: `AGENTS.md` (the open standard Cursor / opencode / Antigravity / the Copilot coding agent read natively) plus the skill at `.agents/skills/webjs/` and the workflow rules at `.agents/rules/workflow.md`. Tools that do not read `AGENTS.md` natively get a THIN bridge pointing at it (`CLAUDE.md` for Claude Code), never a duplicated rule set. The scaffold shipped `GEMINI.md` and `.github/copilot-instructions.md` as further bridges until #1368 removed them, so `CLAUDE.md` is the only one today. Claude Code additionally ships the protective enforcement hooks (`.claude/`, `.hooks/pre-commit`).
37
37
 
38
38
  ### Before starting ANY work: verify and sync the branch
39
39
 
@@ -55,12 +55,13 @@ cd ../<repo>-<slug> # do ALL work for the task here
55
55
 
56
56
  **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`.
57
57
 
58
- Fix it with **`npm run worktree:link`** from inside the worktree (or a full `npm install` there, which is correct but slow and duplicates a large tree per worktree). **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. Two things beyond the root tree are needed, and the script handles both:
58
+ Fix it with **`npm run worktree:link`** from inside the worktree (or a full `npm install` there, which is correct but slow and duplicates a large tree per worktree). **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
59
 
60
60
  - **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.
61
61
  - **`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
+ - **`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`.
62
63
 
63
- 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`.
64
+ 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.
64
65
 
65
66
  **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 a real `npm install` in the worktree, or repoint the individual `@webjsdev/<pkg>` entries at it. CI always builds from the branch, so it is unaffected either way.
66
67
 
@@ -74,6 +75,20 @@ Git enforces one-branch-per-worktree, so separate worktrees make the collision i
74
75
 
75
76
  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.
76
77
 
78
+ ### Talking to GitHub: `gh api` over REST, never the porcelain
79
+
80
+ GitHub scores two independent budgets, and `gh` routes to them in a way the command names hide. **Most `gh` porcelain commands issue `POST /graphql`**, whose 5000/hour budget is scored in **points** rather than requests, so one query walking a large connection can cost hundreds. Sessions here exhaust it routinely, and when it is gone the project board becomes unreachable and two PostToolUse hooks fail silently. The REST budget (`gh api <path>`, 5000 requests/hour) sits idle meanwhile.
81
+
82
+ Convert these, which is the whole high-volume surface. Reads: `gh issue view` / `gh issue list` / `gh pr view` / `gh pr list` / `gh pr diff` / `gh pr status` / `gh search issues` / `gh label list`. Writes: `gh issue create` / `gh issue edit` / `gh issue close` / `gh issue comment`. The writes are lower volume, but a task whose whole deliverable is a write is blocked outright once the budget is gone.
83
+
84
+ So: **go through `gh api` over REST, and reserve GraphQL for the two things only it can do**, Projects V2 (which has no REST API) and `resolveReviewThread`.
85
+
86
+ **Leave these on the porcelain**, even though they do route to GraphQL. `gh pr merge`, because two PostToolUse hooks detect a merge by matching that literal string. `gh pr create`, which resolves base and head from local git state. And `gh pr checks`, which is the merge gate: it folds check-runs and legacy commit statuses into one verdict, and the obvious REST replacement reports a green PR as pending, so a miss there lets a red build onto `main`. All three run once per PR, so converting them buys nothing and risks a lot.
87
+
88
+ Do NOT extend either list by assuming a command family routes one way. `gh label create` looks like it belongs beside `gh label list` and does not, since it issues a plain `POST /repos/{owner}/{repo}/labels`, so it needs no conversion. Measure with `GH_DEBUG=api` first.
89
+
90
+ The full substitution table, the traps in each replacement, and the reasoning behind every exception live in **`.claude/gh-budget.md`**. That file is the single source; skills and hooks link to it rather than restating it, because an earlier copy inside one skill drifted from the rule it stated.
91
+
77
92
  ### Autonomous mode (sandbox / bypass permissions)
78
93
 
79
94
  When interactive approval is disabled, never block on questions. Auto-decide: cut the task's worktree from `origin/main` (auto-create `<prefix>/<task-slug>` per the label scheme); auto-rebase if the parent moved; auto-merge when ready; **delete** feature/fix branches after merge but **keep** long-lived ones (dev, staging, release/*); auto-generate meaningful commit messages; fix failing tests / convention violations rather than asking. Autonomous mode is MORE disciplined, not less, with the same quality bar.
@@ -84,8 +99,8 @@ Every code change MUST include, automatically:
84
99
 
85
100
  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. 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`).
86
101
  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`).
87
- 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 (`packages/cli/templates/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 (`packages/cli/templates/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`.
88
- 4. **Convention validation.** Run `webjs check` and fix violations. Run `webjs doctor` too when you touched an in-repo app (`examples/blog`, `website`): the required `conventions` CI job runs it over both, 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).
102
+ 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`.
103
+ 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).
89
104
 
90
105
  ### Git workflow (mandatory)
91
106
 
@@ -144,7 +159,7 @@ Self-check: `page.ts` / `layout.ts` should NOT appear in the network tab or the
144
159
 
145
160
  ## Framework source: where to find it
146
161
 
147
- 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`. 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).
162
+ 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`. 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.
148
163
 
149
164
  ---
150
165
 
@@ -264,7 +279,7 @@ MyThing.register('my-thing');
264
279
 
265
280
  **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.
266
281
 
267
- **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`.
282
+ **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 expression `observedAttributes` is built from: it is matched by its LOWERCASED name (so write kebab-case, never camelCase), 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.
268
283
 
269
284
  **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).
270
285
 
@@ -290,7 +305,7 @@ Default export receives `{ children, params, searchParams, url }`, must embed `c
290
305
 
291
306
  ### Error / loading / metadata routes
292
307
 
293
- `error.{js,ts}` default-exports `({ error, ...ctx }) => TemplateResult` (catches sibling-page / deeper render errors, innermost wins, prod sends only `error.message`). `loading.{js,ts}` wraps the sibling page in `Suspense` with an immediately-flushed fallback. `forbidden.{js,ts}` / `unauthorized.{js,ts}` render the nearest 403 / 401 boundary for a thrown `forbidden()` / `unauthorized()` (#848). Two **root-only** boundaries (`app/` root exactly): `global-error.{js,ts}` is the app-wide catch-all tried after the nested `error` boundaries are exhausted, and it renders its **own** `<!doctype><html><body>` document (returned verbatim, since a root-layout failure is when it fires). Because it is returned verbatim (no framework `<head>` splice), it ships **no importmap or boot script**, so keep it **static HTML with no components/hydration** (a last-resort page must not depend on the module system that may have just failed); under an opt-in CSP, an inline `<script>` in it must carry the nonce via `cspNonce()` (an inline `<style>` needs one only if you tighten `style-src`, since the default allows inline style outright). `global-not-found.{js,ts}` renders for an unmatched-anywhere URL when no `not-found` matches. `not-found` is nearest-wins from the throwing page's chain (#848 fixed the prior root-only behavior). Metadata routes (`sitemap`, `robots`, `manifest`, `icon`, `apple-icon`, `opengraph-image`, `twitter-image`) live at app root or static segments only and default-export a possibly-async function; `sitemap(entries)` / `sitemapIndex(sitemaps)` from `@webjsdev/server` serialize spec-valid XML.
308
+ `error.{js,ts}` default-exports `({ error, ...ctx }) => TemplateResult` (catches sibling-page / deeper render errors, innermost wins, prod sends only `error.message`). `loading.{js,ts}` wraps the sibling page in `Suspense` with an immediately-flushed fallback. `forbidden.{js,ts}` / `unauthorized.{js,ts}` render the nearest 403 / 401 boundary for a thrown `forbidden()` / `unauthorized()` (#848). Two **root-only** boundaries (`app/` root exactly): `global-error.{js,ts}` is the app-wide catch-all tried after the nested `error` boundaries are exhausted, and it renders its **own** `<!doctype><html><body>` document (returned verbatim, since a root-layout failure is when it fires). Because it is returned verbatim (no framework `<head>` splice), it ships **no importmap or boot script**, so keep it **static HTML with no components/hydration** (a last-resort page must not depend on the module system that may have just failed); under an opt-in CSP, an inline `<script>` in it must carry the nonce via `cspNonce()` (an inline `<style>` needs one only if you tighten `style-src`, since the default allows inline style outright). `global-not-found.{js,ts}` renders for an unmatched-anywhere URL when no `not-found` matches. `not-found` is nearest-wins from the throwing page's chain (#848 fixed the prior root-only behavior). Metadata routes (`sitemap`, `robots`, `manifest`, `icon`, `apple-icon`, `opengraph-image`, `twitter-image`) live at app root or static segments only and default-export a possibly-async function; `sitemap(entries)` / `sitemapIndex(sitemaps)` from `@webjsdev/server` serialize spec-valid XML. **`icon` / `apple-icon` are auto-LINKED** into the head (`<link rel="icon" href="/icon">`, `<link rel="apple-touch-icon" href="/apple-icon">`, base-path prefixed, no asserted `type` / `sizes` since the route picks its content type at request time), so writing the file is the whole wiring. A declared `metadata.icons` **suppresses** them rather than merging, matching Next's precedence for its static icon files, so an app that outgrows a placeholder route names its real icons instead of deleting it. `opengraph-image` / `twitter-image` are NOT auto-linked (a preview image is a per-page editorial choice): point `metadata.openGraph.images` / `metadata.twitter.images` at them. Declare a favicon through `metadata.icons` or a metadata route, never a hand-written `<link rel="icon">`, since only the root layout may write a shell at all (invariant 8). See `references/routing-and-pages.md`.
294
309
 
295
310
  ### Route handlers (`app/**/route.{js,ts}`)
296
311
 
@@ -367,7 +382,7 @@ Derive the type at every boundary: a DB row from the schema (`typeof todos.$infe
367
382
 
368
383
  ## Client navigation: automatic, nothing to opt into
369
384
 
370
- 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). 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. 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): `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.
385
+ 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). 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. 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.
371
386
 
372
387
  The advanced client-router surface is in `references/client-router-and-streaming.md`: **link prefetch** (on by default, device-adaptive default: `intent` on a hover pointer, `viewport` (dwell-gated, cancel-on-scroll-out) on touch, per-link `data-prefetch` override), **`<webjs-frame>`** partial-swap regions, **View Transitions** (opt-in via `<meta name="view-transition" content="same-origin">`, where the router checks that exact `content` value and a bare tag enables nothing, plus `data-webjs-permanent` to persist a live element), **stream actions** (`<webjs-stream>` element-level updates, #248), and the **opt-in nav-loading indicator** (`<html data-webjs-nav-progress>` exposes a `data-navigating` attribute during a nav so you can style a CSS-only progress affordance, off by default because toggling a root attribute re-resolves `oklch()` tokens to a one-frame repaint flash on iOS WebKit, #610). Production benefits from HTTP/2 at the edge; `npm run start` speaks plain HTTP/1.1 (put a reverse proxy in front for TLS + HTTP/2).
373
388
 
@@ -481,7 +496,7 @@ const result = await optimistic(liked, true, () => likePost(postId));
481
496
  9. **No backtick characters inside `html\`...\`` template bodies**, even inside CSS / HTML comments. A nested backtick closes the literal at JS-parse time and 500s in prod.
482
497
  10. **TypeScript must be erasable.** Set `compilerOptions.erasableSyntaxOnly: true`. No `enum`, no value `namespace`, no constructor parameter properties, no legacy decorators with `emitDecoratorMetadata`, no `import = require`. Types are stripped via Node 24+'s `module.stripTypeScriptTypes` (buildless, no bundler fallback); non-erasable syntax 500s at strip time. Enforced by `erasable-typescript-only` (tsconfig flag) and `no-non-erasable-typescript` (source scan). See `references/typescript.md`.
483
498
 
484
- 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; semicolons and colons stay fine inside code / TS / JSON / CSS. 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).
499
+ 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).
485
500
 
486
501
  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}` emits nothing so it is supplied, while `method=${null}` 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.
487
502
 
@@ -491,7 +506,7 @@ const result = await optimistic(liked, true, () => likePost(postId));
491
506
 
492
507
  Two scaffolds exist (do not invent template names): `webjs create <name>` (full-stack: layout, page, components, modules, Drizzle+SQLite, plus a browsable feature gallery), and `webjs create <name> --template api` (backend-only routes + modules + Drizzle, no SSR). Auth is one of the full-stack gallery cards (login + a signed session + a real protected route), so a full-stack app already carries a promotable auth baseline (this replaced the former `--template saas`). The `--db sqlite|postgres` flag (default sqlite) picks the dialect; the schema/queries/actions are identical across dialects (see #563). The `--runtime node|bun` flag (default node, #541) is ORTHOGONAL to the template and re-flavors either for Bun (`bun --bun` dev/start scripts so the SERVER runs on Bun, `bun.lock`, a pure `oven/bun:1` Dockerfile (#595; safe since cli@0.10.20's npx-free `webjs db migrate` (#570) needs no Node in the image) + bun-install CI, bun-command agent docs; the test/db/check tooling stays on Node); `bun create webjs <name>` auto-detects it. Pick from the request: default (full-stack) for any product with UI (todo, blog, dashboard, marketplace, social, e-commerce, a SaaS with accounts/login), `api` for an HTTP/JSON API with no UI; default to full-stack when ambiguous.
493
508
 
494
- The `<name>` must be a valid package name (letters, digits, and the separators `-`, `.`, `_`, starting with a letter or a digit, at most 214 characters, and not `node_modules` or `favicon.ico`, which npm reserves). It becomes the app directory, the generated `package.json` `name`, AND a value the scaffold writes into generated source, so anything else is rejected before a single file is written (#1066). Uppercase is fine (`webjs create MyApp` works), since the scaffold's manifest is private and never published.
509
+ The `<name>` must be a valid package name (letters, digits, and the separators `-`, `.`, `_`, starting with a letter or a digit, at most 214 characters, and not `node_modules` or `favicon.ico`, which npm reserves). It becomes the app directory, the generated `package.json` `name`, AND a value the scaffold writes into generated source, so anything else is rejected before a single file is written (#1066). Uppercase is fine (`webjs create MyApp` works), since the scaffold's manifest is private and never published, and the Postgres `DATABASE_URL` the scaffold emits uses a lowercased, separator-folded form of the name (`MyApp` gets `myapp`) so it matches what an unquoted `CREATE DATABASE` produces.
495
510
 
496
511
  Rules: **always scaffold via `webjs create`** (never hand-roll). **Default to a real database (Drizzle + SQLite); NEVER use JSON files, in-memory arrays, or localStorage for persistence.** Update `db/schema.server.ts` to real models FIRST, then `webjs db generate` + `webjs db migrate`, then build pages/actions/queries. **The scaffold ships a gallery index home, a neutral-palette root layout, db wiring, and a densely-commented feature gallery** (single-concept demos under `app/features/` plus the `app/examples/todo` app, with logic in `modules/`), alongside the one cross-agent skill at `.agents/skills/webjs/` (a routing `SKILL.md` + on-demand `references/`) that teaches how to build. Treat the gallery as browsable reference: read the demos to learn the idioms, then build the app the user asked for by growing the scaffold (add routes under `app/`, components under `components/`, features under `modules/<feature>/`, keep server-only code behind `.server.ts`) and prune the demos the app does not use (delete the `app/features/<x>` route AND its `modules/<x>`). Give a UI app its own palette by setting the token values in `app/layout.ts`. There is no design gate and no placeholder gate: taste is the agent's job, not the checker's. Docs at https://webjs.dev/docs.
497
512
 
@@ -38,7 +38,7 @@ Classify the task first, then load the smallest useful reference set. Each refer
38
38
  | Task involves... | Start with |
39
39
  | --------------------------------------------------------------------------- | --------------------------------------------- |
40
40
  | Pages, layouts, dynamic routes, route handlers, metadata, redirects, 404s | `references/routing-and-pages.md` |
41
- | Writing components: reactive props, signals, lifecycle, light vs shadow DOM | `references/components.md` |
41
+ | Writing components: what a component owns, reactive props, signals, lifecycle, light vs shadow DOM | `references/components.md` |
42
42
  | Why a component's JS was or was not downloaded, `webjs elision`, `static interactive = true` | `references/components.md` |
43
43
  | Server actions, mutations, queries, validation, the `ActionResult` envelope | `references/data-and-actions.md` |
44
44
  | Sessions, login flows, route protection, `forbidden()` / `unauthorized()` | `references/auth-and-sessions.md` |
@@ -238,8 +238,12 @@ Success is a 303 (PRG); failure re-renders the page at 422 with the result on `a
238
238
  - Writing a bare `<form method="post">` and expecting a page `action` export to catch it. There is no such export; bind the action with `action=${fn}` or the submission is a 405.
239
239
  - Putting a submitter's `formaction=${fn}` on anything that is not a submit control, or on a button carrying its own `name` / `value`. The identity IS the button's name/value pair, so both halves are spoken for.
240
240
  - Writing `formmethod="get"` or `formenctype="text/plain"` on a button that BINDS an action. Neither can carry that action's body, so the pair contradicts itself and throws. On a button that binds nothing it is a legal native override and is honoured.
241
- - Binding an action whose file declares `export const method = 'GET'`. That is a 405 at runtime and a `webjs check` error.
241
+ - Binding an action whose file declares `export const method = 'GET'`. Form-bound actions strictly require POST (default). Binding a GET action to a form is a 405 at runtime and a `webjs check` error (`form-action-not-a-get-action`).
242
+ - Leaving read-only RPC server query actions as default `POST`. Always export `export const method = 'GET'` for RPC data queries so arguments ride URL params, ETags/304 caching work, and CSRF is safely bypassed.
243
+ - Writing `method="get"` on a bound `<form action=${fn}>`. WebJs supplies `method="post"` and `formenctype` automatically, and a bound form declaring `method="get"` is REFUSED at render (a thrown error, not a warning), because a GET sends no body for the action to read.
242
244
  - Throwing `redirect()` / `notFound()` inside a `route.ts` handler (uncaught 500). Return a `Response` there.
243
245
  - A placeholder first paint that fetches in `connectedCallback`. SSR does not call `connectedCallback`; put first-paint data in the constructor (server-known inputs) or use `async render()`.
244
246
  - A browser global (`window`, `document`, `localStorage`) in the constructor or `render()`. It throws at SSR; do browser-only work in `connectedCallback`.
245
247
  - Interpolating into a component's `<style>` / `<script>` body. Use `static styles` or Tailwind.
248
+ - Driving a component's markup from a delegated `document` listener in a page or layout, coupled by a class selector. The markup, the state, and the listener belong in one component.
249
+ - Parking one component's UI state on `<body>` or `<html>`. The router's swap range never covers the document shell, so the flag outlives the markup it described. A document-wide SETTING such as the theme is the exception, and a transient effect such as a scroll lock is released in `disconnectedCallback`.
@@ -0,0 +1,6 @@
1
+ {
2
+ "package": "@webjsdev/mcp",
3
+ "version": "0.1.14",
4
+ "sha": "d1ecf972d1bc34128ccdc71878cd87d5430cf4c7",
5
+ "copiedAt": "2026-08-10T21:12:38.703Z"
6
+ }
@@ -145,7 +145,7 @@ setFileStore(diskStore({ dir: '/var/data/uploads', baseUrl: '/files' }));
145
145
 
146
146
  ## The `"webjs"` config block (package.json)
147
147
 
148
- All keys are optional, and a malformed entry in a key the SERVER reads is dropped at boot with a warning, never crashing the pipeline. The one exception is `doctor.gate`, which is read by the `webjs doctor` CLI rather than the server and rejects a bad entry outright (see the doctor severity gate below): a gate whose typo was quietly ignored would leave CI un-gated while looking gated, which is the one thing that mechanism cannot afford.
148
+ All keys are optional, and a malformed entry in a key the SERVER reads is dropped at boot rather than crashing the pipeline, some readers warning as they drop it. `headers` and `redirects` warn on every drop. Both warn for the key itself when it is present but not an array, and for each rule or entry inside it; `headers` has a third level and warns per header directive too. An absent key is the default and says nothing. An UNKNOWN top-level key no longer passes in silence either (#1300): the block is validated against the published JSON Schema once per boot, in dev and in prod alike, and one aggregated warning names what was ignored. So a `"redirect"` typed for `"redirects"` now says so in the server output instead of silently leaving the feature at its default. It never fails the boot. Know its exact reach, since what it passes over looks like what it catches. It reports any unknown TOP-LEVEL key, whatever it is called, and it checks the VALUE of 9 of the 17 known keys, being the one `enum` and the `boolean` / `integer` leaves. It does not descend into a nested object, so a misspelling inside `dev` or `start` is not reported, and it does not type-check the other 8 (`headers`, `redirects`, `basePath`, `allowedOrigins`, `csp`, `dev`, `start`, `doctor`), whose schemas are the free-form ones a blunt check would start refusing working configs over. Whether anything else notices a value this check passes over is up to that value's own reader, and it varies by reader. `doctor.gate` varies furthest, since the boot check never descends into any key and the `webjs doctor` CLI, not the server, is what reads this one, rejecting a bad entry outright (see the doctor severity gate below): a gate whose typo was quietly ignored would leave CI un-gated while looking gated, which is the one thing that mechanism cannot afford.
149
149
 
150
150
  ### Security headers
151
151
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  ## What This Covers
4
4
 
5
+ - What a component owns (markup, state, listeners, styling), and the rules that follow from it: refs over selectors, no state on `<body>`, ARIA derived in `render()`
5
6
  - Declaring reactive properties through the `WebComponent({ ... })` factory and `prop()`, with options (`reflect`, `state`, `attribute`, `default`, `converter`, `hasChanged`)
6
7
  - Signals as the default state primitive for component-local and shared state, plus `effect` / `batch`
7
8
  - The Lit-aligned lifecycle and exactly which hooks SSR runs versus skips
@@ -15,6 +16,91 @@
15
16
 
16
17
  Read this when you are authoring or reviewing a `WebComponent`. For styling a component (Tailwind, the tag-prefix rule, host sizing) see `styling.md`. For streaming a slow region or programmatic navigation see `client-router-and-streaming.md`. For Lit habits that break WebJs see `muscle-memory-gotchas.md`.
17
18
 
19
+ ## Ownership: what a component owns
20
+
21
+ A component owns four things together: its markup, its state, its listeners, and its styling. The moment one of them lives in a different file from the rest, the feature can no longer be read, tested, or deleted as a unit, and the parts drift apart. These are CONVENTIONS, judged by a reader. `webjs check` has no rule for any of them, and adding one would be wrong, because a sensible app can legitimately want a delegated listener to pass.
22
+
23
+ Most of what follows restates widely held component-model advice, ported. Lit's base class exists to hold reactive state, scoped styles, and a declarative template TOGETHER (the `lit` package README), and Lit documents a ref's value as `undefined` once the node "is no longer rendered", which is precisely the signal a selector lookup cannot give you. React frames the same ideas as lifting state to the closest common owner (react.dev, "Sharing State Between Components") and treating a ref as an escape hatch rather than the normal way to reach a node (react.dev, "Escape Hatches"). Where WebJs moves the boundary, the rule that needs it says so inline and names the mechanism.
24
+
25
+ **1. Markup and the code that drives it live in the same component.** A class selector is not an interface. `document.querySelector('.nav-toggle')` keeps compiling, keeps type-checking, and keeps passing `webjs check` after someone renames the class in the other file. It just starts returning `null` at runtime. If you are writing a selector to find markup that another file rendered, write the component that renders it instead. Where a value genuinely has to exist in two places (a layout's pre-paint inline script cannot import), the second place READS the first declaration rather than restating it.
26
+
27
+ **2. Reach your own rendered node with a ref, never with a selector.** `render()` already owns the node, so let the handle flow out of the template with `ref()` / `createRef()` from `@webjsdev/core/directives` (the directives table below carries the one-line summary). A ref is scoped to the component, so it cannot match a node some other component rendered, and it goes `undefined` when the node stops being rendered, which makes a stale handle visible instead of silent. Two reads a ref cannot express stay vanilla: `this.closest('parent-tag')` for compound-component ancestor lookup, and `assignedNodes()` for slotted content.
28
+
29
+ **3. State lives on the component, never on `<body>` or `<html>`.** The client router swaps a range INSIDE the document, so the document shell sits outside every swap. An open flag parked on `<body>` therefore survives a navigation that removed the markup it described, and it re-opens a panel over the next page or leaves scrolling locked on a page with nothing open. State held in a reactive property or an INSTANCE signal dies with the element, which is the behaviour you wanted in the first place. A module-scope signal deliberately outlives it, which is what rule 7 reaches for, so it is the right home for state genuinely shared between components and the wrong one for one element's own open flag. The carve-out is a document-level EFFECT rather than one component's state, and it comes in two shapes. A TRANSIENT effect, a scroll lock being the usual case, belongs to the element that opened it and must be released in `disconnectedCallback`. A PERSISTENT one is a document-wide SETTING, the theme being the case the framework itself ships: the scaffold's theme toggle writes `data-theme` on `<html>` and persists it, deliberately without releasing it on disconnect, because it describes the document rather than the element (`styling.md` carries that pattern). What the rule forbids is neither of those. It is one component's own open / selected / active flag parked on the shell because that was the convenient place to reach it from.
30
+
31
+ **4. ARIA state is a hole in `render()`, derived from the same state that drives behaviour.** `aria-expanded=${this.open ? 'true' : 'false'}` cannot disagree with `this.open`. A second function that re-finds the button and calls `setAttribute` can, and does, the first time someone adds a close path that forgets to call it. The same holds for `class`, `?disabled`, and any `.prop`. Two caveats ride this rule:
32
+
33
+ - Write the string explicitly for a tri-state ARIA attribute. A plain-attribute hole holding `false` serves `aria-expanded="false"` from the server and hydrates to NO attribute, because the client removes an attribute for `null` / `undefined` / `false` while the server stringifies it. `?attr=${bool}` is not a substitute, since a boolean binding omits the attribute in BOTH renderers.
34
+ - A hole commits on the next render, one microtask later. The one place a direct write is still correct is a synchronous snapshot read such as `webjs:before-cache`, where the router reads `outerHTML` in the same task. That is a documented exception, not the normal path.
35
+
36
+ **5. Behaviour needs an importable surface, or its test is a copy of it.** An inline `<script>` in a layout has no module identity, so a browser test cannot import it. It can only transcribe the listener into the test file and assert against the transcription, which then needs a SECOND test to grep the original for drift. Two tests, neither running shipping code. A component is importable, so its browser test mounts the real element and drives real events. A page or layout may still carry an inline `<script>`, but only for pre-paint boot work no module can do: reading a stored theme before first paint so the wrong palette never flashes, or measuring the header height into a CSS custom property. It must not be interactivity, and WHERE it sits decides how often it runs. The ROOT layout's markup sits OUTSIDE every swap range, so a soft navigation does not re-run its script, which is what makes it the right home for boot work and the wrong home for anything that has to respond to a later navigation. A page or a NESTED layout sits inside the swap range instead, so its script re-executes on every navigation that swaps that range (#1102), which means it has to be idempotent or guard on a flag it sets the first time. Neither shape gives you a listener that simply works, which is what a custom element is for. Under an opt-in CSP the script also needs the nonce from `cspNonce()`. `client-router-and-streaming.md` carries the full re-execution rule.
37
+
38
+ **6. Listening on `document` is legitimate. Querying `document` usually is not.** An outside-click dismissal or an Escape handler has no choice, because the event happens outside the element, so the listener has to be global. What decides whether that is ownership or a reach across the app is what the handler then READS. `this.contains(e.target)` is a decision about the component's own subtree. `document.querySelector('.other-thing')` is a decision about someone else's markup. Add the listener in `connectedCallback`, remove it in `disconnectedCallback`, and store the handler in a field so `removeEventListener` gets the same reference back (a function created inline at add time can never be removed).
39
+
40
+ **7. Talk to an ancestor with an event, and to a stranger with a module-scope signal.** A child telling its own ancestor something dispatches a `CustomEvent` with `bubbles: true`, and the ancestor binds `@my-event=${...}` in the template that rendered it. Add `composed: true` as well when the component sets `static shadow = true`, or the event stops at the shadow boundary. Two components with NO ancestor relationship share a module-scope `signal` that both import, which is typed, greppable, and owned by a module. What neither case is: a made-up event name on `document` used as a global bus, which is a global variable with extra steps. Framework events such as `webjs:navigate` ride `document` because the router has no element to dispatch from, and that is not a licence to add your own.
41
+
42
+ The shape to fix, all four pieces in different places:
43
+
44
+ ```js
45
+ // In a layout's inline script, driving markup that another file rendered.
46
+ document.addEventListener('click', (e) => {
47
+ if (e.target.closest('.nav-toggle')) document.body.toggleAttribute('data-nav-open');
48
+ });
49
+ function syncNav() {
50
+ const btn = document.querySelector('.nav-toggle'); // another file's markup
51
+ const open = document.body.hasAttribute('data-nav-open'); // outlives the markup
52
+ if (btn) btn.setAttribute('aria-expanded', String(open)); // a second home for the state
53
+ }
54
+ ```
55
+
56
+ The shape to write, one component owning all four:
57
+
58
+ ```ts
59
+ import { WebComponent, prop, html } from '@webjsdev/core';
60
+ import { createRef, ref } from '@webjsdev/core/directives';
61
+
62
+ class NavDrawer extends WebComponent({ open: prop(Boolean, { reflect: true }) }) {
63
+ private toggleRef = createRef<HTMLButtonElement>();
64
+ // Stored in a field, so removeEventListener gets the same reference back.
65
+ private onDocClick = (e: MouseEvent) => {
66
+ if (!this.contains(e.target as Node)) this.open = false; // reads its OWN subtree
67
+ };
68
+ private onDocKeydown = (e: KeyboardEvent) => {
69
+ if (e.key !== 'Escape' || !this.open) return;
70
+ this.open = false;
71
+ // The ref lands after the FIRST client commit, and `ref()` is a no-op at
72
+ // SSR, so read `.value` from a handler or `firstUpdated`, never from the
73
+ // constructor. This is the reach a selector would otherwise have done.
74
+ this.toggleRef.value?.focus();
75
+ };
76
+
77
+ constructor() { super(); this.open = false; } // SSR runs the constructor
78
+
79
+ connectedCallback() {
80
+ super.connectedCallback();
81
+ document.addEventListener('click', this.onDocClick); // listening globally is fine
82
+ document.addEventListener('keydown', this.onDocKeydown);
83
+ }
84
+ disconnectedCallback() {
85
+ super.disconnectedCallback();
86
+ document.removeEventListener('click', this.onDocClick); // the state dies with the element
87
+ document.removeEventListener('keydown', this.onDocKeydown);
88
+ }
89
+
90
+ render() {
91
+ return html`
92
+ <button ${ref(this.toggleRef)}
93
+ aria-expanded=${this.open ? 'true' : 'false'}
94
+ @click=${() => { this.open = !this.open; }}>Menu</button>
95
+ <nav ?hidden=${!this.open}><slot></slot></nav>
96
+ `;
97
+ }
98
+ }
99
+ NavDrawer.register('nav-drawer');
100
+ ```
101
+
102
+ This repo's own website is the worked example. Before commit `b80de906` the docs drawer and the header menu were exactly the first shape, and every accessibility bug their tests now pin came out of the split. `website/components/docs-drawer.ts` and `website/components/site-nav-menu.ts` are the second shape, and `website/AGENTS.md` records the app-level version of these rules under "What stays inline script in the root layout".
103
+
18
104
  ## Reactive properties: the base-class factory
19
105
 
20
106
  Reactive properties are declared by passing their shape into `WebComponent({ ... })`. The types flow automatically to `this.<prop>`, so there is NO `static properties` block and NO `declare` line (a `static properties` block throws at runtime, caught by `no-static-properties`).
@@ -48,17 +134,23 @@ The bare form is shorthand: `count: Number` means `prop(Number)`. Use `prop()` t
48
134
  | Option | Default | Meaning |
49
135
  |---|---|---|
50
136
  | `type` | `String` | Constructor feeding the default attribute converter |
51
- | `reflect` | `false` | Property changes write back to the HTML attribute (a function value removes it instead, see below) |
52
- | `state` | `false` | Internal-only. No attribute, not observed |
137
+ | `reflect` | `false` | Property changes write back to the HTML attribute (a value with no attribute representation removes it instead, see below) |
138
+ | `state` | `false` | Internal-only. No attribute, not observed, and never read from one at SSR either |
53
139
  | `attribute` | derived from name | The HTML attribute name the property rides |
54
140
  | `default` | none | Declarative initial value (a function runs per instance for a fresh object / array) |
55
141
  | `hasChanged` | strict `!==` | Custom change detection |
56
- | `converter` | type-based | Custom attribute-to-property serialization |
142
+ | `converter` | type-based | Custom attribute-to-property serialization. `fromAttribute` runs on BOTH readers (the client upgrade and SSR), ahead of type coercion |
57
143
 
58
144
  For an array-typed prop pass `Array`, not `Object` (`array-prop-uses-array-type` flags the `Object` form). For anything the built-in converters cannot parse (Date, Map, Set) supply a `converter`.
59
145
 
146
+ **A `converter.fromAttribute` runs SERVER-SIDE too.** Both attribute readers go through one shared implementation, so the converter fires during SSR as well as on the client upgrade, and it wins over the declared `type` on both. So keep it free of browser globals (`document`, `window`, `navigator`), since SSR has no DOM and touching one throws where the same code worked in the browser. A converter that THROWS is not caught by either reader, because an author who writes one owns the conversion: at SSR the throw lands in per-component error isolation (an error box in dev, an empty element at a 200 in production, with the cause in the server log) while sibling components still render, and on the client it escapes `attributeChangedCallback` during upgrade. Both readers hand the converter DECODED attribute text, so a converter that parses its input (`JSON.parse` for a Map or a Set, `new Date(...)`) sees the same string on both sides even when the attribute carries `&quot;` or `&amp;`.
147
+
60
148
  **A `reflect: true` property holding a FUNCTION drops its attribute instead of writing one, and so does one holding an array that carries a function, unless the prop is `Object` or `Array` typed.** A function has no HTML attribute representation, and the serializations it would otherwise get are both useless and dangerous. `String(fn)` is the function's SOURCE, so a reflected `'use server'` action would ship its whole body, closure secrets included, to every visitor, and `JSON.stringify(fn)` is `undefined`, which lands in the attribute as the literal four-character string. So the reflection path treats a function like `null`, removes the attribute, and warns naming the property, the tag, and the attribute. This holds on both sides, since SSR and the client-side setter run the same path, and it holds for every property name (the leak was never specific to one called `action`). Two exceptions. A property with a custom `converter.toAttribute` runs that converter first and is left alone, because an author who writes one has taken responsibility for serializing whatever they are handed. And an `Object` or `Array` typed property CARRYING a function keeps its data, because `JSON.stringify` drops the function to `null` and omits the key, so `[1, 2, fn]` reflects as `[1,2,null]` with no source and nothing else lost. If you need a function on a component, use a plain property or a signal and do not mark it `reflect`.
61
149
 
150
+ **An `Object` or `Array` typed reflected property whose value `JSON.stringify` cannot serialize AT ALL drops its attribute the same way, and warns.** Three shapes do this: a cycle (an object or array that reaches itself, which arrives from a parent/child graph, a linked node, a memo table, or anything a library hands back with a back-reference), a `BigInt` anywhere inside the value, and an author `toJSON()` that throws. The line to keep straight is that a value which serializes WITH A GAP in it keeps its data (the carried-function case above), while one that does not serialize at all has no string to put in the attribute and so has no attribute representation, exactly like a function. The property itself is untouched and still holds the value; only the attribute goes. Before this guard the throw escaped reflection entirely, which meant a client upgrade threw before the component's first render, and an SSR render was swallowed by per-component error isolation, which shows an error box in dev and renders the component EMPTY on a page that still returned 200 in production. To reflect something about a graph-shaped value, reflect a derived scalar (an id, a count) and keep the graph on a non-reflected property. On the read side an attribute that is PRESENT but not parseable JSON reads back as `null` rather than as the raw string, on both the SSR and the client reader. An ABSENT attribute is a different case: neither reader sees it, so the property keeps its constructor value. The two readers also see the same attribute SET, not merely the same fallback: a `state: true` prop, a camelCase attribute name, and an attribute matching no declared property are all ignored by both, and both are handed a value whose HTML character references are already decoded.
151
+
152
+ **Writing attributes in markup.** Names are case-insensitive and the browser lowercases them while parsing, so write kebab-case (`user-name`); a camelCase attribute (`userName="…"`) reaches no property on either side. A prop that renames its attribute answers to the new name ONLY, so `open: prop(Boolean, { attribute: 'is-open' })` is written `<my-el is-open>` and `<my-el open>` reaches nothing. Character references are decoded before the value is coerced, so `cfg="&#123;&quot;a&quot;:1&#125;"` parses as the object it spells and `label="Tom &amp; Jerry"` is `Tom & Jerry`; the legacy semicolon-less forms decode exactly where a browser decodes them (`&nbsp` at the end of a value is a non-breaking space, `&nbspx` and `&nbsp=x` stay literal), and writing the semicolon avoids the question. A `state: true` prop takes an SSR value only through a `.prop=${value}` binding from the parent template, never from an attribute.
153
+
62
154
  **Never use a class-field declaration OR initializer** (`count = 0`, `student: Student = {...}`, `todos!: Todo[]`). Under `useDefineForClassFields` even a type-only `todos!: Todo[]` compiles to define an own property after `super()`, which clobbers the prototype's reactive accessor and silently breaks reactivity. Only declare props in the factory and read/write them off `this`. The `reactive-props-no-class-field` rule catches this.
63
155
 
64
156
  ## Signals are the default state primitive
@@ -146,15 +146,41 @@ Everything the action declares applies here too, or an action would be protected
146
146
  - `invalidates` is evicted when the action actually RAN (a middleware short-circuit does not evict), and the evicted tags are reported on the response so the browser's tag coordinator bypasses a stale cached GET. One reach limit: `fetch` follows the success `303` transparently, so JS cannot read a redirect's headers; the tags are on the wire and the `422` re-render carries them, and the redirect's own render is server-side and seeds fresh data.
147
147
  - `invalidates` and `tags` receive the SAME first argument the action does, so on a form boundary they receive the `FormData`. `invalidates: (input) => ['post:' + input.id]` returns `post:undefined` for a submission and evicts nothing. Either read the field (`(fd) => ['post:' + fd.get('id')]`), declare a `validate` that transforms the `FormData` into the typed input first (the transform result is what the config functions then see), or use an argument-independent tag.
148
148
  - `method = 'GET'` cannot be bound to a form: a GET action rides its args in the url and is CSRF-exempt, so it cannot answer a form POST. That is a `405` at runtime and the `form-action-not-a-get-action` error in `webjs check`.
149
+ - A form whose buttons run DIFFERENT actions binds each on its submitter, `<button formaction=${publishDraft}>`. **The submitter is self-sufficient** (#1307): the renderer gives it `formmethod="post"` and `formenctype` ON THE BUTTON, alongside the identity riding the button's own `name`/`value` pair, and a submitter's `formmethod` overrides the form's `method` per HTML. 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, and the enclosing form does not need binding for the button's sake. Bind the form when the FORM itself should run an action on a plain submit. In dev the client logs one `console.error` at submit time for a submission holding an identity it cannot deliver, and in production both server-visible fingerprints reach `onError` with a code (`WEBJS_FORM_SUBMITTED_AS_GET` for an identity in the query string, `WEBJS_FORM_ACTION_MISSING` for a body carrying no identity). See `muscle-memory-gotchas.md` for the shape.
149
150
 
150
151
  The response drives the page: a success is a `303` PRG (to `result.redirect` when it is a same-site local path, else the page's own url), a failure re-renders the SAME page with `status` (default `422`) and the result on `actionData`, a submission carrying no identity is a `405`, and one whose hash no longer resolves is a `422` with a resubmit message (a form held open across a deploy). The submission is Origin-verified like an RPC call, so no token field is needed.
151
152
 
152
153
  A streamed return (#489) is refused from a form-bound action: the RPC stub decodes frames, but a submission is answered with a redirect or a page, and with JS off there is no consumer at all. Stream from a programmatic call instead.
153
154
 
154
- ## HTTP-verb config exports
155
+ ## HTTP-verb config exports & decision guide
155
156
 
156
157
  A `'use server'` action is a POST by default. Reserved sibling exports, read statically (the same way a page reads `export const revalidate`), change its HTTP semantics WITHOUT changing the call site (you still write `await getUser(7)`).
157
158
 
159
+ ### HTTP Verbs Decision Guide
160
+
161
+ | Action Kind | Target Verb | Example Declaration | HTTP Semantics & Features |
162
+ |---|---|---|---|
163
+ | **Form-Bound Action** (`<form action=${fn}>`) | **POST** (default) | *(no export or `export const method = 'POST'`) | Standard HTML form submission. Enforces `POST` + `multipart/form-data` or `urlencoded`. **Never export `method = 'GET'`** (triggers 405 refusal & `webjs check` violation). |
164
+ | **RPC Read Action (Query)** | **GET** | `export const method = 'GET'` | Read-only RPC calls (`await getTodos()`). Args ride URL query params (with POST fallback over 4KB). CSRF-exempt, supports ETags, 304 revalidation, and `export const cache`. |
165
+ | **RPC Write Action (Mutation)** | **POST** / **PUT** / **PATCH** / **DELETE** | Default or `export const method = 'DELETE'` | Data-modifying RPC calls (`await deleteUser(4)`). Carries CSRF protection, serialized payload body, and evicts cached query tags via `export const invalidates`. |
166
+
167
+ ### Choosing the right HTTP verb
168
+
169
+ 1. **Form-Bound Actions (`<form action=${fn}>` / `<button formaction=${fn}>`):**
170
+ - **MUST be POST.** Leave unannotated (default) or export `export const method = 'POST'`.
171
+ - **NEVER export `export const method = 'GET'` for form actions.** The HTML renderer automatically emits `method="post"` and `formenctype` for form actions. Binding a `method = 'GET'` action to a form returns a `405 Method Not Allowed` at runtime and triggers a `webjs check` error (`form-action-not-a-get-action`).
172
+ - **NEVER add `method="get"` to a bound `<form action=${fn}>`.** WebJs manages form submission semantics automatically, and a bound form declaring `method="get"` is REFUSED at render (a thrown error, not a warning), because a GET sends no body for the action to read.
173
+
174
+ 2. **Programmatic / RPC Read Actions (Queries):**
175
+ - **ALWAYS export `export const method = 'GET'` for read-only queries.**
176
+ - When an action only fetches data (`await getUser(id)`), exporting `method = 'GET'` instructs the client RPC stub to issue an HTTP GET request with arguments encoded in query parameters.
177
+ - Enables browser/CDN caching, weak ETags (returning 304 Not Modified on cache hit), and HTTP `Cache-Control` header generation when paired with `export const cache = ...`.
178
+
179
+ 3. **Programmatic / RPC Write Actions (Mutations):**
180
+ - **Use POST, PUT, PATCH, or DELETE for writes.**
181
+ - Use default `POST` or explicitly export `PUT`/`PATCH`/`DELETE` for RESTful RPC calls (`await removeUser(id)`).
182
+ - Pair mutating actions with `export const invalidates = (args...) => ['tag']` to evict cached reads matching those tags upon completion.
183
+
158
184
  ```ts
159
185
  // modules/users/queries/get-user.server.ts: a cached, tagged GET read
160
186
  'use server';
@@ -165,8 +191,9 @@ export async function getUser(id: number) { return db.query.users.findFirst({ wh
165
191
  ```
166
192
 
167
193
  ```ts
168
- // a mutation evicts the tags it touches
194
+ // modules/users/actions/update-user.server.ts: a mutation evicting matching tags
169
195
  'use server';
196
+ export const method = 'PATCH'; // explicit verb
170
197
  export const invalidates = (id: number) => ['user:' + id];
171
198
  export const middleware = [requireAuth]; // async (ctx, next) => result; read ctx via actionContext()
172
199
  export async function updateUser(id: number, patch: Partial<User>) { /* ... */ }
@@ -110,6 +110,7 @@ The bound, refused, and allowed shapes in full. Every "no" row is a binding that
110
110
  | `action=${fn}` on any other tag | yes | `action` submits nothing off a `<form>`, so it is an ordinary attribute and the function would be stringified |
111
111
  | `action="${fn}"`, or a mixed `action="/x/${fn}"` | yes | quoting turns a binding hole back into a plain attribute |
112
112
  | `formaction=${fn}` unquoted, on a submitter, ANYWHERE | **no, it BINDS** | the second supported shape (#1207, #1307). A bound submitter carries its WHOLE submission: the identity rides the button's own `name`/`value` pair, the one channel a browser submits for the pressed button alone, and the renderer adds `formmethod="post"` and `formenctype="multipart/form-data"` to the button itself. So it works inside a bound form, an unbound form, a `method="get"` form, or a form with no method at all, and it asks NOTHING of the element around it. No `formaction` url is emitted, and the server takes the LAST `__webjs_action` entry |
113
+
113
114
  | `formaction=${fn}` on a submitter carrying its own `name` or `value` | yes | the identity IS that name/value pair, so both halves are already spoken for. Bind one action on the form and dispatch on `name="intent"` if you need the button's own value |
114
115
  | `formaction=${fn}` on a non-submit control, or `<input type="image">` | yes | `formaction` is inert on anything that does not submit, and an image submitter sends `name.x` / `name.y` coordinates instead of `name=value`, so the identity would never arrive |
115
116
  | `formaction=${fn}` on a submitter with `form="other"` | yes | it re-points the submitter at a different form owner, which may not be where the identity field it needs lives |
@@ -118,6 +119,8 @@ The bound, refused, and allowed shapes in full. Every "no" row is a binding that
118
119
  | `formmethod="dialog"` on a submitter that binds nothing | **no** | a native `<dialog>` dismissal, never a submission, so there is no body for the action to miss. It IS refused on a button that also binds an action, which is a straight contradiction |
119
120
  | a plain `formaction="/url"` on a submitter inside a bound form | **no** | it retargets the submission away from the page's bound action entirely, so where it goes and how is your business |
120
121
  | `.action=` on a native form | yes | the supported binding is the plain attribute, and a `.prop` on a native element drops at SSR, so accepting it would mean a form that submits under JS and does nothing without it |
122
+ | `export const method = 'GET'` on a form-bound action file | yes | form-bound actions strictly enforce `POST`. Binding a GET action to a form produces a 405 runtime refusal and `webjs check` error (`form-action-not-a-get-action`) |
123
+ | `method="get"` on a bound `<form action=${fn}>` | yes | WebJs supplies `method="post"` and `formenctype` automatically, and a bound form declaring `method="get"` is REFUSED at render (a thrown error, not a warning), because a GET sends no body for the action to read |
121
124
  | `.method=` / `.enctype=` / `.encoding=` on a BOUND form | yes | the same reason one level over. All three are reflected IDL attributes, so SSR drops the binding and emits `method="post"` while a browser ends at what you assigned. Write them as plain attributes |
122
125
  | a second `action=${fn}` on one form | yes | SSR emits the second as a plain url next to the identity field, the client takes the last. Bind exactly one, in either position |
123
126
  | a plain `action="/url"` beside the bound hole | yes | the hole drops only its OWN attribute, so SSR keeps the static one while the client removes it: without JS the browser posts to `/url`, with JS to the page |
@@ -134,6 +137,27 @@ That last row is the one to remember: quoting a binding hole turns it back into
134
137
 
135
138
  `.action=${fn}` on a native form is refused during SSR too, even though the property is dropped there and nothing could leak, so a page cannot render clean on the server and then throw on hydration.
136
139
 
140
+ **A bound submitter is self-sufficient and asks nothing of the form around it.** This is the shape people expect to have to wire up, and do not:
141
+
142
+ ```ts
143
+ // components/publish-button.ts <- the submitter lives here
144
+ class PublishButton extends WebComponent({}) {
145
+ render() { return html`<button formaction=${publishDraft}>Publish</button>`; }
146
+ }
147
+ PublishButton.register('publish-button');
148
+
149
+ // app/triage/page.ts <- the form lives here
150
+ // BOTH work. The button carries its own submission attributes.
151
+ html`<form><publish-button></publish-button></form>`;
152
+ html`<form action=${saveAll}><publish-button></publish-button></form>`;
153
+ ```
154
+
155
+ The renderer supplies the submission attributes at the level where the action is BOUND (#1307), so a bound `<button>` gains `formmethod="post"` and `formenctype` ON THE BUTTON, alongside the reserved `__webjs_action` identity riding the button's own `name`/`value` pair. A submitter's `formmethod` overrides the form's `method` per HTML, so the submission is a POST whatever the enclosing form declares, including no `method` at all or `method="get"`, and the identity travels in the body where the dispatcher reads it.
156
+
157
+ That is also why the renderer refuses only a SAME-ELEMENT contradiction (a bound submitter's own `formmethod` other than post, an `formenctype` the server cannot parse, `formmethod="dialog"`) and never a cross-element one. A component renders its template in a separate pass with no view of the host page, so the cross-element question is unanswerable at render time, and self-sufficiency leaves nothing for it to answer. One consequence: no `formaction` url is emitted, so the submission targets whatever the FORM targets, and a form declaring `action="/x"` sends its buttons there. The action still runs when `/x` is a PAGE route, since the identity travels in the body; against a `route.ts` or another origin nothing runs, which the dev-time client guard reports at submit time.
158
+
159
+ **Two runtime signals cover what is left.** In dev, submitting a form that carries an action identity it cannot deliver logs one `console.error` naming the fix, once per shape; it never throws, so the submission behaves exactly as it does in production. In production, both server-visible fingerprints reach the `onError` hook (the programmatic `createRequestHandler({ onError })` option and any sink an `instrumentation.{js,ts}` installed) with a code to group on: `WEBJS_FORM_SUBMITTED_AS_GET` for a page GET carrying the reserved field in its query string, and `WEBJS_FORM_ACTION_MISSING` for a form body carrying no identity at all. A BOUND submitter carries its own `formmethod="post"`, and a bound form is refused a `method="get"` outright, so what reaches the first one is a PLAIN submitter's `formmethod="get"`, which native precedence lets win and the renderer deliberately honours, or a hand-authored form carrying the reserved field. Both are detect-only, so no status changes, and both carry the submitted field NAMES and never the values.
160
+
137
161
  **Inside a component you may never see the error.** Per-component SSR error isolation contains the throw, so development shows an error box in place of the component and production renders it empty with the page still returning 200. A form that has silently vanished in production is this bug wearing a disguise; the message is in the server log. Nothing leaks either way.
138
162
 
139
163
  Two things that "renders it empty" understates, both worth knowing before you go looking:
@@ -288,4 +312,4 @@ Context providers publish on connect via `hostConnected`, which does not run at
288
312
 
289
313
  ### Vanilla DOM instead of Lit idioms
290
314
 
291
- WebJs components are Lit-shaped on purpose: the value is the declarative DX. Prefer a factory-declared reactive prop over `this.getAttribute`, a `signal` over a `state: true` prop for internal state, a `class=${...}` binding over `this.classList`, a `@click=${...}` binding over `this.addEventListener`, and `C.register('x')` over `customElements.define`. Vanilla DOM stays right only where the platform offers nothing declarative: `this.closest('ui-tabs')` for compound-component ancestor lookup (resolves at SSR too), slotted-content queries, global `document` / `window` listeners, and imperative `el.focus()`. This is a convention, not a lint rule.
315
+ WebJs components are Lit-shaped on purpose: the value is the declarative DX. Prefer a factory-declared reactive prop over `this.getAttribute`, a `signal` over a `state: true` prop for internal state, a `class=${...}` binding over `this.classList`, a `@click=${...}` binding over `this.addEventListener`, and `C.register('x')` over `customElements.define`. Vanilla DOM stays right only where the platform offers nothing declarative: `this.closest('ui-tabs')` for compound-component ancestor lookup (resolves at SSR too), slotted-content queries, global `document` / `window` listeners, and imperative `el.focus()`. This is a convention, not a lint rule. A global `document` / `window` LISTENER is one of those legitimate cases, because the event happens outside the element. A document QUERY is not: reaching for markup that another component rendered is the jQuery habit to drop, and for your OWN rendered node a `ref` replaces the selector entirely. The ownership rules at the top of `components.md` state the full test.
@@ -179,6 +179,8 @@ Three responses that are not the happy path:
179
179
 
180
180
  The submission is Origin-verified (the same `Sec-Fetch-Site` / `Origin` check the RPC endpoint applies), so a no-JS form needs no CSRF token field.
181
181
 
182
+ A submitter's own `formmethod` / `formenctype` / `formtarget` overrides the form's on PRESENCE, not on the value being non-empty, and the client router resolves them the same way (#1322). So `<button type="submit" formmethod="">` really does submit as a GET, because a present-but-empty enumerated attribute falls to its own invalid-value default rather than inheriting the form's `method="post"`.
183
+
182
184
  Refusals worth knowing: `formaction=${fn}` is supported on a `<button>` anywhere, bound form or not (#1307: the renderer gives the button its own `formmethod` and `formenctype`), and that button may not carry `name`, `value`, `form`, or a static `formaction` attribute (`<input type="submit">` is refused, because the identity needs its `value`, which is also its label). A bound form may not declare `method="get"`, and a function bound to `action=` that is not a `'use server'` export throws at render rather than producing a form that posts nowhere. See `muscle-memory-gotchas.md` for the full table.
183
185
 
184
186
  ## Error, loading, and 404 boundaries
@@ -190,7 +192,28 @@ Refusals worth knowing: `formaction=${fn}` is supported on a `<button>` anywhere
190
192
 
191
193
  Metadata routes (`sitemap.ts`, `robots.ts`, `manifest.ts`, `icon.ts`, `apple-icon.ts`, `opengraph-image.ts`, `twitter-image.ts`) live at app root or static segments and default-export a possibly-async function; `sitemap()` / `sitemapIndex()` from `@webjsdev/server` serialize spec-valid XML.
192
194
 
193
- The IMAGE metadata routes (`icon`, `apple-icon`, `opengraph-image`, `twitter-image`) default-export a function returning a `Response` with an explicit `content-type`, so an inline SVG needs no asset file (buildless). Then point `metadata` at the route via `openGraph.images` / `twitter.images` / `icons` (or drop a static file in `public/` instead).
195
+ The IMAGE metadata routes (`icon`, `apple-icon`, `opengraph-image`, `twitter-image`) default-export a function returning a `Response` with an explicit `content-type`, so an inline SVG needs no asset file (buildless).
196
+
197
+ **`icon` and `apple-icon` are LINKED for you.** An app that declares no `metadata.icons` gets `<link rel="icon" href="/icon">` and `<link rel="apple-touch-icon" href="/apple-icon">` in the head automatically, for whichever of the two routes it defines (base-path prefixed, since that is where the route answers). No `type` or `sizes` is asserted, because the route picks its content type at request time and the browser sniffs the served one.
198
+
199
+ Declaring `metadata.icons` **suppresses** the routes rather than merging with them, which is what Next does with its static icon files. So an app that outgrows a placeholder `app/icon.ts` names its real icons and the route stops being linked without having to be deleted:
200
+
201
+ ```ts
202
+ // app/layout.ts -> these win; /icon and /apple-icon are no longer linked
203
+ export const metadata = {
204
+ icons: {
205
+ icon: [
206
+ { url: '/public/favicon-192.png', type: 'image/png', sizes: '192x192' },
207
+ { url: '/public/favicon.svg', type: 'image/svg+xml', sizes: 'any' },
208
+ ],
209
+ apple: { url: '/public/apple-touch-icon.png', sizes: '180x180' },
210
+ },
211
+ };
212
+ ```
213
+
214
+ Declare a favicon through `metadata.icons` (or a metadata route), never as a hand-written `<link rel="icon">`: only the root layout may write a shell at all (invariant 8), so a hand-written tag is unavailable to every other layout. A `public/favicon.ico` needs no declaration either way, since the framework serves it at the origin root for crawlers that read no markup.
215
+
216
+ `opengraph-image` and `twitter-image` are NOT auto-linked (a preview image is a per-page editorial choice, not a site-wide default). Point `metadata` at those via `openGraph.images` / `twitter.images`.
194
217
 
195
218
  ```ts
196
219
  // app/opengraph-image.ts (OG is 1200x630; apple-icon 180x180)
@@ -70,7 +70,7 @@ Avoid `@apply`: it hides which utilities a class uses and creates a second sourc
70
70
 
71
71
  ### A design system for repeated PRIMITIVES: class helpers built on `@webjsdev/ui`
72
72
 
73
- An `html`-fragment helper is right for a repeated CHUNK of markup (the rubric above). For a repeated UI PRIMITIVE (button, input, card, badge) that needs variants and sizes, use a class helper instead: a function that returns a Tailwind class STRING you spread onto a native element. That is exactly what `@webjsdev/ui` ships (`buttonClass({ variant, size })`, `cardClass()`, `inputClass()`, `badgeClass({ variant })`), and it is what the scaffold gallery uses in `components/ui/`. To style a ONE-OFF that a variant does not cover (a circular icon button, a pill), compose the helper and override the bespoke bits with `cn()`: `cn(buttonClass({ variant: 'secondary', size: 'none' }), 'w-9 h-9 rounded-full')`. `cn` resolves Tailwind conflicts so a later class wins, including a shorthand over the axis it subsumes (`p-0` beats an earlier `px-4 py-2`), so an override just works. Conflicts are keyed on the CSS PROPERTY wherever `cn` can tell the properties apart, rather than on the shared class prefix, so the common prefix collisions do NOT evict: `cn('border-2', 'border-primary')` keeps both (a width and a colour), `cn('flex', 'flex-1')` keeps both (a `display` and a `flex-grow`, the shape an element that is both a flex container and a flex child needs), and an arbitrary value carrying a type hint is read as the property the hint names (`cn('shadow-lg', 'shadow-[color:red]')` keeps both). It is a small hand-rolled merger, not `tailwind-merge`, so some prefixes are still grouped coarsely and a less common pair can collide (`bg-clip-text` against `bg-primary`, `shadow-lg` against `shadow-red-500`). When an override has to win and you are unsure, pass the one class rather than layering, or install `clsx` + `tailwind-merge` and replace the helper (its header comment shows the swap). For an icon button prefer `size: 'none'` (it states "I supply my own box" by dropping the helper's padding + radius) over layering a `p-0` on top of the default size.
73
+ An `html`-fragment helper is right for a repeated CHUNK of markup (the rubric above). For a repeated UI PRIMITIVE (button, input, card, badge) that needs variants and sizes, use a class helper instead: a function that returns a Tailwind class STRING you spread onto a native element. That is exactly what `@webjsdev/ui` ships (`buttonClass({ variant, size })`, `cardClass()`, `inputClass()`, `badgeClass({ variant })`), and it is what the scaffold gallery uses in `components/ui/`. To style a ONE-OFF that a variant does not cover (a circular icon button, a pill), compose the helper and override the bespoke bits with `cn()`: `cn(buttonClass({ variant: 'secondary', size: 'none' }), 'w-9 h-9 rounded-full')`. `cn` resolves Tailwind conflicts so a later class wins, including a shorthand over the axis it subsumes (`p-0` beats an earlier `px-4 py-2`), so an override just works. Conflicts are keyed on the CSS PROPERTY wherever `cn` can tell the properties apart, rather than on the shared class prefix, so the common prefix collisions do NOT evict: `cn('border-2', 'border-primary')` keeps both (a width and a colour), `cn('flex', 'flex-1')` keeps both (a `display` and a `flex-grow`, the shape an element that is both a flex container and a flex child needs), `cn('shadow-lg', 'shadow-red-500')` keeps both (a box-shadow and its colour), `cn('bg-clip-text', 'bg-primary')` keeps both (a clip and a colour, so the gradient-text idiom survives a later background), and an arbitrary value carrying a type hint is read as the property the hint names (`cn('shadow-lg', 'shadow-[color:red]')` keeps both). It is a small hand-rolled merger, not `tailwind-merge`, so it is still coarse in two ways. A prefix outside the families it knows is not grouped at all, so both classes are emitted and the winner is left to compiled stylesheet order (`inset-shadow-sm` against `inset-shadow-red-500`, `ring-2` against `ring-red-500`). And where one prefix carries two properties it reads the value against Tailwind's DEFAULT scales, so a `@theme`-extended name it cannot know about can still be misread and evict the wrong class: a custom `--shadow-card` makes `shadow-card` a box-shadow, but `cn` sees an unfamiliar name under a prefix whose bare names are usually colours and treats it as one. When an override has to win and you are unsure, pass the one class rather than layering, or install `clsx` + `tailwind-merge` and replace the helper (its header comment shows the swap). For an icon button prefer `size: 'none'` (it states "I supply my own box" by dropping the helper's padding + radius) over layering a `p-0` on top of the default size.
74
74
 
75
75
  ```ts
76
76
  // components/ui/button.ts (npx webjsdev ui add button, themed to your app)
@@ -109,6 +109,19 @@ The default stack is a static compiled Tailwind stylesheet (`css:build` compiles
109
109
 
110
110
  **Two halves.** (1) `public/input.css` MAPS token names into Tailwind with `@theme inline` (`--color-background: var(--background)`), so `bg-background` resolves to `var(--background)`. That is infrastructure; leave it. (2) The root layout (`app/layout.ts`) DEFINES the values as plain CSS custom properties in a `<style>` block. That is your palette; make it your own. A freshly cleared app (after `npm run gallery:clear`) ships only the OS system-colour base (`Canvas` / `CanvasText`) with NO tokens, so building this palette is your first styling step.
111
111
 
112
+ **`@theme` and `@theme inline` differ in whether the token reaches `:root`, and the difference is silent.** Measured on `tailwindcss@4.3.0`, a token mapped in a theme block is emitted as a real `:root` custom property when:
113
+
114
+ | block | token used only through a utility (`border-border`) | token written as a raw `var(--color-x)` in any SCANNED file | token unused |
115
+ |---|---|---|---|
116
+ | `@theme` | emitted | emitted | not emitted |
117
+ | `@theme inline` | NOT emitted (the value is substituted into the utility) | emitted | not emitted |
118
+
119
+ The one cell that bites is `inline` plus utility-only usage. Nothing on the page can then inherit `--color-x`, so a raw `var(--color-x)` written somewhere Tailwind never scanned resolves to nothing and the declaration falls back to its initial value (a border or outline silently becomes `currentColor`).
120
+
121
+ "Scanned" is wider than it looks, and this is the part worth knowing: Tailwind scans source files as raw text, so a `var(--color-ring)` inside a component's `static styles` template DOES count and forces emission, exactly like one in the stylesheet. That is why the `@webjsdev/ui` kit theme works despite using `inline`. So the rule is not "shadow components need a plain `@theme`". It is: **if a token is only ever used through utilities, and something outside the scanned source needs to inherit it, map that token with a plain `@theme`.** Anything under a configured `@source` is scanned and needs no special handling.
122
+
123
+ Whichever form you use, a token nothing references is dropped in both, so an unused mapping is dead configuration rather than a safety net.
124
+
112
125
  **Light and dark, defined once (DRY).** Write each colour token ONE time with the native CSS `light-dark(LIGHT, DARK)` function and let `color-scheme` pick the side. The default `color-scheme: light dark` follows the OS; a `[data-theme]` attribute forces one. No duplicated light/dark blocks:
113
126
 
114
127
  ```html
@@ -189,6 +189,14 @@ WEBJS_ELIDE=0 npm run test:e2e
189
189
 
190
190
  A test that passes under one and fails under the other is a wrong verdict, and `webjs elision` tells you which module and on what evidence. If the component's interactivity is genuinely invisible to static analysis, the fix is `static interactive = true` on it; see `components.md` for what that override does and does not rescue.
191
191
 
192
+ ## Type-checking your tests (`webjs typecheck`)
193
+
194
+ Your tests are inside the tsconfig `include`, so `npm run typecheck` reads them (#1299). Treat a type error in a test as a failed gate, not a review catch: the checker sees a wrong argument shape or an unannotated parameter in a test the same way it sees one in `app/`.
195
+
196
+ Write them to the same bar as app code, then. No `any`, no blanket `@ts-expect-error`. When a test needs a complete props object the framework would normally build, put a small typed helper in `test/helpers/` and import it rather than reaching for a cast; a cast in a test silences the one thing that would have told you the call was wrong.
197
+
198
+ `.js` test files follow whatever `checkJs` says. With it off they are parsed and not checked, which is the usual setup for browser tests a real browser runs.
199
+
192
200
  ## Convention validation (`webjs check`)
193
201
 
194
202
  `npm run check` is the correctness validator. Every rule catches code that is wrong to ship, a crash, a security leak, a reactive prop that silently stops re-rendering, or a type-strip failure. Run it and fix every violation before considering the change done (`npm run check -- --json` for an agent loop, `npm run check -- --rules` to list the rules). It is separate from `CONVENTIONS.md`, which carries the customizable project conventions you follow by judgment.
@@ -62,19 +62,33 @@ Prefer explicit `.ts` extensions in imports. A `.js` specifier pointing at a `.t
62
62
  "module": "NodeNext",
63
63
  "moduleResolution": "NodeNext",
64
64
  "lib": ["ES2022", "DOM", "DOM.Iterable"],
65
+ "types": ["node"],
65
66
  "strict": true,
66
67
  "noEmit": true,
67
- "checkJs": true,
68
- "allowJs": true,
69
68
  "allowImportingTsExtensions": true,
70
69
  "skipLibCheck": true,
71
70
  "erasableSyntaxOnly": true
72
- }
71
+ },
72
+ "include": [
73
+ "app/**/*",
74
+ "components/**/*",
75
+ "modules/**/*",
76
+ "lib/**/*",
77
+ "test/**/*",
78
+ "middleware.js",
79
+ "middleware.ts",
80
+ ".webjs/routes.d.ts"
81
+ ],
82
+ "exclude": ["node_modules", ".webjs/vendor", "db/migrations"]
73
83
  }
74
84
  ```
75
85
 
76
86
  `erasableSyntaxOnly: true` is the non-negotiable line. It aligns the compiler's accepted syntax with the stripper's, so violations surface as diagnostics instead of a runtime 500.
77
87
 
88
+ `test/**/*` is in the `include` on purpose (#1299), the way Next / Remix / Astro's generated configs cover the whole tree. Leave it there. A test file outside the `include` is a file `webjs typecheck` never opens, so an implicitly-`any` parameter or a wrong argument shape in a test survives until somebody reads the line, which is exactly how one reached review here. Do not add a second `tsconfig.test.json` either: a config nobody remembers to run reproduces the same gap in a new place.
89
+
90
+ Note what is absent: `checkJs`, the flag mentioned at the top of this file for a JSDoc-typed codebase (it implies `allowJs`, so it is the only one you add). Turning it on makes `tsc` read your `.js` files, which is the point, but it also pulls in browser tests written as `.js`. Those run in a real browser through web-test-runner, so their test globals are not in scope for `tsc` and each one reports a `Cannot find name 'test'`. Turn it on deliberately, and give the browser tests a `types` entry or their own exclude when you do.
91
+
78
92
  ## Full-stack type safety
79
93
 
80
94
  ### The rule: derive the type, never `unknown` or `any`
@@ -72,5 +72,20 @@ dropdown-menu, hover-card, sonner, tabs, tooltip, plus toggle and toggle-group
72
72
  the tokens are missing (re-run `npx webjsdev ui init` or let `add` self-heal them).
73
73
  - Custom elements are display-only-safe at SSR and hydrate in the browser, the
74
74
  standard WebJs component model (`references/components.md`).
75
+ - A registry module should do no work at module scope, because the elision
76
+ analyser reads a module-scope call or a `document` reference as client work
77
+ and then the page that imports it ships whole (#1320). `cn` itself is clean,
78
+ so importing it never pins a page. Six modules still trip the analyser and DO
79
+ pin an importing page: `checkbox`, `radio-group`, `pagination`, `progress`,
80
+ `sonner`, `tabs`. The first two inject a stylesheet for real; the other four
81
+ are an analyser precision gap (an arrow with an expression body puts its call
82
+ at brace depth 0). Either way the page ships, so treat the list as fact rather
83
+ than as a technicality. Keep your own copies clean when you edit them, and run
84
+ `npx webjsdev elision`, which names the blocker whenever a page ships.
85
+ - `native-select`'s `<option>` colours ride the design tokens, not the module.
86
+ An app with no theme block gets the browser default `<option>` colours along
87
+ with everything else unstyled, fixed the same way (re-run `init`, or let `add`
88
+ plant the block). An app whose block predates the rule keeps the default until
89
+ the rule is added by hand, because `init` never rewrites an existing block.
75
90
 
76
91
  Full per-package reference lives in the installed `@webjsdev/ui/AGENTS.md`.
package/src/mcp-docs.js CHANGED
@@ -14,11 +14,15 @@
14
14
  * given its injected `{ docsDir, agentsPath, readFile }` deps, so it is testable
15
15
  * in-process without booting a server or touching the real filesystem.
16
16
  *
17
- * Docs resolution (so `npx @webjsdev/cli mcp` is self-contained): a published
18
- * install reads the corpus bundled under `<cli>/resources/references` (copied
19
- * at `prepack`, see `scripts/copy-mcp-resources.js`); a monorepo dev run falls
20
- * back to the repo-root skill. {@link resolveDocsLocation} encodes that
21
- * two-path lookup so source stays single (no committed duplicate docs).
17
+ * Docs resolution (so `npx @webjsdev/mcp` is self-contained): the APP's own
18
+ * installed `@webjsdev/mcp` corpus wins, then the running server's snapshot
19
+ * bundled under `<pkg>/resources/references` (copied at `prepack`, see
20
+ * `scripts/copy-mcp-resources.js`), then a repo-root-relative fallback, which is
21
+ * the skill on a monorepo dev run. {@link resolveDocsLocation} encodes that
22
+ * lookup so source stays single
23
+ * (no committed duplicate docs). `init` reports which of the three it served,
24
+ * from the `corpus.json` build stamp, and warns when the app's installed mcp is
25
+ * newer than the running server (#1319).
22
26
  *
23
27
  * @module mcp-docs
24
28
  */
@@ -30,38 +34,230 @@ import { fileURLToPath } from 'node:url';
30
34
  /** The URI scheme for a framework-docs resource: `webjs-docs://<name>`. */
31
35
  const DOCS_SCHEME = 'webjs-docs://';
32
36
 
37
+ /**
38
+ * The paths of one bundled corpus root (`<something>/resources`), tagged with
39
+ * which rung it is so a caller can say WHOSE docs it ended up serving.
40
+ *
41
+ * @param {string} root the `resources` directory
42
+ * @param {'app' | 'bundled'} corpusSource which rung this root is
43
+ * @returns {{ docsDir: string, agentsPath: string, skillPath: string, corpusPath: string, corpusSource: 'app' | 'bundled' }}
44
+ */
45
+ function bundleAt(root, corpusSource) {
46
+ return {
47
+ docsDir: join(root, 'references'),
48
+ agentsPath: join(root, 'AGENTS.md'),
49
+ skillPath: join(root, 'SKILL.md'),
50
+ corpusPath: join(root, 'corpus.json'),
51
+ corpusSource,
52
+ };
53
+ }
54
+
33
55
  /**
34
56
  * Resolve where the framework-docs corpus lives, plus the `AGENTS.md` contract
35
- * path. Tries the BUNDLED location first (a published `@webjsdev/mcp` ships
36
- * `resources/references/` + `resources/SKILL.md` + `resources/AGENTS.md` via `prepack`), then falls
37
- * back to the monorepo-root skill layout used in dev and
38
- * tests. Returns `{ docsDir, agentsPath }`; either path may not exist, callers
39
- * fail soft (an empty corpus is valid, never a crash).
57
+ * and build-stamp paths. Three rungs, highest first:
58
+ *
59
+ * 1. `<appDir>/node_modules/@webjsdev/mcp/resources`, the APP's own installed
60
+ * copy. It wins because it is version-matched to the framework the agent is
61
+ * editing, and so is the only corpus that can be correct about that app. A
62
+ * globally installed server otherwise serves a snapshot frozen at whatever
63
+ * it was published from, which taught reverted guidance for months (#1319).
64
+ * 2. `<pkgRoot>/resources`, the running server's own bundled snapshot (copied
65
+ * at `prepack`, see `scripts/copy-mcp-resources.js`).
66
+ * 3. A repo-root-relative path, which is the monorepo skill on a dev/test run.
67
+ * This rung is an unconditional FALLBACK, not a checkout probe, so a
68
+ * published install whose `resources/` bundle is missing lands here too,
69
+ * with `repoRoot` inside `node_modules` and an empty corpus. `corpusPath` is
70
+ * `null` because no bundle was found, which is why nothing downstream may
71
+ * assert a checkout on this rung.
72
+ *
73
+ * The rung-1 probe is a plain `existsSync` on the references directory rather
74
+ * than `require.resolve('@webjsdev/mcp')` from `appDir`, because what this needs
75
+ * is "a directory of markdown at a known path" and nothing more. Resolving the
76
+ * package would go through its `exports` map, and in this monorepo that lands on
77
+ * the workspace symlink back to `packages/mcp`, whose `resources/` does not
78
+ * exist in a checkout, so the resolve would fall to rung 3 anyway.
79
+ *
80
+ * Any returned path may not exist; callers fail soft (an empty corpus is valid,
81
+ * never a crash).
40
82
  *
41
83
  * @param {string} [moduleUrl] `import.meta.url` of the caller (defaults to this module)
42
- * @returns {{ docsDir: string, agentsPath: string }}
84
+ * @param {string} [appDir] the app being asked about; enables the rung-1 probe
85
+ * @returns {{ docsDir: string, agentsPath: string, skillPath: string, corpusPath: string | null, corpusSource: 'app' | 'bundled' | 'repo' }}
43
86
  */
44
- export function resolveDocsLocation(moduleUrl) {
87
+ export function resolveDocsLocation(moduleUrl, appDir) {
45
88
  const here = dirname(fileURLToPath(moduleUrl || import.meta.url));
46
89
  const pkgRoot = resolve(here, '..'); // packages/mcp/src -> packages/mcp
47
90
  const repoRoot = resolve(here, '..', '..', '..'); // -> monorepo root
48
91
 
49
- const bundledRefs = join(pkgRoot, 'resources', 'references');
50
- if (existsSync(bundledRefs)) {
51
- return {
52
- docsDir: bundledRefs,
53
- agentsPath: join(pkgRoot, 'resources', 'AGENTS.md'),
54
- skillPath: join(pkgRoot, 'resources', 'SKILL.md'),
55
- };
92
+ if (typeof appDir === 'string' && appDir) {
93
+ const appRoot = join(appDir, 'node_modules', '@webjsdev', 'mcp', 'resources');
94
+ if (existsSync(join(appRoot, 'references'))) return bundleAt(appRoot, 'app');
56
95
  }
96
+ const bundledRoot = join(pkgRoot, 'resources');
97
+ if (existsSync(join(bundledRoot, 'references'))) return bundleAt(bundledRoot, 'bundled');
98
+
57
99
  const skill = join(repoRoot, '.agents', 'skills', 'webjs');
58
100
  return {
59
101
  docsDir: join(skill, 'references'),
60
102
  agentsPath: join(repoRoot, 'AGENTS.md'),
61
103
  skillPath: join(skill, 'SKILL.md'),
104
+ corpusPath: null,
105
+ corpusSource: 'repo',
62
106
  };
63
107
  }
64
108
 
109
+ /**
110
+ * Compare two `major.minor.patch` versions, returning `-1` / `0` / `1`.
111
+ *
112
+ * Dependency-free by design (this package ships zero dependencies), and
113
+ * deliberately coarse: any prerelease or build suffix is stripped before
114
+ * comparing, a missing segment reads as `0`, and a non-numeric segment reads as
115
+ * `0`. Its only consumer is the staleness warning, where the question is "is the
116
+ * app's copy meaningfully newer", so ranking two prereleases of one patch is not
117
+ * a case that needs an answer.
118
+ *
119
+ * @param {string} a
120
+ * @param {string} b
121
+ * @returns {-1 | 0 | 1}
122
+ */
123
+ export function compareVersions(a, b) {
124
+ const parts = (v) => String(v == null ? '' : v).trim().split(/[-+]/)[0].split('.');
125
+ const pa = parts(a);
126
+ const pb = parts(b);
127
+ for (let i = 0; i < 3; i++) {
128
+ const x = Number(pa[i]);
129
+ const y = Number(pb[i]);
130
+ const nx = Number.isFinite(x) ? x : 0;
131
+ const ny = Number.isFinite(y) ? y : 0;
132
+ if (nx !== ny) return nx < ny ? -1 : 1;
133
+ }
134
+ return 0;
135
+ }
136
+
137
+ /**
138
+ * The `@webjsdev/mcp` version installed in `appDir`, or `null` when that cannot
139
+ * be known (no app dir, no install, an unreadable or unparseable manifest, no
140
+ * `version` field). Only mcp's own version is read: the corpus ships INSIDE this
141
+ * package, so mcp's version is the only one that bounds the docs, and reading
142
+ * the other `@webjsdev/*` manifests would multiply the failure modes without
143
+ * adding signal.
144
+ *
145
+ * @param {string} [appDir]
146
+ * @param {{ exists?: (p: string) => boolean, readFile: (p: string, enc: string) => Promise<string> }} deps
147
+ * @returns {Promise<string | null>}
148
+ */
149
+ export async function readAppMcpVersion(appDir, deps) {
150
+ if (typeof appDir !== 'string' || !appDir) return null;
151
+ const manifest = join(appDir, 'node_modules', '@webjsdev', 'mcp', 'package.json');
152
+ try {
153
+ if (deps.exists && !deps.exists(manifest)) return null;
154
+ const version = JSON.parse(await deps.readFile(manifest, 'utf8')).version;
155
+ return typeof version === 'string' && version ? version : null;
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * The one line naming WHICH docs corpus `init` is serving, so an agent reading a
163
+ * stale global install can see that it is stale (#1319). Never throws: a
164
+ * missing, unreadable, or malformed stamp degrades to the unstamped wording,
165
+ * because refusing to answer is strictly worse than answering with a caveat.
166
+ *
167
+ * @param {object} deps
168
+ * @returns {Promise<string>}
169
+ */
170
+ async function corpusLine(deps) {
171
+ // Rung 3 has no stamp, and it is a FALLBACK rather than a checkout probe, so
172
+ // this must not claim a checkout: a published install whose `resources/` is
173
+ // missing lands here too, with an empty corpus and no checkout to name.
174
+ if (!deps.corpusPath) return 'Docs corpus: no bundled snapshot; serving whatever docs this server resolved locally.';
175
+ const unstamped = 'Docs corpus: an unstamped @webjsdev/mcp bundled snapshot.';
176
+ try {
177
+ const stamp = JSON.parse(await deps.readFile(deps.corpusPath, 'utf8'));
178
+ if (!stamp || !stamp.version) return unstamped;
179
+ const pkg = stamp.package || '@webjsdev/mcp';
180
+ const date = typeof stamp.copiedAt === 'string' && stamp.copiedAt ? stamp.copiedAt.slice(0, 10) : 'an unknown date';
181
+ // A SHA the stamp could not capture drops the clause rather than printing a
182
+ // placeholder: the point of a provenance line is that no answer reads as no
183
+ // answer. Same reason the shape is checked, since only a real commit id
184
+ // resolves to a diff.
185
+ const sha = typeof stamp.sha === 'string' && /^[0-9a-f]{40}$/.test(stamp.sha) ? stamp.sha.slice(0, 7) : null;
186
+ return sha
187
+ ? `Docs corpus: ${pkg}@${stamp.version}, copied from webjsdev/webjs ${sha} on ${date}.`
188
+ : `Docs corpus: ${pkg}@${stamp.version}, copied on ${date}.`;
189
+ } catch {
190
+ return unstamped;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * What the warning says about WHOSE docs it just served, one clause per rung.
196
+ *
197
+ * A `Map` rather than an object literal, for the reason `docsDepsCache` is one:
198
+ * an object literal inherits `Object.prototype`, so a lookup keyed on an
199
+ * unexpected value like `constructor` or `toString` returns a truthy FUNCTION,
200
+ * the `||` fallback never fires, and the warning splices native-code source into
201
+ * its own text. A `Map` has no prototype keys, so the fallback covers every value
202
+ * that is not one of these three.
203
+ *
204
+ * The `repo` clause deliberately does NOT claim a live checkout. Rung 3 is an
205
+ * unconditional fallback rather than a checkout probe (see
206
+ * {@link resolveDocsLocation}), so it is also where a published install with no
207
+ * bundled `resources/` lands, and there the "checkout" does not exist and the
208
+ * corpus is empty.
209
+ */
210
+ const SERVED_CLAUSE = new Map([
211
+ ['app', "The docs below come from this app's own copy, so they match it; update the server so its TOOLS match too."],
212
+ ['bundled', "The docs below are the server's own older snapshot, not this app's copy."],
213
+ ['repo', "The docs below are whatever this server resolved locally, not this app's copy."],
214
+ ]);
215
+
216
+ /**
217
+ * The staleness warning, or `null` when there is nothing to warn about.
218
+ *
219
+ * Fires only when the app's installed `@webjsdev/mcp` is strictly NEWER than the
220
+ * running server's. Equal or lower is silent: a newer server reading an older app
221
+ * is the ordinary shape and is not a defect. It warns rather than failing,
222
+ * because a read-only knowledge tool that refuses to answer sends the agent back
223
+ * to its training data, which is the thing the caveat exists to correct.
224
+ *
225
+ * Two things the wording is careful about, because the obvious phrasings are both
226
+ * wrong:
227
+ *
228
+ * - It does NOT name a global install as the thing to update. Nothing here can
229
+ * observe how the server was started, and the shipped configurations are an
230
+ * `npx @webjsdev/mcp` (whose staleness is a package cache) and `webjs mcp`
231
+ * (whose staleness is `@webjsdev/cli`'s own dependency), neither of which a
232
+ * `npm i -g` would fix.
233
+ * - It does NOT promise the docs below are the app's. The warning's probe is the
234
+ * app's `package.json`, while the corpus rung's probe is that install's
235
+ * `resources/references`, and the two can disagree: a workspace-linked or
236
+ * otherwise resources-less install has a manifest to read but no corpus to
237
+ * serve, so the corpus falls through to the server's older snapshot. That case
238
+ * gets an extra clause naming what was actually served, rather than a claim
239
+ * that contradicts the corpus line printed directly beneath it.
240
+ *
241
+ * @param {object} deps
242
+ * @returns {Promise<string | null>}
243
+ */
244
+ async function staleWarning(deps) {
245
+ const server = deps.serverVersion;
246
+ // '0.0.0' is the sentinel both entry points fall back to when they cannot read
247
+ // their own manifest, so it means "unknown", not "older than everything".
248
+ if (!server || server === '0.0.0') return null;
249
+ const app = await readAppMcpVersion(deps.appDir, deps);
250
+ if (!app || compareVersions(app, server) <= 0) return null;
251
+ const served = SERVED_CLAUSE.get(deps.corpusSource) || "The docs below may not be this app's copy.";
252
+ return (
253
+ `Warning: this MCP server is @webjsdev/mcp@${server}, but this app has @webjsdev/mcp@${app}, ` +
254
+ `so the server may be stale. ${served} ` +
255
+ 'Update whichever copy runs this server: a global install (npm i -g @webjsdev/mcp@latest, ' +
256
+ 'or bun add -g @webjsdev/mcp), the package cache behind npx @webjsdev/mcp, ' +
257
+ '@webjsdev/cli when the server is started as webjs mcp, or the checkout it runs from.'
258
+ );
259
+ }
260
+
65
261
  /**
66
262
  * A doc's logical name from its filename: `lit-muscle-memory-gotchas.md` ->
67
263
  * `lit-muscle-memory-gotchas`. The `AGENTS.md` contract keeps its own name.
@@ -191,6 +387,8 @@ export function sectionByHeading(md, headingRe) {
191
387
  export async function initText(deps) {
192
388
  let agents = '';
193
389
  try { agents = await deps.readFile(deps.agentsPath, 'utf8'); } catch { agents = ''; }
390
+ const corpus = await corpusLine(deps);
391
+ const warning = await staleWarning(deps);
194
392
  const execModel = sectionByHeading(agents, /^##\s+Execution model/im);
195
393
  const invariants = sectionByHeading(agents, /^##\s+Invariants/im);
196
394
 
@@ -217,6 +415,11 @@ export async function initText(deps) {
217
415
  const parts = [
218
416
  '# webjs: read first',
219
417
  '',
418
+ // Provenance first, so an agent reading a months-old global install sees
419
+ // which docs it is holding before it reads a word of them (#1319).
420
+ ...(warning ? [warning, ''] : []),
421
+ corpus,
422
+ '',
220
423
  router,
221
424
  '',
222
425
  execModel || '(execution-model section unavailable)',
package/src/mcp.js CHANGED
@@ -50,25 +50,46 @@ const RESERVED_CONFIG = new Set(['method', 'cache', 'tags', 'invalidates', 'vali
50
50
  * into an agent-friendly shape. Descriptions are crisp so a model picks the
51
51
  * right tool without reading source.
52
52
  */
53
+ /**
54
+ * The `appDir` property, shared by every tool that is scoped to one app.
55
+ *
56
+ * `init` and `docs` carry it too, not just the introspection tools: the docs
57
+ * corpus is resolved from the app being asked about (#1319), so a tool that did
58
+ * not ADVERTISE `appDir` could never receive one from a conforming client, and
59
+ * the app-corpus rung would be reachable only from hand-written JSON-RPC.
60
+ */
61
+ const APPDIR_PROP = {
62
+ type: 'string',
63
+ description: 'App directory to introspect. Defaults to the server cwd.',
64
+ };
65
+
53
66
  /** The shared input schema for the introspection tools: an optional appDir override. */
54
67
  const APPDIR_SCHEMA = {
68
+ type: 'object',
69
+ properties: { appDir: { ...APPDIR_PROP } },
70
+ required: [],
71
+ };
72
+
73
+ /** `init` takes only the optional appDir, which selects whose docs corpus it reports. */
74
+ const INIT_SCHEMA = {
55
75
  type: 'object',
56
76
  properties: {
57
77
  appDir: {
58
- type: 'string',
59
- description: 'App directory to introspect. Defaults to the server cwd.',
78
+ ...APPDIR_PROP,
79
+ description: "App directory whose docs corpus to read. Defaults to the server cwd.",
60
80
  },
61
81
  },
62
82
  required: [],
63
83
  };
64
84
 
65
- /** `init` takes no input. */
66
- const INIT_SCHEMA = { type: 'object', properties: {}, required: [] };
67
-
68
- /** `docs` takes an optional topic OR a free-text query. */
85
+ /** `docs` takes an optional topic OR a free-text query, plus the optional appDir. */
69
86
  const DOCS_SCHEMA = {
70
87
  type: 'object',
71
88
  properties: {
89
+ appDir: {
90
+ ...APPDIR_PROP,
91
+ description: "App directory whose docs corpus to read. Defaults to the server cwd.",
92
+ },
72
93
  topic: {
73
94
  type: 'string',
74
95
  description: 'A doc name (e.g. components, recipes, lit-muscle-memory-gotchas, AGENTS). Returns the full doc.',
@@ -500,22 +521,46 @@ export async function runMcpServer(opts) {
500
521
  const runners = makeToolRunners(deps);
501
522
 
502
523
  // The docs corpus deps for the knowledge layer (#376): resources / prompts /
503
- // init / docs. Injectable for tests; otherwise resolved from the bundled
504
- // (published) or repo-root (dev) docs and node fs.
505
- let docsDeps = opts.docsDeps;
506
- if (!docsDeps) {
507
- const loc = resolveDocsLocation(import.meta.url);
524
+ // init / docs. Injectable for tests; otherwise resolved from the app's own
525
+ // installed corpus, the bundled (published) snapshot, or the repo-root (dev)
526
+ // docs, plus node fs.
527
+ //
528
+ // Resolved PER CALL and memoized by appDir (#1319), not once at boot. `appDir`
529
+ // is a per-call tool argument, so a boot-time resolution would pin the corpus
530
+ // to the launch directory forever and defeat the app-corpus rung for any host
531
+ // that overrides it. `resources/list` and `resources/read` carry no appDir in
532
+ // the MCP protocol, so they resolve from `cwd`, which is the same value a
533
+ // `tools/call` with no `appDir` argument defaults to; the surfaces can only
534
+ // diverge when a caller deliberately asks about a different app.
535
+ let docsFs = null;
536
+ if (!opts.docsDeps) {
508
537
  const { readFile } = await import('node:fs/promises');
509
538
  const { readdirSync, existsSync } = await import('node:fs');
510
- docsDeps = {
511
- docsDir: loc.docsDir,
512
- agentsPath: loc.agentsPath,
513
- skillPath: loc.skillPath,
514
- listDir: readdirSync,
515
- exists: existsSync,
516
- readFile,
517
- };
539
+ docsFs = { listDir: readdirSync, exists: existsSync, readFile };
518
540
  }
541
+ /** @type {Map<string, object>} */
542
+ const docsDepsCache = new Map();
543
+ /** The docs deps for one appDir. An injected `opts.docsDeps` wins for every appDir. */
544
+ const docsDepsFor = (dir) => {
545
+ if (opts.docsDeps) return opts.docsDeps;
546
+ const key = typeof dir === 'string' ? dir : '';
547
+ let built = docsDepsCache.get(key);
548
+ if (!built) {
549
+ const loc = resolveDocsLocation(import.meta.url, key);
550
+ built = {
551
+ docsDir: loc.docsDir,
552
+ agentsPath: loc.agentsPath,
553
+ skillPath: loc.skillPath,
554
+ corpusPath: loc.corpusPath,
555
+ corpusSource: loc.corpusSource,
556
+ appDir: key,
557
+ serverVersion: version,
558
+ ...docsFs,
559
+ };
560
+ docsDepsCache.set(key, built);
561
+ }
562
+ return built;
563
+ };
519
564
 
520
565
  // The `source` tool (#378): read the framework's own source from
521
566
  // node_modules/@webjsdev/*/src (no-build, so it is the real JSDoc). Roots are
@@ -581,12 +626,12 @@ export async function runMcpServer(opts) {
581
626
 
582
627
  // Knowledge layer (#376): the framework docs as MCP resources.
583
628
  if (method === 'resources/list') {
584
- return rpcResult(id, { resources: listResources(docsDeps) });
629
+ return rpcResult(id, { resources: listResources(docsDepsFor(cwd)) });
585
630
  }
586
631
  if (method === 'resources/read') {
587
632
  const uri = ((msg && msg.params) || {}).uri;
588
633
  try {
589
- const r = await readResource(docsDeps, uri);
634
+ const r = await readResource(docsDepsFor(cwd), uri);
590
635
  return rpcResult(id, { contents: [r] });
591
636
  } catch (e) {
592
637
  return rpcError(id, -32602, e && e.message ? e.message : String(e));
@@ -621,9 +666,9 @@ export async function runMcpServer(opts) {
621
666
  try {
622
667
  const result = isKnowledgeTool
623
668
  ? name === 'init'
624
- ? await initText(docsDeps)
669
+ ? await initText(docsDepsFor(appDir))
625
670
  : name === 'docs'
626
- ? await searchDocs(docsDeps, args)
671
+ ? await searchDocs(docsDepsFor(appDir), args)
627
672
  : await runSourceTool(sourceDeps, args)
628
673
  : isUiTool
629
674
  ? runUiTool(uiDeps, args)