@webjsdev/cli 0.10.21 → 0.10.23

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/bin/webjs.js CHANGED
@@ -47,7 +47,7 @@ const USAGE = `webjs commands:
47
47
  webjs test [--server|--browser] Run server + browser tests
48
48
  webjs check [--json] Run correctness checks on the app (--json emits structured violations)
49
49
  webjs mcp Start the read-only MCP server (routes / actions / components / check)
50
- webjs doctor Verify project health (Node, tsconfig, env, vendor pins, importmap coherence, @webjsdev versions, git hook)
50
+ webjs doctor Verify project health (Node, tsconfig, env, vendor pins, importmap coherence, @webjsdev versions, git hook, page/layout elision)
51
51
  webjs types Generate .webjs/routes.d.ts (typed Route union + per-route params)
52
52
  webjs typecheck [tsc args...] Type-check the app with the project's tsc --noEmit (non-zero on errors)
53
53
  webjs create <name> [--template full-stack|api|saas] [--db sqlite|postgres] [--runtime node|bun] [--no-install] Scaffold a new webjs app
package/lib/create.js CHANGED
@@ -918,7 +918,6 @@ export type ActionResult<T> =
918
918
 
919
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.
920
920
  import { html, cspNonce } from '@webjsdev/core';
921
- import '@webjsdev/core/client-router';
922
921
  import '#components/theme-toggle.ts';
923
922
  // Webjs UI components are tiered:
924
923
  // - Tier 1 (button, card, input, label, alert, badge, separator, etc.) are
@@ -975,6 +974,26 @@ export default function RootLayout({ children }: { children: unknown }) {
975
974
  mq.addEventListener('change', apply);
976
975
  } catch (_) {}
977
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
+ })();
978
997
  </script>
979
998
  <script src="/public/tailwind-browser.js"></script>
980
999
  <!--
@@ -1064,7 +1083,9 @@ ${SHADCN_THEME}
1064
1083
  }
1065
1084
  /* Body + pseudo-elements utility classes can't reach. */
1066
1085
  html, body { margin: 0; }
1086
+ :root { --header-h: 56px; } /* fixed-header offset, kept exact by the script above */
1067
1087
  body {
1088
+ padding-top: var(--header-h);
1068
1089
  background: var(--bg);
1069
1090
  color: var(--fg);
1070
1091
  font: 16px/1.65 var(--font-sans);
@@ -1073,7 +1094,7 @@ ${SHADCN_THEME}
1073
1094
  ::selection { background: var(--accent-tint); color: var(--fg); }
1074
1095
  </style>
1075
1096
 
1076
- <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]">
1077
1098
  <a href="/" class="mr-auto inline-flex items-center gap-2 no-underline text-fg font-semibold text-[15px] leading-none tracking-tight">
1078
1099
  <span>${name}</span>
1079
1100
  </a>
package/lib/doctor.js CHANGED
@@ -36,7 +36,7 @@
36
36
 
37
37
  import { existsSync, statSync } from 'node:fs';
38
38
  import { readFile } from 'node:fs/promises';
39
- import { join } from 'node:path';
39
+ import { join, relative } from 'node:path';
40
40
  import { checkNodeInline } from './node-preflight.js';
41
41
 
42
42
  /**
@@ -795,6 +795,53 @@ function checkGitHook(appDir) {
795
795
  * instead of a real live resolve / node_modules read.
796
796
  * @returns {Promise<DoctorResult[]>}
797
797
  */
798
+ /**
799
+ * Advisory (#646): name why a page/layout SHIPS its module to the browser
800
+ * instead of being elided. A page/layout that is a pure carrier (import-only
801
+ * #605 / inert #179) stays out of the browser; one that ships whole is pinned
802
+ * by a specific client-effecting NON-component in its closure (a util touching
803
+ * a client global, a module-scope side effect, a bare side-effect import) or by
804
+ * its own client work. This turns that invisible #605/#179 regression into a
805
+ * named line. WARN only: a page legitimately MAY ship, and the analyser is
806
+ * biased toward shipping by design (server AGENTS invariant 7), so this is a
807
+ * "you may not have intended this" hint, never a hard fail.
808
+ * @param {string} appDir
809
+ * @returns {Promise<DoctorResult>}
810
+ */
811
+ async function checkElisionCarriers(appDir) {
812
+ const name = 'Page/layout elision (carrier hygiene)';
813
+ let report;
814
+ try {
815
+ const { analyzeAppElision } = await import('@webjsdev/server');
816
+ report = await analyzeAppElision(appDir);
817
+ } catch {
818
+ // Analysis unavailable (no app, malformed, server import failed): no advice.
819
+ return { name, status: 'pass', message: 'not analysed (no routable app or analysis unavailable)' };
820
+ }
821
+ if (!report.analysed) {
822
+ return { name, status: 'pass', message: 'not analysed (no routable app, or elision is disabled)' };
823
+ }
824
+ if (report.shipped.length === 0) {
825
+ return { name, status: 'pass', message: 'every page/layout is elided (a pure import-only or inert carrier)' };
826
+ }
827
+ const rel = (f) => relative(appDir, f) || f;
828
+ // Name the FIRST client-effecting blocker (there may be more than one; the
829
+ // module stays shipped until every such blocker is moved out).
830
+ const lines = report.shipped.map(({ file, blocker, reason }) =>
831
+ blocker
832
+ ? `${rel(file)} ships whole. Its first client-effecting blocker is ${rel(blocker)}, which ${reason} and is not a component`
833
+ : `${rel(file)} ships whole because it ${reason}`,
834
+ );
835
+ return {
836
+ name,
837
+ status: 'warn',
838
+ message:
839
+ `${report.shipped.length} page/layout module(s) ship to the browser instead of being elided:\n` +
840
+ lines.map((l) => ` ${l}`).join('\n'),
841
+ fix: 'Move the client work out of the page/layout closure (into a component, or a .server module reached through an action) so the carrier can be elided, or accept that it ships. See agent-docs/components.md.',
842
+ };
843
+ }
844
+
798
845
  export async function runDoctorChecks(appDir, opts = {}) {
799
846
  const cliDir = opts.cliDir || new URL('.', import.meta.url).pathname;
800
847
  const results = await Promise.all([
@@ -806,6 +853,7 @@ export async function runDoctorChecks(appDir, opts = {}) {
806
853
  checkWebjsVersions(appDir),
807
854
  checkImportmapCoherence(appDir, opts),
808
855
  Promise.resolve(checkGitHook(appDir)),
856
+ checkElisionCarriers(appDir),
809
857
  ]);
810
858
  return results;
811
859
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/cli",
3
- "version": "0.10.21",
3
+ "version": "0.10.23",
4
4
  "type": "module",
5
5
  "description": "webjs CLI - dev, start, create, db",
6
6
  "bin": {
@@ -137,6 +137,7 @@ self-review loop.
137
137
  `static styles = css\`...\`` for scoped CSS.
138
138
  - Custom-element tag names are passed to `.register('tag-name')`. They are NOT
139
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.
140
141
  - One function per server action file (`*.server.ts`).
141
142
  - Server-only code (a DB driver like `better-sqlite3`/`pg`, `node:*`, anything that needs Node APIs)
142
143
  goes only in `.server.{js,ts}` files, `route.ts` handlers, or
@@ -145,8 +146,22 @@ self-review loop.
145
146
  stub for the browser. `lib/` holds both server-only infra
146
147
  (the DB in `db/*.server.ts`) and browser-safe utilities (`lib/utils/cn.ts` with
147
148
  `cn`); follow the same rule per file.
148
- - Directives are deliberately minimal: only `unsafeHTML`, `live`, and `repeat`
149
- 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
150
165
  (`class=${active ? 'btn active' : 'btn'}`, `style=${'color:' + color}`,
151
166
  `${cond ? a : b}`) and lifecycle hooks (`this.query('#el')` in
152
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": [
@@ -113,8 +113,10 @@ self-review loop.
113
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.
114
114
  - One function per server action file (*.server.ts)
115
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.
116
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.
117
- - 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.
118
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.
119
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.
120
122
  - See AGENTS.md for the complete directive decision guide
@@ -107,13 +107,14 @@ each change must include.
107
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.
108
108
  - Tagged template: html`<div>${value}</div>` with css`...` for styles.
109
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.
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`).
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.
111
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.
112
112
  - Server actions: *.server.ts files with one exported async function each.
113
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.
114
- - 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.
115
115
  - Context: import { createContext, ContextProvider, ContextConsumer } from '@webjsdev/core/context'
116
116
  - Task: import { Task, TaskStatus } from '@webjsdev/core/task'
117
117
  - Routing: file-based under app/ (page.ts, layout.ts, route.ts, middleware.ts).
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.
118
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.
119
120
  - Don't skip tests or documentation updates.
@@ -562,7 +562,6 @@ CI gate).
562
562
 
563
563
  ```ts
564
564
  import { html, css, WebComponent } from '@webjsdev/core';
565
- import '@webjsdev/core/client-router'; // enable SPA nav
566
565
  import { unsafeHTML, live } from '@webjsdev/core/directives';
567
566
  import { createContext } from '@webjsdev/core/context';
568
567
  import { Task } from '@webjsdev/core/task';
@@ -721,6 +720,9 @@ Practical consequences for agents writing webjs code.
721
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`) |
722
721
  | `@property()` decorator | Banned by invariant 10 (erasable TS) | Pass the shape to the base-class factory `WebComponent({ ... })` (the only supported form) |
723
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 |
724
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 |
725
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()` |
726
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({ ... })` |
@@ -787,8 +789,9 @@ reference: https://docs.webjs.com/docs/server-actions
787
789
 
788
790
  ## Client navigation patterns (auto-magic)
789
791
 
790
- The client router enables itself when the scaffolded root layout imports
791
- `@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
792
795
  `<form action>` on the page is enhanced into a partial-swap navigation
793
796
  or submission automatically**. You don't call a router API. Write
794
797
  standard HTML; the swap happens.
@@ -403,6 +403,7 @@ modules/
403
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."
404
404
  - Routes (`app/**/page.ts`, `app/**/route.ts`) must be thin: import logic from modules
405
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.
406
407
 
407
408
  ---
408
409
 
@@ -505,7 +506,7 @@ SSR, page actions, server-action RPC, auth + CSRF), drive
505
506
 
506
507
  ```ts
507
508
  import { createRequestHandler } from '@webjsdev/server';
508
- import { testRequest, getCsrf, invokeActionForTest, loginAndGetCookies, withSessionCookie }
509
+ import { testRequest, invokeActionForTest, loginAndGetCookies, withSessionCookie }
509
510
  from '@webjsdev/server/testing';
510
511
 
511
512
  const app = await createRequestHandler({ appDir: process.cwd(), dev: true });
@@ -523,8 +524,9 @@ const out = await invokeActionForTest(app, 'modules/posts/actions/create.server.
523
524
 
524
525
  Prefer `invokeActionForTest` over a direct import of the action when you want
525
526
  to verify the production contract: it exercises the wire serializer (a `Date` /
526
- `Map` arg survives), CSRF, and prod error sanitization, which a direct call
527
- bypasses. The saas template's `test/auth/auth.test.ts` is a worked example.
527
+ `Map` arg survives), the Origin / Sec-Fetch-Site CSRF check (it models a
528
+ same-origin POST), and prod error sanitization, which a direct call bypasses.
529
+ The saas template's `test/auth/auth.test.ts` is a worked example.
528
530
 
529
531
  This is also why the auth test lives at `test/auth/auth.test.ts` (the
530
532
  feature-folder convention), NOT `test/unit/auth.test.ts`. Test KIND is a
@@ -678,9 +680,10 @@ Reactive properties are declared one way: pass the properties shape directly to
678
680
  - **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
681
  - `.my-widget__body`, `.my-widget__title` (BEM-ish)
680
682
  - `my-widget .body`, `my-widget .title` (descendant selector)
683
+ - **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
684
  - Tag name must contain a hyphen (HTML spec)
682
685
  - Always call `Class.register('tag')`. That's the standard DOM API.
683
- - **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.
686
+ - **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`.
684
687
  - 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.
685
688
  - Use lifecycle hooks (`firstUpdated`, `updated`) only when needed
686
689
 
@@ -729,6 +732,17 @@ color (via the `@theme` tokens), typography, borders, radius, shadows,
729
732
  and interaction states (hover/focus/active/disabled, dark mode). Light
730
733
  DOM does not scope styles, so utilities apply directly.
731
734
 
735
+ **Pin a header with `position: fixed`, never `position: sticky`.** A
736
+ sticky header flickers its background for one frame on iOS WebKit (every
737
+ iOS browser) during a client-router navigation, because the preserved
738
+ header plus the scroll-to-top trips a WebKit sticky-repaint bug that the
739
+ usual GPU-promotion hacks (`translateZ`, `will-change`) do NOT fix. Use
740
+ `position: fixed` and reserve the header height on the content with a
741
+ `--header-height` variable (the scaffolded `app/layout.ts` does exactly
742
+ this, kept exact by a `ResizeObserver`). It is iOS-only, invisible on
743
+ desktop, Android, and in DevTools emulation, so it shows only on a real
744
+ device.
745
+
732
746
  **The lit muscle-memory trap.** If you have written lit, the habit is to
733
747
  scope CSS in a shadow root (`static styles = css\`\``) or write an inline
734
748
  `<style>` with semantic class names (`.hero`, `.feature`, `.card`) for