@webjsdev/cli 0.10.53 → 0.10.54

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
@@ -68,8 +68,8 @@ the CLI gives you `webjs ui` automatically. See
68
68
 
69
69
  The scaffold seeds opinionated defaults so AI agents produce consistent code:
70
70
 
71
- - `AGENTS.md` + `CONVENTIONS.md` (the machine-readable contract)
72
- - `.claude/`, `.cursorrules`, `.agents/rules/workflow.md` (Antigravity), `.github/copilot-instructions.md`
71
+ - `AGENTS.md` + `CONVENTIONS.md` + `.agents/skills/webjs/` (single cross-agent source of truth)
72
+ - `.agents/rules/workflow.md` & `.claude/` protective hooks
73
73
  - `test/<feature>/` (with optional `browser/` / `e2e/` subfolders per kind) with example tests
74
74
  - Tailwind CSS via CLI (no browser runtime at build time)
75
75
  - TypeScript, `.editorconfig`, `.gitignore`
package/bin/webjs.js CHANGED
@@ -9,6 +9,7 @@ import { checkNodeInline, nodeInlineMessage } from '../lib/node-preflight.js';
9
9
  import { loadAppEnv, resolvePort } from '../lib/port.js';
10
10
  import { planDevSupervisor } from '../lib/dev-supervisor.js';
11
11
  import { checkAppName, appNameErrorMessage } from '../lib/app-name.js';
12
+ import { findCheckTarget, notAnAppMessage, notAnAppJson } from '../lib/check-target.js';
12
13
 
13
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
15
  const [cmd, ...rest] = process.argv.slice(2);
@@ -722,6 +723,22 @@ async function main() {
722
723
  break;
723
724
  }
724
725
 
726
+ // #1301: `webjs check` is an APP-level tool. At a workspace root it
727
+ // walks every package's tests and every app at once and reports
728
+ // cross-app collisions that no single runtime ever sees (67 false
729
+ // findings at this repo's root). Refuse instead, naming the member
730
+ // apps to run it in. Exit 1: an agent gates on the exit status, and
731
+ // 0 would read as "clean", the exact false signal this fixes.
732
+ const target = await findCheckTarget(process.cwd());
733
+ if (!target.isApp) {
734
+ if (rest.includes('--json')) {
735
+ console.log(JSON.stringify(notAnAppJson(process.cwd(), target.workspaceApps)));
736
+ } else {
737
+ console.error(notAnAppMessage(process.cwd(), target.workspaceApps));
738
+ }
739
+ process.exit(1);
740
+ }
741
+
725
742
  const violations = await checkConventions(process.cwd());
726
743
 
727
744
  // --json emits the raw structured violations + a summary count as JSON,
@@ -0,0 +1,145 @@
1
+ /**
2
+ * `webjs check` invocation-target guard (#1301).
3
+ *
4
+ * `webjs check` is an APP-level tool: every rule assumes one application,
5
+ * meaning one module graph, one custom-element registry, one runtime. Run at a
6
+ * workspace root it walks whatever JS/TS happens to live under that path (two
7
+ * apps, every package's test suite, editor fixtures, the scaffold templates)
8
+ * and reports collisions no single runtime ever sees. At this repo's root that
9
+ * was 67 findings, all false.
10
+ *
11
+ * The predicate is the presence of an `app/` directory, nothing else. That is
12
+ * the same test `check.js` already applies per-rule, `app/` cannot be renamed
13
+ * (AGENTS.md "App layout"), and BOTH scaffold templates create it. Next.js
14
+ * refuses on the identical predicate in
15
+ * `packages/next/src/lib/find-pages-dir.ts`. A `workspaces` key is NOT part of
16
+ * the predicate (a directory with no `app/` is not an app either way); it only
17
+ * enriches the MESSAGE with the member apps to run instead.
18
+ *
19
+ * PURE apart from directory reads: it never prints and never exits. The bin
20
+ * owns rendering and the exit code.
21
+ *
22
+ * @module check-target
23
+ */
24
+
25
+ import { statSync } from 'node:fs';
26
+ import { readFile, glob } from 'node:fs/promises';
27
+ import { join } from 'node:path';
28
+
29
+ /**
30
+ * @typedef {{ isApp: boolean, workspaceApps: string[] }} CheckTarget
31
+ */
32
+
33
+ /**
34
+ * Whether `dir` holds an `app/` DIRECTORY. A plain file named `app` is not one,
35
+ * and `existsSync` alone would call it one, so the type is checked. A broken
36
+ * symlink or an unreadable parent throws out of `statSync` rather than
37
+ * returning false, so it is caught: an unreadable path is not an app either.
38
+ *
39
+ * @param {string} dir
40
+ * @returns {boolean}
41
+ */
42
+ function hasAppDir(dir) {
43
+ try {
44
+ return statSync(join(dir, 'app')).isDirectory();
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Classify `cwd` as a checkable app or not, and (when it declares npm
52
+ * workspaces) list the member directories that ARE apps, sorted, as
53
+ * cwd-relative POSIX paths.
54
+ *
55
+ * @param {string} cwd
56
+ * @returns {Promise<CheckTarget>}
57
+ */
58
+ export async function findCheckTarget(cwd) {
59
+ if (hasAppDir(cwd)) return { isApp: true, workspaceApps: [] };
60
+ return { isApp: false, workspaceApps: await workspaceApps(cwd) };
61
+ }
62
+
63
+ /**
64
+ * Expand `package.json` `workspaces` (the array form and yarn's
65
+ * `{ packages: [...] }` form) and keep the members that have an `app/`
66
+ * directory. Any read / parse failure yields an empty list: the message
67
+ * degrades to the generic form and the refusal still stands.
68
+ *
69
+ * @param {string} cwd
70
+ * @returns {Promise<string[]>}
71
+ */
72
+ async function workspaceApps(cwd) {
73
+ let patterns;
74
+ try {
75
+ const pkg = JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8'));
76
+ const ws = pkg.workspaces;
77
+ patterns = Array.isArray(ws) ? ws : Array.isArray(ws?.packages) ? ws.packages : null;
78
+ } catch {
79
+ return [];
80
+ }
81
+ if (!patterns) return [];
82
+ /** @type {Set<string>} */
83
+ const apps = new Set();
84
+ for (const pattern of patterns) {
85
+ if (typeof pattern !== 'string') continue;
86
+ try {
87
+ for await (const match of glob(pattern, { cwd })) {
88
+ // `glob` yields whatever matched, files included, so the app test is
89
+ // what filters a stray same-named file out too.
90
+ if (hasAppDir(join(cwd, match))) apps.add(match.split('\\').join('/'));
91
+ }
92
+ } catch {
93
+ // A malformed pattern drops out of the listing, never out of the refusal.
94
+ }
95
+ }
96
+ return [...apps].sort();
97
+ }
98
+
99
+ /**
100
+ * The human refusal, for stderr.
101
+ *
102
+ * @param {string} cwd
103
+ * @param {string[]} apps
104
+ * @returns {string}
105
+ */
106
+ export function notAnAppMessage(cwd, apps) {
107
+ const lines = [
108
+ 'webjs check: this directory is not a WebJs app, so nothing was checked.',
109
+ '',
110
+ ` ${cwd}`,
111
+ '',
112
+ 'There is no `app/` directory here. Every check assumes ONE application',
113
+ '(one module graph, one custom-element registry, one runtime), so running',
114
+ 'them over a workspace root reports collisions no single runtime ever sees.',
115
+ '',
116
+ ];
117
+ if (apps.length) {
118
+ lines.push('This is a workspace root. Run the check inside each app:', '');
119
+ for (const app of apps) lines.push(` ( cd ${app} && npx webjs check )`);
120
+ } else {
121
+ lines.push('Change into your app directory (the one holding `app/`) and re-run.');
122
+ }
123
+ lines.push('', '`webjs check --rules` lists the rules and works from anywhere.');
124
+ return lines.join('\n');
125
+ }
126
+
127
+ /**
128
+ * The `--json` refusal. It carries NO `violations` key on purpose: a consumer
129
+ * that ignores the exit code and reads `report.violations.length` must throw
130
+ * rather than be told the workspace is clean.
131
+ *
132
+ * @param {string} cwd
133
+ * @param {string[]} apps
134
+ * @returns {{ error: { code: string, message: string, cwd: string, apps: string[] } }}
135
+ */
136
+ export function notAnAppJson(cwd, apps) {
137
+ return {
138
+ error: {
139
+ code: 'NOT_AN_APP',
140
+ message: 'No `app/` directory here, so webjs check has no application to check.',
141
+ cwd,
142
+ apps,
143
+ },
144
+ };
145
+ }
package/lib/create.js CHANGED
@@ -12,13 +12,14 @@
12
12
  */
13
13
 
14
14
  import { mkdir, writeFile, readFile, cp } from 'node:fs/promises';
15
- import { join, resolve, dirname } from 'node:path';
15
+ import { join, resolve, dirname, relative } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { existsSync } from 'node:fs';
18
18
  import { createRequire } from 'node:module';
19
19
  import { spawnSync } from 'node:child_process';
20
20
  import { bunifyProse, bunifyDockerfile, bunifyCompose, bunifyCi } from './runtime-rewrite.js';
21
21
  import { assertValidAppName, toDatabaseName } from './app-name.js';
22
+ import { isGalleryAppShellFile } from './gallery-shell-files.js';
22
23
 
23
24
  /**
24
25
  * Detect which package manager invoked us. Reads `npm_config_user_agent`,
@@ -116,13 +117,20 @@ const UI_REGISTRY_ROOT = resolveUiRegistryRoot();
116
117
  * Ships verbatim (no `{{APP_NAME}}` substitution): the examples are self-
117
118
  * contained and reference only `@webjsdev/*`, drizzle, `#db/*`, and each other.
118
119
  * The scaffold's own `app/page.ts` / `app/layout.ts` are written AFTER this and
119
- * the gallery ships neither, so there is no clobber. `cp` merges into existing
120
- * `app/` and `modules/` dirs rather than replacing them.
120
+ * are filtered out of the copy here (see GALLERY_APP_SHELL_FILES), so there is
121
+ * no clobber. `cp` merges into existing `app/` and `modules/` dirs rather than
122
+ * replacing them.
123
+ *
124
+ * The source is the canonical repo-root `gallery/` app in monorepo dev, and the
125
+ * `templates/gallery/` copy `prepack` bundles into the tarball when installed
126
+ * from npm. Both are filtered identically, so the two modes emit the same app.
121
127
  *
122
128
  * @param {string} appDir
123
129
  */
124
130
  async function copyGallery(appDir) {
125
- const galleryDir = join(TEMPLATES, 'gallery');
131
+ const bundledGallery = join(TEMPLATES, 'gallery');
132
+ const repoRootGallery = resolve(__dirname, '..', '..', '..', 'gallery');
133
+ const galleryDir = existsSync(bundledGallery) ? bundledGallery : repoRootGallery;
126
134
  // `test` carries the auth card's real request-pipeline test (test/auth); it
127
135
  // ships with the gallery and is pruned by gallery:clear alongside the card.
128
136
  // `components` carries the gallery's EXAMPLE design system (components/ui/ class
@@ -135,7 +143,15 @@ async function copyGallery(appDir) {
135
143
  // the ui bootstrap's cn.ts/dom.ts (written earlier); gallery:clear removes just
136
144
  // ui.ts. cp is recursive-merge, so the pre-written lib/utils/ files are kept.
137
145
  for (const sub of ['app', 'modules', 'test', 'components', 'lib']) {
138
- await cp(join(galleryDir, sub), join(appDir, sub), { recursive: true });
146
+ const srcSub = join(galleryDir, sub);
147
+ if (!existsSync(srcSub)) continue;
148
+ await cp(srcSub, join(appDir, sub), {
149
+ recursive: true,
150
+ // Skip the gallery's own app shell (root layout, home page, theme toggle,
151
+ // cn.ts). Those exist because gallery/ is a live app; the scaffold writes
152
+ // its own, with the app's displayName and the ui-registry cn.ts.
153
+ filter: (src) => !isGalleryAppShellFile(relative(galleryDir, src)),
154
+ });
139
155
  }
140
156
  }
141
157
 
@@ -568,26 +584,12 @@ export async function scaffoldApp(name, cwd, opts = {}) {
568
584
  // --- Templates (AGENTS.md, CONVENTIONS.md, CLAUDE.md, test files, Claude hooks) ---
569
585
 
570
586
  const templateFiles = [
571
- // Single cross-agent source: a thin AGENTS.md points at the skill; the
587
+ // Single cross-agent source: AGENTS.md points at .agents/skills/webjs/; the
572
588
  // .agents/rules workflow rules and the Claude enforcement hooks back it up.
573
589
  'AGENTS.md',
574
590
  'CONVENTIONS.md',
575
591
  '.agents/rules/workflow.md',
576
- // Per-agent files. Content is single-source (AGENTS.md + the skill); these
577
- // are thin bridges plus each tool's own config and commit-nudge hook. Claude
578
- // Code (CLAUDE.md @-imports AGENTS.md), Gemini CLI (GEMINI.md), Copilot in VS
579
- // Code (copilot-instructions.md). Cursor / opencode / Antigravity read
580
- // AGENTS.md natively; Cursor also gets a .cursorrules bridge, and each of
581
- // Cursor / Gemini / opencode ships a "commit often" nudge hook.
582
592
  'CLAUDE.md',
583
- 'GEMINI.md',
584
- '.github/copilot-instructions.md',
585
- '.cursorrules',
586
- '.cursor/hooks.json',
587
- '.cursor/hooks/nudge-uncommitted.sh',
588
- '.gemini/settings.json',
589
- '.gemini/hooks/nudge-uncommitted.sh',
590
- '.opencode/plugins/nudge-uncommitted.ts',
591
593
  // Claude Code config + the protective enforcement hooks (no design ceremony).
592
594
  '.claude.json',
593
595
  '.claude/settings.json',
@@ -629,7 +631,7 @@ export async function scaffoldApp(name, cwd, opts = {}) {
629
631
  // rewrites; the three infra files get their file-specific transform. On Node,
630
632
  // every file is copied byte-identical (the map is empty).
631
633
  const PROSE_REWRITE = new Set([
632
- 'AGENTS.md', 'CLAUDE.md', 'CONVENTIONS.md', '.cursorrules',
634
+ 'AGENTS.md', 'CLAUDE.md', 'CONVENTIONS.md',
633
635
  '.agents/rules/workflow.md',
634
636
  'test/hello/browser/hello.test.js', 'test/hello/e2e/hello.test.ts',
635
637
  ]);
@@ -1376,13 +1378,16 @@ import { cardClass } from '#components/ui/card.ts';
1376
1378
  import { badgeClass } from '#components/ui/badge.ts';
1377
1379
  // The demo index is defined once in modules/gallery/nav.ts (the same source the
1378
1380
  // left sidebar reads), so the home cards and the sidebar can never drift.
1379
- import { FEATURES, EXAMPLES } from '#modules/gallery/nav.ts';
1381
+ import { featureList, EXAMPLES } from '#modules/gallery/nav.ts';
1380
1382
 
1381
1383
  export const metadata = {
1382
1384
  title: '${displayName}',
1383
1385
  };
1384
1386
 
1385
1387
  export default function Home() {
1388
+ // Flattened HERE rather than at module scope. A top-level call is a module
1389
+ // side effect, which would ship this page to the browser for nothing.
1390
+ const FEATURES = featureList();
1386
1391
  return html\`
1387
1392
  <div class="py-8 flex flex-col items-center gap-16">
1388
1393
  <!-- Hero -->
package/lib/doctor.js CHANGED
@@ -1396,11 +1396,19 @@ function unmarkedStylesheetHref(tag, basePath = '') {
1396
1396
  * Ported rather than imported because that helper is not on `@webjsdev/server`'s
1397
1397
  * public surface, and because doctor must stay usable when the framework does
1398
1398
  * not resolve from the app dir at all (the #954 fresh-worktree case this same
1399
- * command exists to diagnose). `test/cli/doctor.test.mjs` pins the forms.
1399
+ * command exists to diagnose). The port is intentional and stays. What makes it
1400
+ * safe is that the drift is tested rather than trusted.
1401
+ *
1402
+ * `test/cli/base-path-parity.test.mjs` feeds one input table through BOTH this
1403
+ * function and the server's `readBasePath`, asserting they agree with each other
1404
+ * and with the expected value. Change either side without the other and it reds.
1405
+ * So edit this body only alongside `packages/server/src/base-path.js`, and run
1406
+ * that test. (`test/cli/doctor.test.mjs` covers the check that consumes this,
1407
+ * not the normalization forms themselves.)
1400
1408
  * @param {string} appDir
1401
1409
  * @returns {Promise<string>}
1402
1410
  */
1403
- async function readAppBasePath(appDir) {
1411
+ export async function readAppBasePath(appDir) {
1404
1412
  let raw;
1405
1413
  try {
1406
1414
  const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The files the canonical `gallery/` app owns only because it is a RUNNABLE
3
+ * WebJs app, and which the scaffold (`webjs create`) generates itself.
4
+ *
5
+ * `gallery/` is both the single source of the scaffold's feature gallery AND a
6
+ * live app deployed on its own, so it needs a root layout, a home page, a theme
7
+ * toggle, and the `cn()` helper. The scaffold writes all four itself, with
8
+ * things the gallery's copies cannot carry: the app's `displayName`, the
9
+ * `cspNonce()` wiring, `LayoutProps` typing, the `metadata.icons` favicon, and
10
+ * a `cn.ts` read verbatim from the `@webjsdev/ui` registry so `webjs ui add`
11
+ * stays in lockstep with the kit.
12
+ *
13
+ * So they are the ONE part of `gallery/` that is not scaffold payload. They are
14
+ * dropped from the published bundle (`scripts/sync-scaffold-gallery.mjs`) and
15
+ * skipped by `copyGallery()` so monorepo-dev and installed-npm scaffolding emit
16
+ * byte-identical apps. Paths are POSIX-relative to the gallery root.
17
+ *
18
+ * @type {readonly string[]}
19
+ */
20
+ export const GALLERY_APP_SHELL_FILES = Object.freeze([
21
+ 'app/layout.ts',
22
+ 'app/page.ts',
23
+ 'components/theme-toggle.ts',
24
+ 'lib/utils/cn.ts',
25
+ ]);
26
+
27
+ /**
28
+ * True when `relPath` (POSIX-relative to the gallery root) is a gallery-only
29
+ * app-shell file rather than scaffold payload.
30
+ *
31
+ * @param {string} relPath
32
+ * @returns {boolean}
33
+ */
34
+ export function isGalleryAppShellFile(relPath) {
35
+ return GALLERY_APP_SHELL_FILES.includes(relPath.split('\\').join('/'));
36
+ }
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@webjsdev/cli",
3
- "version": "0.10.53",
3
+ "version": "0.10.54",
4
4
  "type": "module",
5
5
  "description": "The CLI for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Runs the dev and production servers, scaffolds apps, validates conventions, and drives the database. Node 24+ or Bun.",
6
6
  "bin": {
7
7
  "webjs": "bin/webjs.js"
8
8
  },
9
9
  "scripts": {
10
- "prepack": "node ../../scripts/sync-scaffold-skill.mjs",
11
- "postpack": "node ../../scripts/sync-scaffold-skill.mjs --clean"
10
+ "prepack": "node ../../scripts/sync-scaffold-skill.mjs && node ../../scripts/sync-scaffold-gallery.mjs",
11
+ "postpack": "node ../../scripts/sync-scaffold-skill.mjs --clean && node ../../scripts/sync-scaffold-gallery.mjs --clean"
12
12
  },
13
13
  "files": [
14
14
  "bin",
@@ -238,7 +238,9 @@ 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`.
@@ -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
 
@@ -135,17 +135,21 @@ The bare form is shorthand: `count: Number` means `prop(Number)`. Use `prop()` t
135
135
  |---|---|---|
136
136
  | `type` | `String` | Constructor feeding the default attribute converter |
137
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 |
138
+ | `state` | `false` | Internal-only. No attribute, not observed, and never read from one at SSR either |
139
139
  | `attribute` | derived from name | The HTML attribute name the property rides |
140
140
  | `default` | none | Declarative initial value (a function runs per instance for a fresh object / array) |
141
141
  | `hasChanged` | strict `!==` | Custom change detection |
142
- | `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 |
143
143
 
144
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`.
145
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
+
146
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`.
147
149
 
148
- **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.
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.
149
153
 
150
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.
151
155
 
@@ -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:
@@ -192,7 +192,28 @@ Refusals worth knowing: `formaction=${fn}` is supported on a `<button>` anywhere
192
192
 
193
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.
194
194
 
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). 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`.
196
217
 
197
218
  ```ts
198
219
  // app/opengraph-image.ts (OG is 1200x630; apple-icon 180x180)
@@ -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
@@ -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`.
@@ -1,9 +1,14 @@
1
- // app/icon.ts serves /icon (the dynamic favicon). The default export is a
1
+ // app/icon.ts serves /icon (a dynamic favicon). The default export is a
2
2
  // (possibly async) server function; returning a Response lets you set the exact
3
- // content type, so an inline SVG needs no asset file. For a favicon that never
4
- // changes, put a static file in public/ instead (e.g. public/favicon.ico) and
5
- // delete this route. Generate it dynamically (per-theme, per-tenant) when the
6
- // mark must be computed at request time.
3
+ // content type, so an inline SVG needs no asset file. Generate it dynamically
4
+ // (per-theme, per-tenant) when the mark must be computed at request time.
5
+ //
6
+ // This is the DEMO of that surface, not the gallery's own favicon. A metadata
7
+ // route is not auto-linked: the framework emits `<link rel="icon">` only from
8
+ // metadata.icons, so the gallery declares the static WebJs brand mark from
9
+ // public/ there (see app/layout.ts) and this route stays browsable at /icon.
10
+ // For a favicon that never changes, that static path is the one to copy; drop
11
+ // this route when your app has no request-time mark to compute.
7
12
  export default function Icon() {
8
13
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
9
14
  <rect width="32" height="32" rx="7" fill="#1e2226"/>
@@ -63,7 +63,7 @@ export const FEATURE_GROUPS: NavGroup[] = [
63
63
  items: [
64
64
  { href: '/features/caching', title: 'Caching', blurb: 'export const revalidate caches the page HTML per URL, with the safety rule for when a shared cache is allowed.' },
65
65
  { href: '/features/env', title: 'Env vars', blurb: 'The server-only vs WEBJS_PUBLIC_ boundary, read during SSR so secrets never reach the browser.' },
66
- { href: '/features/rate-limit', title: 'Rate limiting', blurb: 'The rateLimit() middleware scoped to one endpoint, returning a 429 with Retry-After past the window.' },
66
+ { href: '/features/rate-limit', title: 'Rate limiting', blurb: 'The rateLimit() middleware scoped to one endpoint, returning a 429 with Retry-After once the interval resets.' },
67
67
  { href: '/features/file-storage', title: 'File storage', blurb: 'A no-JS multipart upload streamed into the FileStore, then served back through a streaming route.' },
68
68
  { href: '/features/service-worker', title: 'Service worker', blurb: 'The opt-in offline enhancement, registered from a browser-only lifecycle hook (never a page or layout).' },
69
69
  ],
@@ -75,5 +75,14 @@ export const EXAMPLES: NavItem[] = [
75
75
  { href: '/examples/todo', title: 'Optimistic todo', blurb: 'A whole app composing several features: the declarative optimistic() list API, progressive-enhancement forms, accessible labels, the modules split, and SQLite.' },
76
76
  ];
77
77
 
78
- /** Flattened single-feature list (for the home card grid). */
79
- export const FEATURES: NavItem[] = FEATURE_GROUPS.flatMap((g) => g.items);
78
+ /**
79
+ * Flattened single-feature list (for the home card grid).
80
+ *
81
+ * This is a function, not a `const` initialised by a top-level `.flatMap()`
82
+ * call. A top-level call is a module side effect, so the const form pinned
83
+ * every page importing this module into the browser bundle, even a home page
84
+ * with no client behaviour at all. Call it inside the render function.
85
+ */
86
+ export function featureList(): NavItem[] {
87
+ return FEATURE_GROUPS.flatMap((g) => g.items);
88
+ }
@@ -21,6 +21,13 @@ import { deleteTodo } from './delete-todo.server.ts';
21
21
  // control's visible label), and a bound submitter cannot carry its own
22
22
  // `name`/`value`, which is exactly the channel `name="intent"` uses below.
23
23
  //
24
+ // Third thing to know: no `formaction` url is emitted, because the identity
25
+ // travels in the body instead. So the submission targets whatever the FORM
26
+ // targets, and a form declaring `action="/x"` sends its buttons there. The
27
+ // action still runs when `/x` is a PAGE route; against a `route.ts` or another
28
+ // origin the identity is ignored and nothing runs, which the dev-time client
29
+ // guard reports at submit time.
30
+ //
24
31
  // With JS the component intercepts the submit and calls the underlying action
25
32
  // directly for the optimistic path, so this runs only with JS off.
26
33
  export async function submitTodo(formData: FormData) {
@@ -1,38 +0,0 @@
1
- #!/bin/bash
2
- #
3
- # Cursor afterFileEdit hook.
4
- #
5
- # Counterpart of .claude/hooks/nudge-uncommitted.sh. After each
6
- # file edit, counts uncommitted changes in the working tree.
7
- # When the count crosses a threshold (default 4, override with
8
- # WEBJS_COMMIT_NUDGE_THRESHOLD), injects a reminder via the
9
- # top-level additional_context field (snake_case, unlike Claude
10
- # Code's nested hookSpecificOutput.additionalContext).
11
- #
12
- # Soft nudge. Exit 0 always. Skipped on main/master and outside
13
- # a git work tree.
14
-
15
- set -e
16
-
17
- THRESHOLD="${WEBJS_COMMIT_NUDGE_THRESHOLD:-4}"
18
-
19
- if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
20
- exit 0
21
- fi
22
-
23
- BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo "")
24
- if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
25
- exit 0
26
- fi
27
-
28
- cat /dev/stdin >/dev/null 2>&1 || true
29
-
30
- CHANGED=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
31
-
32
- if [ -z "$CHANGED" ] || [ "$CHANGED" -lt "$THRESHOLD" ]; then
33
- exit 0
34
- fi
35
-
36
- REASON="You have ${CHANGED} uncommitted changes on '${BRANCH}'. The webjs convention is small, focused commits per logical unit (one feature, one fix, one rename, one doc rewrite). Before continuing with more edits, group the current changes into a meaningful commit. See AGENTS.md \"Git workflow\" for the rule and the rationale. To raise the threshold for this hook in long-running tasks, set WEBJS_COMMIT_NUDGE_THRESHOLD."
37
-
38
- jq -n --arg ctx "$REASON" '{ additional_context: $ctx }'
@@ -1,8 +0,0 @@
1
- {
2
- "version": 1,
3
- "hooks": {
4
- "afterFileEdit": [
5
- { "command": ".cursor/hooks/nudge-uncommitted.sh" }
6
- ]
7
- }
8
- }
@@ -1,21 +0,0 @@
1
- # WebJs app rules (Cursor)
2
-
3
- Cursor reads `AGENTS.md` natively. This file points you at it and the agent
4
- skill, and carries the commit rule.
5
-
6
- - **Read `AGENTS.md` first, then `.agents/skills/webjs/SKILL.md`** (the guide to
7
- building a WebJs app; it routes to focused references under
8
- `.agents/skills/webjs/references/`). These are required context, not optional
9
- reading: WebJs is not React, Next, or Lit, so gather this context before you
10
- write code.
11
- - **Study the shipped examples, then clear them and build.** The scaffold ships
12
- a browsable showcase to learn the real idioms from (a full-stack app ships a
13
- UI feature gallery, the api template ships a backend-features showcase). Read
14
- the parts that match your task, run `npm run gallery:clear` to shed the
15
- showcase and reset to a clean base, then grow the app in place: add routes
16
- under `app/`, features under `modules/<feature>/`, and keep server-only code
17
- behind `.server.ts`. `AGENTS.md` carries the full template-specific playbook.
18
- - **Use the wired-up database (Drizzle)** for persistence. Never a JSON file, an
19
- in-memory array, or localStorage.
20
- - **Commit per logical unit** as soon as it is complete, and never commit to
21
- `main`.
@@ -1,42 +0,0 @@
1
- #!/bin/bash
2
- #
3
- # Gemini CLI AfterTool hook.
4
- #
5
- # Counterpart of .claude/hooks/nudge-uncommitted.sh. After each
6
- # write_file or replace, counts uncommitted changes in the working
7
- # tree. When the count crosses a threshold (default 4, override
8
- # with the WEBJS_COMMIT_NUDGE_THRESHOLD env var), injects a
9
- # reminder via hookSpecificOutput.additionalContext (same shape
10
- # as Claude Code).
11
- #
12
- # Soft nudge. Does NOT block the edit (exit 0). Skipped on
13
- # main/master and outside a git work tree.
14
-
15
- set -e
16
-
17
- THRESHOLD="${WEBJS_COMMIT_NUDGE_THRESHOLD:-4}"
18
-
19
- if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
20
- exit 0
21
- fi
22
-
23
- BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo "")
24
- if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
25
- exit 0
26
- fi
27
-
28
- cat /dev/stdin >/dev/null 2>&1 || true
29
-
30
- CHANGED=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
31
-
32
- if [ -z "$CHANGED" ] || [ "$CHANGED" -lt "$THRESHOLD" ]; then
33
- exit 0
34
- fi
35
-
36
- REASON="You have ${CHANGED} uncommitted changes on '${BRANCH}'. The webjs convention is small, focused commits per logical unit (one feature, one fix, one rename, one doc rewrite). Before continuing with more edits, group the current changes into a meaningful commit. See AGENTS.md \"Git workflow\" for the rule and the rationale. To raise the threshold for this hook in long-running tasks, set WEBJS_COMMIT_NUDGE_THRESHOLD."
37
-
38
- jq -n --arg ctx "$REASON" '{
39
- hookSpecificOutput: {
40
- additionalContext: $ctx
41
- }
42
- }'
@@ -1,15 +0,0 @@
1
- {
2
- "hooks": {
3
- "AfterTool": [
4
- {
5
- "matcher": "write_file|replace",
6
- "hooks": [
7
- {
8
- "type": "command",
9
- "command": ".gemini/hooks/nudge-uncommitted.sh"
10
- }
11
- ]
12
- }
13
- ]
14
- }
15
- }
@@ -1,9 +0,0 @@
1
- # Copilot instructions
2
-
3
- This is a thin bridge to the single source. GitHub Copilot always reads this
4
- file; in VS Code it reads `AGENTS.md` directly only when `chat.useAgentsMdFile`
5
- is enabled, so this bridge keeps Copilot pointed at the instructions regardless.
6
-
7
- The instructions for this app live in `AGENTS.md` (the cross-agent source) and
8
- the skill at `.agents/skills/webjs/SKILL.md`. Read `AGENTS.md` first, then the
9
- skill (it routes to focused references on demand).
@@ -1,62 +0,0 @@
1
- /**
2
- * OpenCode commit-frequency nudge plugin.
3
- *
4
- * Counterpart of the Claude Code, Gemini CLI, and Cursor hooks in
5
- * `.claude/hooks/`, `.gemini/hooks/`, and `.cursor/hooks/`. After
6
- * each edit/write tool call, counts uncommitted changes in the
7
- * working tree. When the count crosses a threshold (default 4,
8
- * override with the WEBJS_COMMIT_NUDGE_THRESHOLD env var), appends
9
- * a reminder to the tool result so the agent sees it on the next
10
- * turn.
11
- *
12
- * Soft nudge by design. Does NOT block the edit. The goal is to
13
- * keep the agent honest about the "commit per logical unit" rule,
14
- * not to interrupt valid work.
15
- *
16
- * Skipped on main/master (different guard rules cover that) and
17
- * outside a git work tree.
18
- *
19
- * Auto-discovered by OpenCode at startup. No opencode.json entry
20
- * needed. Lives in .opencode/plugins/ at the project root.
21
- *
22
- * Docs: https://opencode.ai/docs/plugins/
23
- */
24
- import type { Plugin } from "@opencode-ai/plugin";
25
-
26
- export const NudgeUncommitted: Plugin = async ({ $ }) => {
27
- const THRESHOLD = Number(process.env.WEBJS_COMMIT_NUDGE_THRESHOLD ?? 4);
28
-
29
- return {
30
- "tool.execute.after": async (input, output) => {
31
- if (input.tool !== "edit" && input.tool !== "write") return;
32
-
33
- let branch = "";
34
- try {
35
- branch = (await $`git symbolic-ref --short HEAD`.text()).trim();
36
- } catch {
37
- return; // not in a git work tree
38
- }
39
- if (branch === "main" || branch === "master") return;
40
-
41
- let changed = 0;
42
- try {
43
- const out = (await $`git status --porcelain`.text()).trim();
44
- changed = out === "" ? 0 : out.split("\n").length;
45
- } catch {
46
- return;
47
- }
48
- if (changed < THRESHOLD) return;
49
-
50
- const reason =
51
- `[webjs] You have ${changed} uncommitted changes on '${branch}'. ` +
52
- `The webjs convention is small, focused commits per logical unit ` +
53
- `(one feature, one fix, one rename, one doc rewrite). Before ` +
54
- `continuing with more edits, group the current changes into a ` +
55
- `meaningful commit. See AGENTS.md "Git workflow" for the rule ` +
56
- `and the rationale. To raise the threshold for this hook in ` +
57
- `long-running tasks, set WEBJS_COMMIT_NUDGE_THRESHOLD.`;
58
-
59
- output.output = output.output ? `${output.output}\n\n${reason}` : reason;
60
- },
61
- };
62
- };
@@ -1,11 +0,0 @@
1
- # GEMINI.md
2
-
3
- Gemini CLI reads `GEMINI.md`, not `AGENTS.md`, by default, so this file is a
4
- thin bridge to the single source.
5
-
6
- The instructions for this app live in `AGENTS.md` (the cross-agent source) and
7
- the skill at `.agents/skills/webjs/SKILL.md`. Read `AGENTS.md` first, then the
8
- skill (it routes to focused references on demand).
9
-
10
- To have Gemini read `AGENTS.md` directly instead of this bridge, add it to
11
- `context.fileName` in `.gemini/settings.json`.