@webjsdev/cli 0.10.20 → 0.10.22

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/lib/create.js CHANGED
@@ -17,7 +17,7 @@ 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
- import { bunifyProse, bunifyDockerfile, bunifyCi } from './runtime-rewrite.js';
20
+ import { bunifyProse, bunifyDockerfile, bunifyCompose, bunifyCi } from './runtime-rewrite.js';
21
21
 
22
22
  /**
23
23
  * Detect which package manager invoked us. Reads `npm_config_user_agent`,
@@ -532,11 +532,12 @@ export async function scaffoldApp(name, cwd, opts = {}) {
532
532
  // touches npm/npx command tokens, so the test code itself is unaffected.
533
533
  'test/hello/browser/hello.test.js', 'test/hello/e2e/hello.test.ts',
534
534
  ]);
535
- // compose.yaml needs NO bun transform: it builds from the (node-base + bun
536
- // binary) Dockerfile and inherits its `bun --bun run start` CMD, and its
537
- // healthcheck `node -e` works because the Node base provides node.
535
+ // compose.yaml builds from the (pure oven/bun) Dockerfile and inherits its
536
+ // `bun --bun run start` CMD; only its healthcheck needs switching off node
537
+ // (the pure Bun image has no node), which bunifyCompose does.
538
538
  const FILE_REWRITE = {
539
539
  'Dockerfile': bunifyDockerfile,
540
+ 'compose.yaml': bunifyCompose,
540
541
  '.github/workflows/ci.yml': bunifyCi,
541
542
  };
542
543
  for (const f of templateFiles) {
@@ -917,7 +918,6 @@ export type ActionResult<T> =
917
918
 
918
919
  await writeFile(join(appDir, 'app', 'layout.ts'), `// webjs-scaffold-placeholder. This is the example app chrome (brand, nav, content-width container). Adapt it to your app, then delete this line. webjs check fails while the marker remains.
919
920
  import { html, cspNonce } from '@webjsdev/core';
920
- import '@webjsdev/core/client-router';
921
921
  import '#components/theme-toggle.ts';
922
922
  // Webjs UI components are tiered:
923
923
  // - Tier 1 (button, card, input, label, alert, badge, separator, etc.) are
@@ -974,6 +974,26 @@ export default function RootLayout({ children }: { children: unknown }) {
974
974
  mq.addEventListener('change', apply);
975
975
  } catch (_) {}
976
976
  })();
977
+ // The header is position:fixed (not sticky): a sticky header flickers on
978
+ // iOS WebKit during a client-router nav. fixed leaves normal flow, so
979
+ // --header-h reserves its height for the content below. Measured here so
980
+ // it tracks the real (responsive) height; degrades fine with no JS via
981
+ // the :root default.
982
+ (function(){
983
+ function measure(){
984
+ try {
985
+ var hdr = document.querySelector('header');
986
+ if (!hdr) return;
987
+ var apply = function(){
988
+ document.documentElement.style.setProperty('--header-h', hdr.offsetHeight + 'px');
989
+ };
990
+ apply();
991
+ if (window.ResizeObserver) new ResizeObserver(apply).observe(hdr);
992
+ } catch (_) {}
993
+ }
994
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', measure);
995
+ else measure();
996
+ })();
977
997
  </script>
978
998
  <script src="/public/tailwind-browser.js"></script>
979
999
  <!--
@@ -1063,7 +1083,9 @@ ${SHADCN_THEME}
1063
1083
  }
1064
1084
  /* Body + pseudo-elements utility classes can't reach. */
1065
1085
  html, body { margin: 0; }
1086
+ :root { --header-h: 56px; } /* fixed-header offset, kept exact by the script above */
1066
1087
  body {
1088
+ padding-top: var(--header-h);
1067
1089
  background: var(--bg);
1068
1090
  color: var(--fg);
1069
1091
  font: 16px/1.65 var(--font-sans);
@@ -1072,7 +1094,7 @@ ${SHADCN_THEME}
1072
1094
  ::selection { background: var(--accent-tint); color: var(--fg); }
1073
1095
  </style>
1074
1096
 
1075
- <header class="sticky top-0 z-20 flex items-center gap-6 px-4 sm:px-6 py-3 border-b border-border bg-[color-mix(in_oklch,var(--bg)_75%,transparent)] backdrop-blur-[18px]">
1097
+ <header class="fixed inset-x-0 top-0 z-20 flex items-center gap-6 px-4 sm:px-6 py-3 border-b border-border bg-[color-mix(in_oklch,var(--bg)_75%,transparent)] backdrop-blur-[18px]">
1076
1098
  <a href="/" class="mr-auto inline-flex items-center gap-2 no-underline text-fg font-semibold text-[15px] leading-none tracking-tight">
1077
1099
  <span>${name}</span>
1078
1100
  </a>
@@ -23,14 +23,11 @@
23
23
  * - the `dev` / `start` scripts force `bun --bun` (the server is Bun),
24
24
  * - every other command stays `bun run` / `webjs ...` (runs on Node via the
25
25
  * `webjs` bin's `#!/usr/bin/env node` shebang),
26
- * - the Dockerfile keeps the `node:24-alpine` base and COPIES in the Bun
27
- * binary (the server serves on Bun, but Node + `npx` stay available).
28
- * A pure `oven/bun` base would need the installed `@webjsdev/cli` to be
29
- * npx-free (#570), but a scaffolded app pins `@webjsdev/cli: latest`, and
30
- * until the #570 build is the published `latest` an installed CLI may still
31
- * shell `npx drizzle-kit` for the boot `webjs db migrate`, which a pure
32
- * `oven/bun` image lacks. The node base works with ANY installed CLI; a
33
- * future scaffold can drop to a pure `oven/bun` base once #570 is published.
26
+ * - the Dockerfile is a pure `oven/bun:1` base (#595). This is safe as of
27
+ * `@webjsdev/cli@0.10.20` (#570): `webjs db migrate` resolves drizzle-kit
28
+ * and runs it under Bun (no `npx`), so a Node-less image works. (Before
29
+ * #570 shipped as `latest`, this stayed on `node:24-alpine` + a copied Bun
30
+ * binary, since the installed CLI could still shell `npx`.)
34
31
  */
35
32
 
36
33
  /**
@@ -50,10 +47,17 @@
50
47
  export function bunifyProse(s) {
51
48
  return s
52
49
  // Prose claim about the Dockerfile CMD (AGENTS.md dev-start parity section);
53
- // the bun Dockerfile's CMD becomes `bun --bun run start`. The base stays
54
- // node:24-alpine (+ a copied Bun binary), so the "pins node:24-alpine" prose
55
- // is still accurate and is NOT rewritten.
50
+ // the bun Dockerfile's CMD becomes `bun --bun run start`.
56
51
  .replaceAll('`CMD ["npm", "start"]`', '`CMD ["bun", "--bun", "run", "start"]`')
52
+ // The "Containerized deploy" prose describes the node template's
53
+ // node:24-alpine base; the bun Dockerfile is a pure oven/bun:1 image (#595),
54
+ // so rewrite the base claim to match what the bun app actually ships. (The
55
+ // trailing `npm start` is rewritten to `bun --bun run start` by the generic
56
+ // rule below.)
57
+ .replaceAll(
58
+ 'Dockerfile pins `node:24-alpine` (the same Node major CI uses), installs\ndeps (no build step, since Drizzle has no codegen), and starts via',
59
+ 'Dockerfile is a pure `oven/bun:1` image (no Node, since `webjs db migrate`\nresolves drizzle-kit and runs under Bun with no `npx`, #570), installs deps\nwith `bun install` (no build step, since Drizzle has no codegen), and starts via',
60
+ )
57
61
  // The "Running on Bun" section frames Bun as opt-in ("force it with --bun").
58
62
  // In a bun-flavored app the dev/start scripts ALREADY embed --bun, so reframe
59
63
  // it as the configured default.
@@ -92,64 +96,73 @@ export function bunifyProse(s) {
92
96
  /**
93
97
  * Rewrite the scaffolded Dockerfile for Bun.
94
98
  *
95
- * Base decision (acceptance criterion): KEEP the `node:24-alpine` base and COPY
96
- * in the Bun binary, NOT a pure `oven/bun` image. Justification: the server
97
- * serves on Bun (`bun --bun run start` selects the `Bun.serve` listener), but a
98
- * scaffolded app pins `@webjsdev/cli: latest`, and until the npx-free CLI (#570)
99
- * is the published `latest`, the INSTALLED CLI may still shell `npx drizzle-kit`
100
- * for the boot-time `webjs db migrate`. A pure `oven/bun` image has NO `npx`
101
- * (verified), so that migrate would fail at container start. Keeping the Node
102
- * base gives `npx` + the Node toolchain while the copied Bun binary serves the
103
- * app on Bun, so the image works with ANY installed CLI version (the same
104
- * pattern the in-repo example apps deploy with). `bun install` (not `npm
105
- * install`) uses the committed `bun.lock`, and `trustedDependencies` lets
106
- * better-sqlite3's prebuild postinstall run. Once cli@#570 is the published
107
- * `latest`, a future scaffold can drop to a pure `oven/bun` base.
99
+ * Base decision (acceptance criterion): a pure `oven/bun:1` image (no Node).
100
+ * Safe as of `@webjsdev/cli@0.10.20` (#570): `webjs db` / `webjs test` resolve
101
+ * their tools (drizzle-kit, wtr) and spawn them with the current runtime instead
102
+ * of `npx`, so the boot-time `webjs db migrate` runs under Bun with no Node
103
+ * toolchain. (Before #570 was the published `latest`, this stayed on a
104
+ * `node:24-alpine` base with a copied Bun binary, since the installed CLI could
105
+ * still shell `npx`, which a pure Bun image lacks. #595 flipped it once the
106
+ * npx-free CLI shipped.) `oven/bun:1` is Debian-based: `ca-certificates` ship in
107
+ * the image and better-sqlite3 fetches its glibc prebuild on `bun install`
108
+ * (gated by `trustedDependencies`), so no build toolchain is needed.
108
109
  *
109
110
  * @param {string} s
110
111
  * @returns {string}
111
112
  */
112
113
  export function bunifyDockerfile(s) {
113
114
  return s
114
- // Top comment: explain the node-base + copied-bun-binary rationale.
115
+ // Top comment: explain the pure oven/bun base.
115
116
  .replace(
116
117
  /# webjs serves \.ts directly[\s\S]*?since the built-in stripper and recursive fs\.watch need it\.\n/,
117
118
  '# webjs serves .ts directly by stripping types at the runtime layer, so there is\n' +
118
119
  '# NO JavaScript build step (webjs is buildless end to end; there is no bundler or\n' +
119
- '# esbuild fallback). This image SERVES the app on **Bun** (`bun --bun run start`\n' +
120
- '# selects the Bun.serve listener) while keeping the `node:24-alpine` base so the\n' +
121
- '# Node toolchain (npx) stays available for the boot-time `webjs db migrate`. The\n' +
122
- '# Bun binary is copied in below. Do not lower the Node base below 24 (the floor the\n' +
123
- '# CI workflow and the framework pin enforce), since the toolchain and recursive\n' +
124
- '# fs.watch need it. (A pure oven/bun base, no Node, is possible once your installed\n' +
125
- '# @webjsdev/cli resolves drizzle-kit without npx; the node base works regardless.)\n',
120
+ '# esbuild fallback). This image runs the app on **Bun**: the type-strip comes from\n' +
121
+ '# `amaro`, the server serves via Bun.serve, and `webjs db migrate` runs under Bun\n' +
122
+ '# (the CLI resolves drizzle-kit without npx, #570), so no Node is needed. webjs also\n' +
123
+ '# runs on Node 24+; for a Node base instead, swap to `node:24-alpine` and start with\n' +
124
+ '# `npm start`.\n',
126
125
  )
127
- // Copy the Bun binary (musl, matching the alpine base) in after the apk step.
126
+ .replace('FROM node:24-alpine', 'FROM oven/bun:1')
127
+ // Debian base: ca-certificates already present, no `apk`. Drop the alpine line.
128
128
  .replace(
129
- 'RUN apk add --no-cache ca-certificates\n',
130
- 'RUN apk add --no-cache ca-certificates\n\n' +
131
- '# Bun binary, so the server serves on Bun while the Node toolchain above stays\n' +
132
- '# available for the boot `webjs db migrate` (which may shell npx, depending on the\n' +
133
- '# installed @webjsdev/cli version) and the shebang bins.\n' +
134
- 'COPY --from=oven/bun:1-alpine /usr/local/bin/bun /usr/local/bin/bun\n',
129
+ /# ca-certificates for outbound TLS \(e\.g\. a managed Postgres\)\. better-sqlite3\n# is a prebuilt native module, so no build toolchain is needed here\.\nRUN apk add --no-cache ca-certificates\n\n/,
130
+ '# The Debian-based oven/bun image ships ca-certificates for outbound TLS (e.g. a\n# managed Postgres). better-sqlite3 fetches its glibc prebuild on `bun install`\n# (gated by trustedDependencies), so no build toolchain is needed.\n\n',
135
131
  )
136
132
  // Lockfile + install (bun.lock, bun install).
137
133
  .replace(
138
134
  '# package-lock.json is optional (it\'s absent when the app was scaffolded with\n# --no-install); the glob keeps the COPY working with or without it.\nCOPY package.json package-lock.json* ./\nRUN npm install --no-audit --no-fund',
139
135
  '# bun.lock is optional (absent when scaffolded with --no-install); the glob keeps\n# the COPY working with or without it. trustedDependencies in package.json lets\n# better-sqlite3\'s native-prebuild postinstall run (bun skips postinstalls).\nCOPY package.json bun.lock* ./\nRUN bun install',
140
136
  )
141
- // Entrypoint: serve on Bun. The healthcheck keeps `node -e` (the Node base
142
- // provides node, so no change is needed there).
137
+ // Healthcheck: the pure Bun image has no node; use `bun -e`. Keep the
138
+ // dependency-free-probe comment accurate (the probe runs under Bun now).
139
+ .replace("(Node 24's built-in fetch, no curl/wget)", "(the runtime's built-in fetch, no curl/wget)")
140
+ .replace('CMD ["node", "-e", "fetch(', 'CMD ["bun", "-e", "fetch(')
141
+ // Entrypoint: serve on Bun.
143
142
  .replace(
144
143
  /# `npm start` is a thin alias[\s\S]*?the migrate no longer depends on an npm `prestart` hook\.\nCMD \["npm", "start"\]/,
145
144
  '# `bun --bun run start` runs the `start` script on Bun (the server serves via\n' +
146
145
  '# Bun.serve). `webjs start` runs the `webjs.start.before` step (`webjs db migrate`,\n' +
147
- '# which shells drizzle-kit through the Node toolchain in this image), idempotent /\n' +
148
- '# a no-op with no pending migrations, then serves on $PORT.\n' +
146
+ '# which resolves drizzle-kit and runs it under Bun, no npx, #570), idempotent / a\n' +
147
+ '# no-op with no pending migrations, then serves on $PORT.\n' +
149
148
  'CMD ["bun", "--bun", "run", "start"]',
150
149
  );
151
150
  }
152
151
 
152
+ /**
153
+ * Rewrite compose.yaml for the pure-Bun image (#595): its healthcheck runs in
154
+ * the `oven/bun:1` container (compose's healthcheck overrides the Dockerfile's),
155
+ * which has no `node`, so switch `node -e` to `bun -e`. compose otherwise builds
156
+ * from the Dockerfile and inherits its `bun --bun run start` CMD, so nothing
157
+ * else changes.
158
+ *
159
+ * @param {string} s
160
+ * @returns {string}
161
+ */
162
+ export function bunifyCompose(s) {
163
+ return s.replace('test: ["CMD", "node", "-e", "fetch(', 'test: ["CMD", "bun", "-e", "fetch(');
164
+ }
165
+
153
166
  /**
154
167
  * Rewrite the GitHub Actions CI workflow for Bun: ADD `oven-sh/setup-bun`
155
168
  * alongside `actions/setup-node` (kept, because the `webjs` test/db/check
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/cli",
3
- "version": "0.10.20",
3
+ "version": "0.10.22",
4
4
  "type": "module",
5
5
  "description": "webjs CLI - dev, start, create, db",
6
6
  "bin": {
@@ -34,6 +34,13 @@ FIRST, before writing any code:
34
34
  - If on main/master: create a feature branch before editing.
35
35
  - If on a feature branch: verify it matches the current task.
36
36
  2. Sync: `git fetch origin && git rebase origin/main` if behind.
37
+ 3. If more than one agent may work this repo at once, use a DEDICATED git
38
+ worktree per task (`git worktree add -b <branch> ../<repo>-<slug> origin/main`,
39
+ `cd` in, work there, `git worktree remove` after merge), never a shared
40
+ checkout. Two agents in one directory collide: a `git checkout` in one moves
41
+ HEAD under the other, so commits land on the wrong branch. Git enforces
42
+ one-branch-per-worktree, so worktrees prevent it. A lone agent in a clean
43
+ checkout may use a plain branch.
37
44
 
38
45
  ## Autonomous mode (sandbox / no-prompt)
39
46
 
@@ -130,6 +137,7 @@ self-review loop.
130
137
  `static styles = css\`...\`` for scoped CSS.
131
138
  - Custom-element tag names are passed to `.register('tag-name')`. They are NOT
132
139
  a static field on the class.
140
+ - **Never extend raw HTMLElement directly for app components.** Always subclass `WebComponent` (or the factory form `WebComponent({...})`) to hook into SSR, lifecycle, elision, and the reactive property system. Extend raw HTMLElement only for rare native-API edge cases (like form-associated `ElementInternals` or customized built-in elements), and add a `webjs-allow-htmlelement: <reason>` comment to acknowledge the exception.
133
141
  - One function per server action file (`*.server.ts`).
134
142
  - Server-only code (a DB driver like `better-sqlite3`/`pg`, `node:*`, anything that needs Node APIs)
135
143
  goes only in `.server.{js,ts}` files, `route.ts` handlers, or
@@ -138,8 +146,22 @@ self-review loop.
138
146
  stub for the browser. `lib/` holds both server-only infra
139
147
  (the DB in `db/*.server.ts`) and browser-safe utilities (`lib/utils/cn.ts` with
140
148
  `cn`); follow the same rule per file.
141
- - Directives are deliberately minimal: only `unsafeHTML`, `live`, and `repeat`
142
- ship. Use plain template-literal expressions
149
+ - Keep pages and layouts as pure carriers so their modules stay out of the
150
+ network tab. A page/layout never hydrates; the framework drops its module
151
+ from the browser as long as its only browser job is registering the
152
+ components it imports. It starts shipping its own module (invisible in tests,
153
+ an elision verdict) the moment its closure does any OTHER client work. So do
154
+ not give a page/layout module-scope client work (a top-level call, a
155
+ `window` / `document` / `customElements` access, a bare side-effect import,
156
+ or a `@webjsdev/core/client-router` import: routing is automatic), and do not
157
+ import a client-global-touching non-component util into it. Put client
158
+ behaviour in a component, server-only code in `.server.{js,ts}`. Self-check:
159
+ `page.ts` / `layout.ts` should not appear in the browser's network tab.
160
+ - Directives: webjs exports the lit directives with no clean native equivalent
161
+ (`repeat`, `unsafeHTML`, `live`, `keyed`, `guard`, `cache`, `until`, `ref` /
162
+ `createRef`, `templateContent`, `asyncAppend` / `asyncReplace`, `watch`).
163
+ `classMap` / `styleMap` / `ifDefined` / `when` / `choose` are NOT exported.
164
+ For those, use plain template-literal expressions
143
165
  (`class=${active ? 'btn active' : 'btn'}`, `style=${'color:' + color}`,
144
166
  `${cond ? a : b}`) and lifecycle hooks (`this.query('#el')` in
145
167
  `firstUpdated`) instead of Lit's `classMap` / `styleMap` / `ref` / `when` /
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env bash
2
+ # Guardrail: a webjs custom element must extend the framework's WebComponent
3
+ # base class, never raw HTMLElement.
4
+ #
5
+ # Why: a raw `extends HTMLElement` custom element is invisible to the webjs
6
+ # elision analyser (it ships unconditionally, defeats display-only elision, and
7
+ # keeps any importing page/layout from being import-only), it bypasses the
8
+ # SSR / lifecycle / reactive-prop machinery, and it usually applies its DOM work
9
+ # in connectedCallback (client-only), a progressive-enhancement bug.
10
+ #
11
+ # Scope: fires ONLY when the edited file lives in a webjs project (a package.json
12
+ # up the tree depends on @webjsdev/*), so vanilla-JS projects are never touched.
13
+ # Exempts framework source (packages/, node_modules/), since the framework
14
+ # legitimately defines WebComponent and the SSR-inert <webjs-frame> / -stream /
15
+ # -suspense primitives on raw HTMLElement. Honours an explicit escape-hatch
16
+ # marker `webjs-allow-htmlelement: <reason>` for the rare native-API case
17
+ # WebComponent cannot express (a form-associated element via ElementInternals,
18
+ # a customized built-in via `extends HTMLButtonElement`, etc.).
19
+ #
20
+ # PreToolUse contract: exit 0 = allow, exit 2 = block (message on stderr).
21
+ #
22
+ # NOTE: No em-dashes, spaces around hyphens as pauses, or semicolons as pauses
23
+ # are allowed in comments per project rules.
24
+ set -euo pipefail
25
+
26
+ input=$(cat)
27
+ fp=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')
28
+ [ -z "$fp" ] && exit 0
29
+ case "$fp" in
30
+ *.ts|*.tsx|*.js|*.jsx|*.mts|*.mjs) ;;
31
+ *) exit 0 ;;
32
+ esac
33
+
34
+ # The text being written: Write -> .content, Edit -> .new_string, MultiEdit -> .edits[]?.new_string.
35
+ content=$(printf '%s' "$input" | jq -r '(.tool_input.content // empty), (.tool_input.new_string // empty), (.tool_input.edits[]?.new_string // empty)')
36
+ [ -z "$content" ] && exit 0
37
+
38
+ # Only a class that extends raw HTMLElement is the target (not `typeof
39
+ # HTMLElement` guards, not `instanceof HTMLElement`, not another base).
40
+ printf '%s' "$content" \
41
+ | grep -Eq 'class[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]+extends[[:space:]]+HTMLElement([[:space:]{]|$)' \
42
+ || exit 0
43
+
44
+ # Explicit, acknowledged exception.
45
+ printf '%s' "$content" | grep -qi 'webjs-allow-htmlelement' && exit 0
46
+
47
+ # Framework source / installed deps are never app components.
48
+ case "$fp" in
49
+ */packages/*|*/node_modules/*|packages/*|node_modules/*) exit 0 ;;
50
+ esac
51
+
52
+ # Webjs context: a package.json up the tree references @webjsdev/* (a webjs app
53
+ # or the framework repo). Outside a webjs project this hook is a no-op.
54
+ dir=$(CDPATH= cd -- "$(dirname -- "$fp")" 2>/dev/null && pwd || dirname -- "$fp")
55
+ is_webjs=0
56
+ while [ -n "$dir" ] && [ "$dir" != "/" ]; do
57
+ if [ -f "$dir/package.json" ] && grep -q '@webjsdev/' "$dir/package.json" 2>/dev/null; then
58
+ is_webjs=1
59
+ break
60
+ fi
61
+ dir=$(dirname -- "$dir")
62
+ done
63
+ [ "$is_webjs" -eq 0 ] && exit 0
64
+
65
+ cat >&2 <<'MSG'
66
+ BLOCKED: a webjs custom element must extend the WebComponent base class, not raw HTMLElement.
67
+
68
+ import { WebComponent } from '@webjsdev/core';
69
+ class MyThing extends WebComponent {
70
+ render() { return html`...`; }
71
+ }
72
+ MyThing.register('my-thing');
73
+
74
+ A display-only element (just host classes / static markup) can set its classes
75
+ in the constructor (runs at SSR, so it is progressive-enhancement-safe) and
76
+ stays elidable, so it ships zero JS. A raw `extends HTMLElement` element cannot
77
+ be elided, defeats import-only routes, and applies its work client-only.
78
+
79
+ If WebComponent genuinely cannot express this (a rare native-API edge case),
80
+ add a marker comment containing `webjs-allow-htmlelement: <reason>` to the file
81
+ to acknowledge the exception, and this guardrail will allow it.
82
+ MSG
83
+ exit 2
@@ -1,6 +1,15 @@
1
1
  {
2
2
  "hooks": {
3
3
  "PreToolUse": [
4
+ {
5
+ "matcher": "Write|Edit|MultiEdit",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": ".claude/hooks/block-raw-htmlelement.sh"
10
+ }
11
+ ]
12
+ },
4
13
  {
5
14
  "matcher": "Write|Edit|MultiEdit|NotebookEdit|Bash",
6
15
  "hooks": [
@@ -35,6 +35,12 @@ FIRST, before writing any code:
35
35
  2. Sync with parent: `git fetch origin && git log HEAD..origin/main --oneline`
36
36
  - If upstream has new commits: `git rebase origin/main` before starting.
37
37
  - Resolve any conflicts before proceeding with the task.
38
+ 3. If more than one agent may work this repo at once, use a DEDICATED git
39
+ worktree per task, not a shared checkout: `git worktree add -b <branch>
40
+ ../<repo>-<slug> origin/main`, `cd` in, work there, `git worktree remove`
41
+ after merge. Two agents in one directory collide (a `git checkout` in one
42
+ moves HEAD under the other, so commits land on the wrong branch). A lone
43
+ agent in a clean checkout may use a plain branch.
38
44
 
39
45
  ## Autonomous mode (sandbox / no-prompt)
40
46
 
@@ -107,8 +113,10 @@ self-review loop.
107
113
  - Shadow-DOM components opt in with `static shadow = true` and use `static styles = css` for scoped CSS, not inline styles. That is the right home for scoped CSS.
108
114
  - One function per server action file (*.server.ts)
109
115
  - Components must call customElements.define('tag', Class)
116
+ - **Never extend raw HTMLElement directly for app components.** Always subclass `WebComponent` (or the factory form `WebComponent({...})`) to hook into SSR, lifecycle, elision, and the reactive property system. Extend raw HTMLElement only for rare native-API edge cases (like form-associated `ElementInternals` or customized built-in elements), and add a `webjs-allow-htmlelement: <reason>` comment to acknowledge the exception.
110
117
  - Server-only code (the DB driver `better-sqlite3` / `pg`, node:*, anything that needs Node APIs) goes only in .server.{js,ts} files, route.ts handlers, or middleware.ts. Never in pages, layouts, or components. Wrap the access in a .server.{js,ts} file; the framework rewrites that import into an RPC stub for the browser. The DB lives in db/*.server.ts (db/connection.server.ts); lib/ holds other server-only infra and browser-safe utilities (lib/utils/cn.ts with cn); follow the same rule per file: if a lib/ file needs Node APIs, only import it from server-only files.
111
- - Directives are deliberately minimal: only `unsafeHTML`, `live`, and `repeat` ship. Lit's `classMap` / `styleMap` / `ref` / `when` / `choose` / `guard` are NOT exported. Use plain template-literal expressions (`class=${cond ? 'a' : 'b'}`, `${cond ? a : b}`) and lifecycle hooks (`this.query('#el')` in `firstUpdated`) instead.
118
+ - Directives: webjs exports the lit directives with no clean native equivalent (`repeat`, `unsafeHTML`, `live`, `keyed`, `guard`, `cache`, `until`, `ref` / `createRef`, `templateContent`, `asyncAppend` / `asyncReplace`, `watch`). Lit's `classMap` / `styleMap` / `ifDefined` / `when` / `choose` are NOT exported. For those, use plain template-literal expressions (`class=${cond ? 'a' : 'b'}`, `${cond ? a : b}`) and lifecycle hooks (`this.query('#el')` in `firstUpdated`) instead.
112
119
  - **Progressive enhancement is the default.** Pages AND every web component are SSR'd to real HTML. Write components so the first paint is the right content. Read SSR-meaningful defaults in `constructor()`. `connectedCallback` is never called on the server, so anything there only runs after hydration. Initial data for components comes from the page function (server-side fetch plus pass as attribute/property) OR from an `async render()` in the component itself (`const u = await getUser(this.uid)`, which SSR awaits so the data is in the first paint), NOT from `fetch` calls in `connectedCallback`. Prefer the co-located `async render()` over prop-drilling; `renderFallback()` is the optional re-fetch loading state (never first paint), and a `Task` is for genuinely client-only data. For write-paths, prefer `<form action=...>` plus server action over `fetch` plus click handler. The framework upgrades plain forms to partial-swap submissions automatically.
113
120
  - **Client navigation is auto-magic.** Real `<a href>` and `<form action>` get partial-swap behavior with no opt-in. Because layouts persist across navigation, put shared chrome (sidenav, header) in `layout.ts` and page-specific content in `page.ts`. For validation errors, return 4xx HTML from a `route.ts` POST handler; the router renders it in place preserving the user's input. For non-layout swap regions, wrap in `<webjs-frame id="...">`. See "Client navigation patterns" in AGENTS.md.
121
+ - **Keep pages and layouts as pure carriers** so their modules stay out of the network tab. A page/layout never hydrates; the framework drops its module from the browser as long as its only browser job is registering the components it imports. It starts shipping its own module (invisible in tests, an elision verdict) the moment its closure does any OTHER client work. Don't give a page/layout module-scope client work (a top-level call, a window/document/customElements access, a bare side-effect import, or a @webjsdev/core/client-router import: routing is automatic) or import a client-global-touching non-component util into it. Put client behaviour in a component, server-only code in .server.{js,ts}. Self-check: page.ts/layout.ts should not appear in the browser's network tab.
114
122
  - See AGENTS.md for the complete directive decision guide
@@ -32,6 +32,12 @@ FIRST, before writing any code:
32
32
  - If on main/master: create a feature branch before editing.
33
33
  - If on a feature branch: verify it matches the task at hand.
34
34
  2. Sync: `git fetch origin && git rebase origin/main` if behind.
35
+ 3. If more than one agent may work this repo at once, use a DEDICATED git
36
+ worktree per task (`git worktree add -b <branch> ../<repo>-<slug> origin/main`,
37
+ `cd` in, work there, `git worktree remove` after merge), never a shared
38
+ checkout. Two agents in one directory collide: a `git checkout` in one moves
39
+ HEAD under the other, so commits land on the wrong branch. A lone agent in a
40
+ clean checkout may use a plain branch.
35
41
 
36
42
  ## Autonomous mode (sandbox / no-prompt)
37
43
 
@@ -101,13 +107,14 @@ each change must include.
101
107
  - **Erasable TypeScript only.** The runtime strips types via `module.stripTypeScriptTypes` (Node's built-in, or `amaro` on Bun) (whitespace replacement, byte-exact position preservation, no sourcemap). The scaffold's tsconfig.json sets `erasableSyntaxOnly: true`, so the TS compiler rejects `enum`, `namespace` with values, constructor parameter properties, legacy decorators with `emitDecoratorMetadata`, and `import = require`. Use erasable equivalents: `const X = { ... } as const` plus a derived union type instead of `enum`; explicit fields plus constructor body assignments instead of parameter properties. If `erasableSyntaxOnly` is disabled and non-erasable syntax is used, the dev server fails at strip time and returns a 500 pointing at the `no-non-erasable-typescript` lint rule. webjs is buildless end-to-end and has no bundler fallback.
102
108
  - Tagged template: html`<div>${value}</div>` with css`...` for styles.
103
109
  - **Tailwind-first styling.** Tailwind utilities are the strong default for pages AND light-DOM components (the default DOM mode): layout, spacing, color (via `@theme` tokens), typography, borders, radius, shadows, interaction states. Light DOM does not scope, so utilities apply directly. The lit reflex to scope CSS (`static styles = css\`...\``) or write an inline `<style>` with semantic class names (`.hero`, `.card`) in a light-DOM component is wrong: the scoped block needs `static shadow = true`, and inline class names leak globally. When a utility bundle repeats, extract a `lib/utils/ui.ts` helper returning an `html` fragment, not a CSS class. Reserve raw CSS for the allowlist (design tokens / `@theme`, `@property` + `@keyframes`, `::-webkit-scrollbar`, `prefers-reduced-motion`, complex `color-mix()` / gradients); when unavoidable in a light-DOM component, prefix every class selector with the component tag. Shadow-DOM components (`static shadow = true`) legitimately author `static styles = css\`...\`` for scoped CSS; don't use inline `style="..."` there.
104
- - Components: extend WebComponent, declare `static properties` (and `static styles` for shadow-DOM components), call `Class.register('tag-name')` at the bottom of the file. The tag name is the argument to `.register()`, not a static field.
110
+ - Components: extend the `WebComponent({ ... })` factory to declare reactive properties (e.g. `extends WebComponent({ count: Number })`; per-prop options via `prop(Number, { reflect: true })`), add `static styles` for shadow-DOM components, call `Class.register('tag-name')` at the bottom of the file. The tag name is the argument to `.register()`, not a static field. A hand-written `static properties` throws at construction (`no-static-properties`); set defaults via the `default` option or the constructor, never a class-field initializer (`reactive-props-no-class-field`). Declare an array-typed prop with the `Array` constructor, not `Object` (`items: prop<Tag[]>(Array)`), flagged by `array-prop-uses-array-type`. **Never extend raw HTMLElement directly for app components.** Always subclass `WebComponent` (or the factory form `WebComponent({...})`) to hook into SSR, lifecycle, elision, and the reactive property system. Extend raw HTMLElement only for rare native-API edge cases (like form-associated `ElementInternals` or customized built-in elements), and add a `webjs-allow-htmlelement: <reason>` comment to acknowledge the exception.
105
111
  - Async data in a component: prefer an `async render()` (`const u = await getUser(this.uid)`), which SSR awaits so the data is in the first paint, over prop-drilling from the page or fetching in `connectedCallback`. `renderFallback()` is the optional re-fetch loading state (never first paint); error isolation is automatic (`renderError()` customizes it); a `Task` is for genuinely client-only data.
106
112
  - Server actions: *.server.ts files with one exported async function each.
107
113
  - Server-only code (a DB driver like better-sqlite3/pg, node:*, anything needing Node APIs) goes only in .server.{js,ts} files, route.ts handlers, or middleware.ts. Never in pages, layouts, or components. Wrap in a .server.{js,ts} file; the framework rewrites that import to an RPC stub for the browser. The DB lives in db/*.server.ts; lib/ holds other server-only infra and browser-safe utilities (lib/utils/cn.ts with cn); apply the same rule per file.
108
- - Directives: webjs ships only `unsafeHTML`, `live`, and `repeat`. Lit's `classMap` / `styleMap` / `ref` / `when` / `choose` / `guard` are NOT exported. Use plain template-literal expressions and lifecycle hooks instead.
114
+ - Directives: webjs exports the lit directives with no clean native equivalent (`repeat`, `unsafeHTML`, `live`, `keyed`, `guard`, `cache`, `until`, `ref` / `createRef`, `templateContent`, `asyncAppend` / `asyncReplace`, `watch`). Lit's `classMap` / `styleMap` / `ifDefined` / `when` / `choose` are NOT exported; use plain template-literal expressions instead.
109
115
  - Context: import { createContext, ContextProvider, ContextConsumer } from '@webjsdev/core/context'
110
116
  - Task: import { Task, TaskStatus } from '@webjsdev/core/task'
111
117
  - Routing: file-based under app/ (page.ts, layout.ts, route.ts, middleware.ts).
112
- - Component state lives in signals from @webjsdev/core. Module-scope signals share state across components; instance signals (created in the constructor) carry component-local state. Reactive properties (static properties + declare) are for HTML attributes and .prop=${...} hydration.
118
+ - Keep pages and layouts as pure carriers so their modules stay out of the network tab. A page/layout never hydrates; the framework drops its module from the browser as long as its only browser job is registering the components it imports. It starts shipping its own module (invisible in tests, an elision verdict) the moment its closure does any OTHER client work. Don't give a page/layout module-scope client work (a top-level call, a window/document/customElements access, a bare side-effect import, or a @webjsdev/core/client-router import: routing is automatic) or import a client-global-touching non-component util into it. Put client behaviour in a component, server-only code in .server.{js,ts}. Self-check: page.ts/layout.ts should not appear in the network tab.
119
+ - Component state lives in signals from @webjsdev/core. Module-scope signals share state across components; instance signals (created in the constructor) carry component-local state. Reactive properties (declared via the `WebComponent({ ... })` factory) are for HTML attributes and .prop=${...} hydration.
113
120
  - Don't skip tests or documentation updates.
@@ -11,9 +11,20 @@
11
11
  # here, so a commit stays fast and the test gate cannot be skipped by a
12
12
  # local --no-verify. The CI workflow runs `webjs check` + `webjs test`
13
13
  # on every push and pull request.
14
+ #
15
+ # Running more than one AI agent on this repo at once? Give each task its own
16
+ # git worktree, not a shared checkout. Two agents in one working directory
17
+ # collide: a `git checkout` in one moves HEAD under the other, so the next
18
+ # commit lands on the wrong branch. Before committing, confirm the branch below
19
+ # is the one you intended; if it moved, you are sharing a checkout. Isolate:
20
+ # git worktree add -b <branch> ../<app>-<task> origin/main && cd ../<app>-<task>
14
21
 
15
22
  BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null)
16
23
 
24
+ # Surface the branch so a wrong-branch commit (a concurrent-agent HEAD move) is
25
+ # visible in the commit output rather than silent.
26
+ echo "[pre-commit] committing on branch: ${BRANCH:-<detached>}"
27
+
17
28
  if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
18
29
  echo ""
19
30
  echo "ERROR: Cannot commit directly to '$BRANCH'."
@@ -163,8 +163,9 @@ entry, its own template parser. Inside `` html`…` `` templates you get:
163
163
  - Binding-aware completions: reachable tag names after `<`, and
164
164
  prefix-keyed attributes (`.prop` property names, `?bool` / plain
165
165
  hyphenated attribute names).
166
- - Diagnostics: value type-checks against `declare propName: T`, unquoted
167
- `@`/`.`/`?` bindings, and expressionless `.prop` bindings.
166
+ - Diagnostics: value type-checks against the reactive props declared in
167
+ `WebComponent({ ... })`, unquoted `@`/`.`/`?` bindings, and
168
+ expressionless `.prop` bindings.
168
169
  - Hover showing the component class / declared member type.
169
170
 
170
171
  In VS Code / Cursor / Windsurf, the **`webjs` extension** bundles this
@@ -561,7 +562,6 @@ CI gate).
561
562
 
562
563
  ```ts
563
564
  import { html, css, WebComponent } from '@webjsdev/core';
564
- import '@webjsdev/core/client-router'; // enable SPA nav
565
565
  import { unsafeHTML, live } from '@webjsdev/core/directives';
566
566
  import { createContext } from '@webjsdev/core/context';
567
567
  import { Task } from '@webjsdev/core/task';
@@ -597,12 +597,13 @@ const secret = process.env.AUTH_SECRET; // undefined (fail-closed)
597
597
  ```ts
598
598
  import { WebComponent, html, css } from '@webjsdev/core';
599
599
 
600
- export class Counter extends WebComponent {
601
- static properties = { count: { type: Number } };
600
+ // Recommended declare-free base-class factory style
601
+ export class Counter extends WebComponent({
602
+ count: Number
603
+ }) {
602
604
  static styles = css`button { padding: 8px 12px; }`; // shadow-DOM only
603
605
  // static shadow = true; // opt into shadow DOM (default: light DOM)
604
606
  // static lazy = true; // download JS only when scrolled into view
605
- declare count: number; // TypeScript-only typed accessor
606
607
 
607
608
  constructor() {
608
609
  super();
@@ -629,8 +630,9 @@ the click handler is inert). Two consequences for how you write code:
629
630
  1. **Defaults for the first paint go in `constructor()`** (after
630
631
  `super()`), never as class-field initializers (which break
631
632
  reactivity) and never in `connectedCallback` (which the server
632
- doesn't run). For Web Component properties with `declare`, set the
633
- default in the constructor.
633
+ doesn't run). For reactive properties declared via the
634
+ `WebComponent({ ... })` factory, set the default in the constructor
635
+ or pass the `default` option (e.g. `prop(Number, { default: 0 })`).
634
636
  2. **`connectedCallback` is browser-only.** Use it for
635
637
  `localStorage`, viewport size, online status, or anything that
636
638
  genuinely can't be known on the server. Read the value, then
@@ -659,7 +661,8 @@ See [Progressive Enhancement](https://docs.webjs.dev/docs/progressive-enhancemen
659
661
  ## Lit muscle-memory gotchas (read if you have written lit before)
660
662
 
661
663
  Webjs's runtime API matches lit. The `WebComponent` base class,
662
- `static properties`, the lifecycle hooks, ReactiveControllers, the
664
+ reactive properties (declared via the `WebComponent({ ... })` factory),
665
+ the lifecycle hooks, ReactiveControllers, the
663
666
  directive set, `html` / `css` tagged templates. The **rendering
664
667
  model**, however, is different. Pure-lit patterns that work fine in a
665
668
  client-only lit app break in webjs's SSR pipeline or its reactivity
@@ -714,11 +717,15 @@ Practical consequences for agents writing webjs code.
714
717
  | Assuming an `async render()` always ships its module | A bare one (no other client signal) is ELIDED, so it costs zero JS and skips the on-hydration re-fetch, first paint unchanged | Rely on it for a fetch-and-display leaf. `static refresh = true` keeps the on-load refresh, `static shadow = true` always ships |
715
718
  | `window.X` / `document.X` in constructor or `render()` | SSR crash | Move to `connectedCallback` |
716
719
  | Top-level `import` of a browser-only library | SSR crash | Dynamic `import()` inside `connectedCallback` |
717
- | Class-field initializer for a reactive property (`student: Student = {...}`) | Silently breaks reactivity (overwrites the framework accessor) | `declare student: Student` plus constructor default |
718
- | `@property()` decorator | Banned by invariant 10 (erasable TS) | `static properties = { ... }` plus `declare` |
720
+ | Class-field initializer for a reactive property (`student: Student = {...}`) | Silently breaks reactivity (overwrites the framework accessor) | Pass the shape to the base-class factory `WebComponent({ student: Object })` and set the default in the constructor (flagged by `reactive-props-no-class-field`) |
721
+ | `@property()` decorator | Banned by invariant 10 (erasable TS) | Pass the shape to the base-class factory `WebComponent({ ... })` (the only supported form) |
722
+ | Hand-written `static properties = { ... }` | Throws at construction (the factory owns property setup) | Pass the same shape to the base-class factory `WebComponent({ ... })` (flagged by `no-static-properties`) |
723
+ | Array-typed prop declared with `Object` (`items: prop<Tag[]>(Object)`) | Works (Object and Array share one JSON converter), but misstates the prop's shape | Pass the `Array` constructor (`items: prop<Tag[]>(Array)`), flagged by `array-prop-uses-array-type` |
724
+ | Extending raw `HTMLElement` directly | Bypasses SSR, reactive properties, elision, and lifecycle hooks; keeps the component from being elided | Always subclass `WebComponent` (or the factory form `WebComponent({...})`) |
725
+ | Module-scope client work in a `page.ts` / `layout.ts` (a top-level call, a `window` / `document` / `customElements` access, a `@webjsdev/core/client-router` import), or importing a client-global-touching non-component util into one | The page/layout module stops being a droppable carrier and SHIPS its own JS to the browser (it shows up in the network tab); invisible in tests because it is an elision verdict, not a behaviour change | Keep pages/layouts pure carriers (their only browser job is registering the components they import; routing is automatic). Put client behaviour in a component, server-only code in `.server.{js,ts}`. Self-check: `page.ts` / `layout.ts` should not appear in the network tab |
719
726
  | Scoped `static styles = css` or an inline `<style>` with semantic class names (`.hero`, `.card`) in a light-DOM component | Scoped block does nothing without `static shadow = true`; inline `<style>` class names leak globally | Tailwind utilities (the light-DOM default); or `static shadow = true` for genuinely scoped CSS |
720
727
  | `willUpdate` computing SSR-visible derived state | Works (runs at SSR), but overriding it opts the component out of elision | Fine for interactive components; for display-only, derive inline in `render()` |
721
- | `this.hasAttribute` / `getAttribute` in `render()` | Works (server attribute shim backs the attribute methods at SSR) | Read attributes directly, or via a `static properties` + `declare` reactive prop |
728
+ | `this.hasAttribute` / `getAttribute` in `render()` | Works (server attribute shim backs the attribute methods at SSR) | Read attributes directly, or declare a reactive prop via the base-class factory `WebComponent({ ... })` |
722
729
  | `ContextProvider` for server-known data | Default value during SSR, content shift on hydration | Pass via props from the page function |
723
730
 
724
731
  The full annotated catalog with code examples lives in the framework
@@ -782,8 +789,9 @@ reference: https://docs.webjs.com/docs/server-actions
782
789
 
783
790
  ## Client navigation patterns (auto-magic)
784
791
 
785
- The client router enables itself when the scaffolded root layout imports
786
- `@webjsdev/core/client-router`. After that, **every `<a href>` and
792
+ The client router enables itself automatically: it turns on whenever
793
+ `@webjsdev/core` loads in the browser, which happens on any page that
794
+ ships a component, so there is no import to add. **Every `<a href>` and
787
795
  `<form action>` on the page is enhanced into a partial-swap navigation
788
796
  or submission automatically**. You don't call a router API. Write
789
797
  standard HTML; the swap happens.
@@ -1218,7 +1226,14 @@ composition, so a nested shell ends up dropped by the HTML parser.
1218
1226
 
1219
1227
  ## Workflow expectations for AI agents
1220
1228
 
1221
- 1. Branch before editing. Never push to `main` directly.
1229
+ 1. Branch before editing. Never push to `main` directly. **If more than one
1230
+ agent may work this repo at once, give each task its own git worktree, not a
1231
+ shared checkout** (`git worktree add -b <branch> ../<repo>-<slug> origin/main`,
1232
+ `cd` in, work there, `git worktree remove` after merge). Two agents in one
1233
+ working directory collide: a `git checkout` in one moves `HEAD` under the
1234
+ other, so the next commit lands on the wrong branch. Git enforces
1235
+ one-branch-per-worktree, so worktrees prevent it; a lone agent in a clean
1236
+ checkout may use a plain branch.
1222
1237
  2. Every code change comes with a test, AGENTS.md / docs updates if the
1223
1238
  feature surface changed, `webjs check` passing. A unit test is not
1224
1239
  always enough: a component, hydration, the client router, or a server
@@ -60,6 +60,14 @@ even if the user doesn't explicitly ask.**
60
60
  3. If on a feature branch → verify it matches the current task
61
61
  4. Sync with parent: `git fetch origin && git rebase origin/main` if behind
62
62
  5. Don't mix unrelated work on the wrong branch
63
+ 6. **If more than one agent may work this repo at once, use a dedicated git
64
+ worktree per task, never a shared checkout.** Two agents in one working
65
+ directory collide: a `git checkout` in one moves `HEAD` under the other, so
66
+ the next commit lands on the wrong branch. Isolate each task:
67
+ `git worktree add -b <branch> ../<repo>-<slug> origin/main`, `cd` in, work
68
+ there, and `git worktree remove` after the PR merges. Git enforces
69
+ one-branch-per-worktree, so this makes the collision impossible. A lone agent
70
+ in a clean checkout may use a plain branch.
63
71
 
64
72
  ### After cloning: verify the toolchain
65
73
 
@@ -395,6 +403,7 @@ modules/
395
403
  - **Server-only code goes in `.server.{js,ts}` files, `route.ts` handlers, or `middleware.ts`. Never in pages, layouts, or components.** Direct imports of a DB driver (`better-sqlite3` / `pg`) or `node:*` from pages, layouts, or components crash the browser at module load. Wrap in a `.server.{js,ts}` file; the framework rewrites that import to an RPC stub on the browser side. The DB lives in `db/*.server.ts`; `lib/` holds other server-only infra and browser-safe utilities (`lib/utils/cn.ts` with `cn`); the convention is "if a `lib/` file needs Node APIs, only import it from server-only files."
396
404
  - Routes (`app/**/page.ts`, `app/**/route.ts`) must be thin: import logic from modules
397
405
  - **Fetch server data in the component that needs it, with an `async render()`, not by prop-drilling.** A leaf component can write `const u = await getUser(this.uid)` directly in `render()`; SSR awaits it so the data is in the first paint, and the client uses stale-while-revalidate on a re-fetch. Reach for `renderFallback()` only to show a re-fetch loading state, and `Task` / signals only for genuinely client-only data (a `Task` shows its pending state at SSR, losing first-paint data). Do not put `await getData()` in a page / layout when a leaf component can own it (page fetches run sequentially, a route-level waterfall).
406
+ - **Keep pages and layouts as pure carriers, so their modules stay out of the network tab.** A page/layout never hydrates; the framework drops its module from the browser as long as its only browser-relevant job is registering the components it imports. It starts shipping its own module (invisible in tests) the moment its closure does any OTHER client work. So do not give a page/layout module-scope client work (a top-level call, a `window` / `document` / `customElements` access, a bare side-effect import, or a `@webjsdev/core/client-router` import: routing is automatic), and do not import a client-global-touching non-component util into it. Put client behaviour in a component, server-only code in `.server.{js,ts}`. Self-check: `page.ts` / `layout.ts` should not appear in the browser's network tab.
398
407
 
399
408
  ---
400
409
 
@@ -635,10 +644,11 @@ Any stateful behavior with a Tier-2 element uses the element.
635
644
  ```ts
636
645
  import { WebComponent, html } from '@webjsdev/core';
637
646
 
638
- export class MyWidget extends WebComponent {
639
- static properties = { label: { type: String }, count: { type: Number } };
640
- declare label: string;
641
- declare count: number;
647
+ // Recommended declare-free base-class factory style
648
+ export class MyWidget extends WebComponent({
649
+ label: String,
650
+ count: Number
651
+ }) {
642
652
  // Light DOM is the default; Tailwind utility classes apply directly.
643
653
 
644
654
  constructor() {
@@ -660,16 +670,7 @@ export class MyWidget extends WebComponent {
660
670
  MyWidget.register('my-widget');
661
671
  ```
662
672
 
663
- `static properties` is the runtime declaration (reactive accessor,
664
- attribute coercion, reflection). `declare` types the field for
665
- TypeScript without emitting a class-field initializer that would
666
- clobber the reactive accessor at construction time. The two
667
- declarations together give you full intelligence in any tsserver-backed
668
- editor. See the Editor Setup docs for the standalone `@webjsdev/intellisense`
669
- (no Lit dependency) that extends this to tag / attribute intelligence
670
- inside `html\`…\`` templates (go-to-definition, binding-aware completions,
671
- value/binding diagnostics, hover); in VS Code / Cursor / Windsurf the
672
- `webjs` extension bundles it automatically.
673
+ Reactive properties are declared one way: pass the properties shape directly to the base-class factory `WebComponent({ ... })` (e.g. `label: String`). The property types flow to `this.<prop>` with no `declare` lines needed, and the factory installs the reactive accessors so a class-field initializer can never clobber them. For per-property options use the `prop()` helper inside the shape (`count: prop(Number, { reflect: true })`, `mode: prop({ state: true })`); narrow a type with `prop<Student>(Object)`. Set defaults via the `default` option (`prop(Number, { default: 0 })`) or by assigning in the constructor after `super()`. A hand-written `static properties = { ... }` THROWS at construction (`no-static-properties`). The factory gives you full intelligence in any tsserver-backed editor. See the Editor Setup docs for the standalone `@webjsdev/intellisense` (no Lit dependency) that extends this to tag / attribute intelligence inside `html\`…\`` templates (go-to-definition, binding-aware completions, value/binding diagnostics, hover); in VS Code / Cursor / Windsurf the `webjs` extension bundles it automatically.
673
674
 
674
675
  **Rules:**
675
676
  - One component per file
@@ -678,17 +679,11 @@ value/binding diagnostics, hover); in VS Code / Cursor / Windsurf the
678
679
  - **If a light-DOM component authors its own custom CSS (a `<style>` block in `render()` or an imported stylesheet), every class selector MUST be prefixed with the component's tag name.** Either pattern works. Pick one and stay consistent:
679
680
  - `.my-widget__body`, `.my-widget__title` (BEM-ish)
680
681
  - `my-widget .body`, `my-widget .title` (descendant selector)
682
+ - **Never extend raw HTMLElement directly for app components.** Always subclass `WebComponent` (or the factory form `WebComponent({...})`) to hook into SSR, lifecycle, elision, and the reactive property system. Extend raw HTMLElement only for rare native-API edge cases (like form-associated `ElementInternals` or customized built-in elements), and add a `webjs-allow-htmlelement: <reason>` comment to acknowledge the exception.
681
683
  - Tag name must contain a hyphen (HTML spec)
682
684
  - Always call `Class.register('tag')`. That's the standard DOM API.
683
- - **Reactive props use `declare propName: Type` (no value) plus a default in `constructor()` after `super()`.** Never write `propName = value` or `propName: Type = value` as a class-field initializer. It compiles to `Object.defineProperty(this, …)` after `super()` and clobbers the framework's reactive accessor, silently breaking re-renders. `webjs check` flags this via the `reactive-props-use-declare` rule.
684
- - Component state lives in signals. Import `signal` from
685
- `@webjsdev/core`, read via `signal.get()` inside `render()`, write
686
- via `signal.set(value)`. Module-scope signals share state across
687
- components; instance signals (created in the constructor) carry
688
- component-local state. Reactive properties (`static properties =
689
- { foo: { type: ... } }` with a sibling `declare foo: T`) wrap HTML
690
- attributes, attribute reflection, and `.prop=${value}` SSR
691
- hydration.
685
+ - **Reactive props are declared via the base-class factory `WebComponent({ ... })`.** A hand-written `static properties = { ... }` throws at construction (`webjs check` flags it via `no-static-properties`). Never write `propName = value` or `propName: Type = value` as a class-field initializer on a reactive prop. It compiles to `Object.defineProperty(this, …)` after `super()` and clobbers the framework's reactive accessor, silently breaking re-renders (`webjs check` flags this via `reactive-props-no-class-field`). Set defaults via the `default` option or in the constructor. 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 shape and `webjs check` flags the `Object` form via `array-prop-uses-array-type`.
686
+ - Component state lives in signals. Import `signal` from `@webjsdev/core`, read via `signal.get()` inside `render()`, write via `signal.set(value)`. Module-scope signals share state across components; instance signals (created in the constructor) carry component-local state. Reactive properties (declared via the factory) wrap HTML attributes, attribute reflection, and `.prop=${value}` SSR hydration.
692
687
  - Use lifecycle hooks (`firstUpdated`, `updated`) only when needed
693
688
 
694
689
  ---
@@ -736,6 +731,17 @@ color (via the `@theme` tokens), typography, borders, radius, shadows,
736
731
  and interaction states (hover/focus/active/disabled, dark mode). Light
737
732
  DOM does not scope styles, so utilities apply directly.
738
733
 
734
+ **Pin a header with `position: fixed`, never `position: sticky`.** A
735
+ sticky header flickers its background for one frame on iOS WebKit (every
736
+ iOS browser) during a client-router navigation, because the preserved
737
+ header plus the scroll-to-top trips a WebKit sticky-repaint bug that the
738
+ usual GPU-promotion hacks (`translateZ`, `will-change`) do NOT fix. Use
739
+ `position: fixed` and reserve the header height on the content with a
740
+ `--header-height` variable (the scaffolded `app/layout.ts` does exactly
741
+ this, kept exact by a `ResizeObserver`). It is iOS-only, invisible on
742
+ desktop, Android, and in DevTools emulation, so it shows only on a real
743
+ device.
744
+
739
745
  **The lit muscle-memory trap.** If you have written lit, the habit is to
740
746
  scope CSS in a shadow root (`static styles = css\`\``) or write an inline
741
747
  `<style>` with semantic class names (`.hero`, `.feature`, `.card`) for
@@ -997,7 +1003,8 @@ component, applies its attributes, runs `willUpdate` and controllers'
997
1003
  `firstUpdated`, `updated`, or any other browser-only lifecycle hook.
998
1004
  Whatever state should appear on first paint MUST be set in the
999
1005
  constructor (after `super()`), derived in `willUpdate`, or derivable
1000
- from `static properties` + attributes on the rendered tag. Reading
1006
+ from the factory-declared reactive props + attributes on the rendered
1007
+ tag. Reading
1001
1008
  `this.getAttribute` / `hasAttribute` in `render()` works server-side (a
1002
1009
  server attribute shim backs the attribute methods), but a `Task`'s
1003
1010
  fetch still runs only on the client.