@webjsdev/cli 0.10.20 → 0.10.21

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) {
@@ -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.21",
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
 
@@ -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
 
@@ -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,7 +107,7 @@ 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`).
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.
@@ -109,5 +115,5 @@ each change must include.
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
+ - 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
119
  - 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
@@ -597,12 +598,13 @@ const secret = process.env.AUTH_SECRET; // undefined (fail-closed)
597
598
  ```ts
598
599
  import { WebComponent, html, css } from '@webjsdev/core';
599
600
 
600
- export class Counter extends WebComponent {
601
- static properties = { count: { type: Number } };
601
+ // Recommended declare-free base-class factory style
602
+ export class Counter extends WebComponent({
603
+ count: Number
604
+ }) {
602
605
  static styles = css`button { padding: 8px 12px; }`; // shadow-DOM only
603
606
  // static shadow = true; // opt into shadow DOM (default: light DOM)
604
607
  // static lazy = true; // download JS only when scrolled into view
605
- declare count: number; // TypeScript-only typed accessor
606
608
 
607
609
  constructor() {
608
610
  super();
@@ -629,8 +631,9 @@ the click handler is inert). Two consequences for how you write code:
629
631
  1. **Defaults for the first paint go in `constructor()`** (after
630
632
  `super()`), never as class-field initializers (which break
631
633
  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.
634
+ doesn't run). For reactive properties declared via the
635
+ `WebComponent({ ... })` factory, set the default in the constructor
636
+ or pass the `default` option (e.g. `prop(Number, { default: 0 })`).
634
637
  2. **`connectedCallback` is browser-only.** Use it for
635
638
  `localStorage`, viewport size, online status, or anything that
636
639
  genuinely can't be known on the server. Read the value, then
@@ -659,7 +662,8 @@ See [Progressive Enhancement](https://docs.webjs.dev/docs/progressive-enhancemen
659
662
  ## Lit muscle-memory gotchas (read if you have written lit before)
660
663
 
661
664
  Webjs's runtime API matches lit. The `WebComponent` base class,
662
- `static properties`, the lifecycle hooks, ReactiveControllers, the
665
+ reactive properties (declared via the `WebComponent({ ... })` factory),
666
+ the lifecycle hooks, ReactiveControllers, the
663
667
  directive set, `html` / `css` tagged templates. The **rendering
664
668
  model**, however, is different. Pure-lit patterns that work fine in a
665
669
  client-only lit app break in webjs's SSR pipeline or its reactivity
@@ -714,11 +718,12 @@ Practical consequences for agents writing webjs code.
714
718
  | 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
719
  | `window.X` / `document.X` in constructor or `render()` | SSR crash | Move to `connectedCallback` |
716
720
  | 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` |
721
+ | 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
+ | `@property()` decorator | Banned by invariant 10 (erasable TS) | Pass the shape to the base-class factory `WebComponent({ ... })` (the only supported form) |
723
+ | 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`) |
719
724
  | 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
725
  | `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 |
726
+ | `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
727
  | `ContextProvider` for server-known data | Default value during SSR, content shift on hydration | Pass via props from the page function |
723
728
 
724
729
  The full annotated catalog with code examples lives in the framework
@@ -1218,7 +1223,14 @@ composition, so a nested shell ends up dropped by the HTML parser.
1218
1223
 
1219
1224
  ## Workflow expectations for AI agents
1220
1225
 
1221
- 1. Branch before editing. Never push to `main` directly.
1226
+ 1. Branch before editing. Never push to `main` directly. **If more than one
1227
+ agent may work this repo at once, give each task its own git worktree, not a
1228
+ shared checkout** (`git worktree add -b <branch> ../<repo>-<slug> origin/main`,
1229
+ `cd` in, work there, `git worktree remove` after merge). Two agents in one
1230
+ working directory collide: a `git checkout` in one moves `HEAD` under the
1231
+ other, so the next commit lands on the wrong branch. Git enforces
1232
+ one-branch-per-worktree, so worktrees prevent it; a lone agent in a clean
1233
+ checkout may use a plain branch.
1222
1234
  2. Every code change comes with a test, AGENTS.md / docs updates if the
1223
1235
  feature surface changed, `webjs check` passing. A unit test is not
1224
1236
  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
 
@@ -635,10 +643,11 @@ Any stateful behavior with a Tier-2 element uses the element.
635
643
  ```ts
636
644
  import { WebComponent, html } from '@webjsdev/core';
637
645
 
638
- export class MyWidget extends WebComponent {
639
- static properties = { label: { type: String }, count: { type: Number } };
640
- declare label: string;
641
- declare count: number;
646
+ // Recommended declare-free base-class factory style
647
+ export class MyWidget extends WebComponent({
648
+ label: String,
649
+ count: Number
650
+ }) {
642
651
  // Light DOM is the default; Tailwind utility classes apply directly.
643
652
 
644
653
  constructor() {
@@ -660,16 +669,7 @@ export class MyWidget extends WebComponent {
660
669
  MyWidget.register('my-widget');
661
670
  ```
662
671
 
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.
672
+ 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
673
 
674
674
  **Rules:**
675
675
  - One component per file
@@ -680,15 +680,8 @@ value/binding diagnostics, hover); in VS Code / Cursor / Windsurf the
680
680
  - `my-widget .body`, `my-widget .title` (descendant selector)
681
681
  - Tag name must contain a hyphen (HTML spec)
682
682
  - 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.
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.
684
+ - 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
685
  - Use lifecycle hooks (`firstUpdated`, `updated`) only when needed
693
686
 
694
687
  ---
@@ -997,7 +990,8 @@ component, applies its attributes, runs `willUpdate` and controllers'
997
990
  `firstUpdated`, `updated`, or any other browser-only lifecycle hook.
998
991
  Whatever state should appear on first paint MUST be set in the
999
992
  constructor (after `super()`), derived in `willUpdate`, or derivable
1000
- from `static properties` + attributes on the rendered tag. Reading
993
+ from the factory-declared reactive props + attributes on the rendered
994
+ tag. Reading
1001
995
  `this.getAttribute` / `hasAttribute` in `render()` works server-side (a
1002
996
  server attribute shim backs the attribute methods), but a `Task`'s
1003
997
  fetch still runs only on the client.