@webjsdev/cli 0.10.8 → 0.10.9

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
@@ -41,7 +41,8 @@ const USAGE = `webjs commands:
41
41
  webjs dev [--port 8080] Start dev server with live reload
42
42
  webjs start [--port 8080] Start production server (serves source directly, no build step)
43
43
  webjs test [--server|--browser] Run server + browser tests
44
- webjs check Run correctness checks on the app
44
+ webjs check [--json] Run correctness checks on the app (--json emits structured violations)
45
+ webjs mcp Start the read-only MCP server (routes / actions / components / check)
45
46
  webjs doctor Verify project health (Node, tsconfig, env, vendor pins, @webjsdev versions, git hook)
46
47
  webjs types Generate .webjs/routes.d.ts (typed Route union + per-route params)
47
48
  webjs typecheck [tsc args...] Type-check the app with the project's tsc --noEmit (non-zero on errors)
@@ -266,6 +267,18 @@ async function main() {
266
267
 
267
268
  const violations = await checkConventions(process.cwd());
268
269
 
270
+ // --json emits the raw structured violations + a summary count as JSON,
271
+ // so an agent running `webjs check` in a loop consumes structured data
272
+ // instead of regex-scraping stdout. The shared projector keeps this byte-
273
+ // identical to the MCP `check` tool. The non-zero exit on violations is
274
+ // preserved (an agent gates on the exit code AND parses the report).
275
+ if (rest.includes('--json')) {
276
+ const { projectCheck } = await import('../lib/check-json.js');
277
+ console.log(JSON.stringify(projectCheck(violations)));
278
+ if (violations.length > 0) process.exit(1);
279
+ break;
280
+ }
281
+
269
282
  if (violations.length === 0) {
270
283
  console.log('webjs check: all checks pass ✓');
271
284
  } else {
@@ -631,6 +644,29 @@ Full docs: https://docs.webjs.com`);
631
644
  ` --from PROVIDER CDN to resolve through. One of: ${[...SUPPORTED_PROVIDERS].join(', ')}. Default: jspm.`);
632
645
  process.exit(1);
633
646
  }
647
+ case 'mcp': {
648
+ // Read-only MCP server (#262) over stdio. STDOUT is the JSON-RPC channel,
649
+ // so nothing here may write to stdout: the data functions are read-only
650
+ // and `runMcpServer` routes all diagnostics to stderr. The CLI version is
651
+ // advertised in the initialize handshake's serverInfo.
652
+ const { readFileSync } = await import('node:fs');
653
+ let version = '0.0.0';
654
+ try {
655
+ const pkg = JSON.parse(
656
+ readFileSync(join(__dirname, '..', 'package.json'), 'utf8'),
657
+ );
658
+ version = pkg.version || version;
659
+ } catch {}
660
+ const { runMcpServer } = await import('../lib/mcp.js');
661
+ await runMcpServer({
662
+ stdin: process.stdin,
663
+ stdout: process.stdout,
664
+ stderr: process.stderr,
665
+ cwd: process.cwd(),
666
+ version,
667
+ });
668
+ break;
669
+ }
634
670
  case 'help':
635
671
  case undefined:
636
672
  console.log(USAGE);
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Shared JSON projector for `webjs check` violations (#262).
3
+ *
4
+ * `webjs check --json` and the `webjs mcp` server's `check` tool BOTH return
5
+ * the identical shape, so the projection lives here once. The input is the raw
6
+ * `Violation[]` from `checkConventions(appDir)` (each `{ rule, file, message,
7
+ * fix }`); the output adds a `summary` count plus a per-rule breakdown so an
8
+ * agent consuming the structured output never has to regex-scrape stdout.
9
+ *
10
+ * Pure and side-effect-free: it neither reads files nor prints. The caller owns
11
+ * running `checkConventions` and (for the CLI) the non-zero exit when there are
12
+ * violations.
13
+ *
14
+ * @module check-json
15
+ */
16
+
17
+ /**
18
+ * @typedef {{ rule: string, file: string, message: string, fix: string }} Violation
19
+ */
20
+
21
+ /**
22
+ * @typedef {{
23
+ * violations: Violation[],
24
+ * summary: { count: number, byRule: Record<string, number> },
25
+ * }} CheckReport
26
+ */
27
+
28
+ /**
29
+ * Project a raw `Violation[]` into the structured `{ violations, summary }`
30
+ * report shared by `check --json` and the MCP `check` tool. `violations` is
31
+ * passed through verbatim (the `{ rule, file, message, fix }` shape), and
32
+ * `summary.byRule` tallies how many violations each rule produced.
33
+ *
34
+ * @param {Violation[]} violations
35
+ * @returns {CheckReport}
36
+ */
37
+ export function projectCheck(violations) {
38
+ /** @type {Record<string, number>} */
39
+ const byRule = {};
40
+ for (const v of violations) {
41
+ byRule[v.rule] = (byRule[v.rule] || 0) + 1;
42
+ }
43
+ return {
44
+ violations,
45
+ summary: { count: violations.length, byRule },
46
+ };
47
+ }
package/lib/mcp.js ADDED
@@ -0,0 +1,408 @@
1
+ /**
2
+ * `webjs mcp`: a minimal, READ-ONLY Model Context Protocol server (#262).
3
+ *
4
+ * Exposes the live introspection surface an AI agent needs while editing a
5
+ * webjs app (the route table, registered server actions with their RPC
6
+ * endpoints, registered custom-element tags, and the structured `webjs check`
7
+ * violations) over MCP's stdio transport. Every tool REUSES an existing
8
+ * `@webjsdev/server` data function and MUTATES NOTHING. The prior art is
9
+ * Next.js's `next-devtools-mcp` (get_routes / get_server_action_by_id /
10
+ * get_errors); this is deliberately tiny.
11
+ *
12
+ * Transport: MCP stdio is newline-delimited JSON-RPC 2.0. One JSON object per
13
+ * line arrives on stdin; exactly one JSON response line per request is written
14
+ * to stdout. STDOUT IS THE PROTOCOL CHANNEL, so this module writes ONLY
15
+ * JSON-RPC frames there and routes every diagnostic to stderr. A malformed
16
+ * input line is answered with a JSON-RPC parse error and never crashes the
17
+ * loop. Hand-rolled with zero new dependency (webjs is buildless +
18
+ * minimal-deps).
19
+ *
20
+ * @module mcp
21
+ */
22
+
23
+ import { createInterface } from 'node:readline';
24
+ import { relative } from 'node:path';
25
+
26
+ const PROTOCOL_VERSION = '2024-11-05';
27
+
28
+ /**
29
+ * The four read-only tools. Each takes an optional `{ appDir }` (default the
30
+ * server's cwd) and projects an EXISTING `@webjsdev/server` function's output
31
+ * into an agent-friendly shape. Descriptions are crisp so a model picks the
32
+ * right tool without reading source.
33
+ */
34
+ const TOOL_DEFS = [
35
+ {
36
+ name: 'list_routes',
37
+ description:
38
+ 'List the app route table: SSR pages (path, file, dynamic flag, param names) and route.{js,ts} API handlers (path, file, HTTP methods). Read-only.',
39
+ },
40
+ {
41
+ name: 'list_actions',
42
+ description:
43
+ 'List registered server actions (the .server.{js,ts} files with "use server"): file, exported function name, and the /__webjs/action/<hash>/<fn> RPC endpoint. Read-only.',
44
+ },
45
+ {
46
+ name: 'list_components',
47
+ description:
48
+ 'List registered custom-element tags: tag name, defining file, and class name. Read-only.',
49
+ },
50
+ {
51
+ name: 'check',
52
+ description:
53
+ 'Run webjs check (correctness rules) and return the structured violations { rule, file, message, fix } plus a summary count and per-rule breakdown. Read-only.',
54
+ },
55
+ ];
56
+
57
+ /** The shared input schema: every tool takes an optional appDir override. */
58
+ const TOOL_INPUT_SCHEMA = {
59
+ type: 'object',
60
+ properties: {
61
+ appDir: {
62
+ type: 'string',
63
+ description: 'App directory to introspect. Defaults to the server cwd.',
64
+ },
65
+ },
66
+ required: [],
67
+ };
68
+
69
+ /**
70
+ * Lexically extract the names exported from a module source. Recognises the
71
+ * common forms a server-action / route file uses without LOADING the module
72
+ * (loading would run its top-level side effects: Prisma init, DB connects).
73
+ *
74
+ * export async function foo() {} export function foo() {}
75
+ * export const foo = ... export let/var foo = ...
76
+ * export default ... (recorded as 'default')
77
+ * export { a, b as c } (the EXPORTED name, so `c`)
78
+ *
79
+ * @param {string} src
80
+ * @returns {string[]} unique export names, in source order
81
+ */
82
+ export function extractExportNames(src) {
83
+ /** @type {string[]} */
84
+ const names = [];
85
+ const add = (n) => { if (n && !names.includes(n)) names.push(n); };
86
+
87
+ // export [async] function NAME / export const|let|var NAME / export class NAME
88
+ const declRe =
89
+ /\bexport\s+(?:async\s+)?(?:function\*?|const|let|var|class)\s+([A-Za-z_$][\w$]*)/g;
90
+ let m;
91
+ while ((m = declRe.exec(src)) !== null) add(m[1]);
92
+
93
+ // export default ...
94
+ if (/\bexport\s+default\b/.test(src)) add('default');
95
+
96
+ // export { a, b as c, default as d }
97
+ const namedRe = /\bexport\s*\{([^}]*)\}/g;
98
+ while ((m = namedRe.exec(src)) !== null) {
99
+ for (const part of m[1].split(',')) {
100
+ const seg = part.trim();
101
+ if (!seg) continue;
102
+ // `local as exported` -> the exported name is what callers import.
103
+ const asMatch = /\bas\s+([A-Za-z_$][\w$]*)\s*$/.exec(seg);
104
+ if (asMatch) add(asMatch[1]);
105
+ else {
106
+ const idMatch = /^([A-Za-z_$][\w$]*)$/.exec(seg);
107
+ if (idMatch) add(idMatch[1]);
108
+ }
109
+ }
110
+ }
111
+ return names;
112
+ }
113
+
114
+ /**
115
+ * Lexically extract the HTTP method exports of a route.{js,ts} file. The webjs
116
+ * API router (`api.js`) dispatches the five standard verbs, plus `WS` for a
117
+ * WebSocket upgrade. We report exactly those that are exported, NOT `HEAD` /
118
+ * `OPTIONS` (the router does not dispatch a named handler for them, so listing
119
+ * them would imply a route the framework ignores). Read-only: no module load.
120
+ *
121
+ * @param {string} src
122
+ * @returns {string[]}
123
+ */
124
+ export function extractRouteMethods(src) {
125
+ const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'WS'];
126
+ const exported = new Set(extractExportNames(src));
127
+ return METHODS.filter((m) => exported.has(m));
128
+ }
129
+
130
+ /**
131
+ * The literal URL path for a page/api directory: `blog/[slug]` -> `/blog/[slug]`,
132
+ * the root `.` -> `/`. Route groups `(group)` and `_private` segments drop, the
133
+ * same normalization `buildRouteTable` uses for matching.
134
+ *
135
+ * @param {string} routeDir POSIX-style, `.` for the app root.
136
+ * @returns {string}
137
+ */
138
+ function routePathFromDir(routeDir) {
139
+ if (!routeDir || routeDir === '.') return '/';
140
+ const segs = routeDir
141
+ .split('/')
142
+ .filter((s) => !(s.startsWith('(') && s.endsWith(')')) && !s.startsWith('_'));
143
+ return segs.length ? '/' + segs.join('/') : '/';
144
+ }
145
+
146
+ /**
147
+ * The tool runners. Each is async, takes `(appDir)`, and returns a plain
148
+ * JSON-serialisable projection of an existing server data function. All are
149
+ * read-only.
150
+ *
151
+ * @param {{ buildRouteTable: Function, buildActionIndex: Function, hashFile: Function, scanComponents: Function, checkConventions: Function, projectCheck: Function, readFile: Function }} deps
152
+ */
153
+ export function makeToolRunners(deps) {
154
+ const {
155
+ buildRouteTable,
156
+ buildActionIndex,
157
+ hashFile,
158
+ scanComponents,
159
+ checkConventions,
160
+ projectCheck,
161
+ readFile,
162
+ } = deps;
163
+
164
+ return {
165
+ async list_routes(appDir) {
166
+ const table = await buildRouteTable(appDir);
167
+ const pages = await Promise.all(
168
+ table.pages.map(async (r) => {
169
+ /** @type {{ path: string, file: string, dynamic?: boolean, params?: string[] }} */
170
+ const out = {
171
+ path: routePathFromDir(r.routeDir),
172
+ file: relative(appDir, r.file),
173
+ };
174
+ if (r.paramNames && r.paramNames.length) {
175
+ out.dynamic = true;
176
+ out.params = r.paramNames;
177
+ }
178
+ return out;
179
+ }),
180
+ );
181
+ const apis = await Promise.all(
182
+ table.apis.map(async (r) => {
183
+ let methods = [];
184
+ try {
185
+ methods = extractRouteMethods(await readFile(r.file, 'utf8'));
186
+ } catch {}
187
+ return {
188
+ path: routePathFromDir(r.routeDir),
189
+ file: relative(appDir, r.file),
190
+ methods,
191
+ };
192
+ }),
193
+ );
194
+ return { pages, apis };
195
+ },
196
+
197
+ async list_actions(appDir) {
198
+ // `skipExposeLoad` builds the file -> hash maps WITHOUT importing any
199
+ // `expose()` module, so this stays truly read-only (no Prisma/DB init, and
200
+ // no stray stdout from a loaded module corrupting the JSON-RPC channel).
201
+ // The RPC hash is over the file path only, so no module load is needed.
202
+ const idx = await buildActionIndex(appDir, false, { skipExposeLoad: true });
203
+ /** @type {Array<{ file: string, fn: string, endpoint: string }>} */
204
+ const actions = [];
205
+ for (const [file, hash] of idx.fileToHash) {
206
+ let names = [];
207
+ try {
208
+ names = extractExportNames(await readFile(file, 'utf8'));
209
+ } catch {}
210
+ for (const fn of names) {
211
+ actions.push({
212
+ file: relative(appDir, file),
213
+ fn,
214
+ endpoint: `/__webjs/action/${hash}/${fn}`,
215
+ });
216
+ }
217
+ }
218
+ // Stable order for deterministic output.
219
+ actions.sort((a, b) =>
220
+ a.file === b.file ? a.fn.localeCompare(b.fn) : a.file.localeCompare(b.file),
221
+ );
222
+ return actions;
223
+ },
224
+
225
+ async list_components(appDir) {
226
+ const comps = await scanComponents(appDir);
227
+ return comps
228
+ .map((c) => ({
229
+ tag: c.tag,
230
+ file: relative(appDir, c.file),
231
+ className: c.className,
232
+ }))
233
+ .sort((a, b) => a.tag.localeCompare(b.tag));
234
+ },
235
+
236
+ async check(appDir) {
237
+ const violations = await checkConventions(appDir);
238
+ return projectCheck(violations);
239
+ },
240
+ };
241
+ }
242
+
243
+ /**
244
+ * A JSON-RPC 2.0 result frame.
245
+ * @param {string|number|null} id
246
+ * @param {any} result
247
+ */
248
+ function rpcResult(id, result) {
249
+ return { jsonrpc: '2.0', id, result };
250
+ }
251
+
252
+ /**
253
+ * A JSON-RPC 2.0 error frame.
254
+ * @param {string|number|null} id
255
+ * @param {number} code
256
+ * @param {string} message
257
+ */
258
+ function rpcError(id, code, message) {
259
+ return { jsonrpc: '2.0', id, error: { code, message } };
260
+ }
261
+
262
+ /**
263
+ * Run the read-only webjs MCP server over the given streams. Reads
264
+ * newline-delimited JSON-RPC from `stdin`, writes one response line per request
265
+ * to `stdout`, and logs diagnostics to `stderr` ONLY (stdout is the protocol
266
+ * channel). Resolves when stdin ends (clean shutdown).
267
+ *
268
+ * Injectable streams + `cwd` keep it testable in-process with PassThrough
269
+ * streams; the bin passes the real `process.std*` + `process.cwd()`.
270
+ *
271
+ * @param {{
272
+ * stdin: NodeJS.ReadableStream,
273
+ * stdout: NodeJS.WritableStream,
274
+ * stderr: NodeJS.WritableStream,
275
+ * cwd: string,
276
+ * version?: string,
277
+ * deps?: object,
278
+ * }} opts
279
+ * @returns {Promise<void>}
280
+ */
281
+ export async function runMcpServer(opts) {
282
+ const { stdin, stdout, stderr, cwd } = opts;
283
+ const version = opts.version || '0.0.0';
284
+
285
+ // Resolve the data functions from @webjsdev/server (reading them is
286
+ // read-only). Injectable for tests so they need not boot the real server.
287
+ let deps = opts.deps;
288
+ if (!deps) {
289
+ const server = await import('@webjsdev/server');
290
+ const check = await import('@webjsdev/server/check');
291
+ const { readFile } = await import('node:fs/promises');
292
+ const { projectCheck } = await import('./check-json.js');
293
+ deps = {
294
+ buildRouteTable: server.buildRouteTable,
295
+ buildActionIndex: server.buildActionIndex,
296
+ hashFile: server.hashFile,
297
+ scanComponents: server.scanComponents,
298
+ checkConventions: check.checkConventions,
299
+ projectCheck,
300
+ readFile,
301
+ };
302
+ }
303
+ const runners = makeToolRunners(deps);
304
+
305
+ /** Write one JSON-RPC frame as a single line to stdout. */
306
+ const send = (frame) => {
307
+ stdout.write(JSON.stringify(frame) + '\n');
308
+ };
309
+ /** Diagnostics go to stderr only, never stdout. */
310
+ const logErr = (msg) => {
311
+ try { stderr.write(`[webjs mcp] ${msg}\n`); } catch {}
312
+ };
313
+
314
+ /**
315
+ * Dispatch one parsed JSON-RPC message. Returns a frame to send, or null
316
+ * for a notification (no `id`) which gets no response.
317
+ */
318
+ const dispatch = async (msg) => {
319
+ const id = msg && Object.prototype.hasOwnProperty.call(msg, 'id') ? msg.id : null;
320
+ const isNotification = id === null || id === undefined;
321
+ const method = msg && msg.method;
322
+
323
+ if (method === 'initialize') {
324
+ return rpcResult(id, {
325
+ protocolVersion: PROTOCOL_VERSION,
326
+ capabilities: { tools: {} },
327
+ serverInfo: { name: 'webjs', version },
328
+ });
329
+ }
330
+
331
+ // `notifications/initialized` (and any other notification) gets no reply.
332
+ if (isNotification) return null;
333
+
334
+ if (method === 'tools/list') {
335
+ return rpcResult(id, {
336
+ tools: TOOL_DEFS.map((t) => ({
337
+ name: t.name,
338
+ description: t.description,
339
+ inputSchema: TOOL_INPUT_SCHEMA,
340
+ })),
341
+ });
342
+ }
343
+
344
+ if (method === 'tools/call') {
345
+ const params = (msg && msg.params) || {};
346
+ const name = params.name;
347
+ const args = params.arguments || {};
348
+ const runner = runners[name];
349
+ if (!runner) {
350
+ return rpcError(id, -32602, `Unknown tool: ${String(name)}`);
351
+ }
352
+ const appDir = typeof args.appDir === 'string' && args.appDir ? args.appDir : cwd;
353
+ try {
354
+ const result = await runner(appDir);
355
+ return rpcResult(id, {
356
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
357
+ });
358
+ } catch (e) {
359
+ // A tool failure is an MCP tool-result error (isError), not a transport
360
+ // error, so the agent sees the message in the content channel.
361
+ logErr(`tool ${name} failed: ${e && e.message ? e.message : e}`);
362
+ return rpcResult(id, {
363
+ isError: true,
364
+ content: [
365
+ { type: 'text', text: `Error running ${name}: ${e && e.message ? e.message : String(e)}` },
366
+ ],
367
+ });
368
+ }
369
+ }
370
+
371
+ return rpcError(id, -32601, `Method not found: ${String(method)}`);
372
+ };
373
+
374
+ const rl = createInterface({ input: stdin, crlfDelay: Infinity });
375
+
376
+ await new Promise((resolveRun) => {
377
+ // Serialise line handling so responses preserve request order even though
378
+ // dispatch is async.
379
+ let chain = Promise.resolve();
380
+ rl.on('line', (line) => {
381
+ const trimmed = line.trim();
382
+ if (!trimmed) return;
383
+ chain = chain.then(async () => {
384
+ let msg;
385
+ try {
386
+ msg = JSON.parse(trimmed);
387
+ } catch {
388
+ // Malformed line: a JSON-RPC parse error, never a crash.
389
+ send(rpcError(null, -32700, 'Parse error'));
390
+ return;
391
+ }
392
+ try {
393
+ const frame = await dispatch(msg);
394
+ if (frame) send(frame);
395
+ } catch (e) {
396
+ logErr(`dispatch error: ${e && e.message ? e.message : e}`);
397
+ const id =
398
+ msg && Object.prototype.hasOwnProperty.call(msg, 'id') ? msg.id : null;
399
+ send(rpcError(id, -32603, 'Internal error'));
400
+ }
401
+ });
402
+ });
403
+ rl.on('close', () => {
404
+ // Drain the in-flight chain, then resolve (clean shutdown on stdin end).
405
+ chain.then(() => resolveRun()).catch(() => resolveRun());
406
+ });
407
+ });
408
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/cli",
3
- "version": "0.10.8",
3
+ "version": "0.10.9",
4
4
  "type": "module",
5
5
  "description": "webjs CLI - dev, start, create, db",
6
6
  "bin": {
@@ -114,6 +114,20 @@ self-review loop.
114
114
  `static styles = css\`...\``) or third-party-embed isolation. `<slot>`
115
115
  projection works identically in both modes (named slots, fallback content,
116
116
  `assignedNodes` / `slotchange`, first-wins resolution).
117
+ - **Tailwind-first styling.** Tailwind utilities are the strong default for
118
+ pages AND light-DOM components: layout, spacing, color (via `@theme`
119
+ tokens), typography, borders, radius, shadows, interaction states. Light
120
+ DOM does not scope, so utilities apply directly. The lit reflex to scope
121
+ CSS (`static styles = css\`...\``) or write an inline `<style>` with
122
+ semantic class names (`.hero`, `.card`) in a light-DOM component is wrong:
123
+ the scoped block needs `static shadow = true`, and inline class names leak
124
+ globally. When a utility bundle repeats, extract a `lib/utils/ui.ts` helper
125
+ returning an `` html`...` `` fragment, not a CSS class. Reserve raw CSS for
126
+ the allowlist (design tokens / `@theme`, `@property` + `@keyframes`,
127
+ `::-webkit-scrollbar`, `prefers-reduced-motion`, complex `color-mix()` /
128
+ gradients); when unavoidable in a light-DOM component, prefix every class
129
+ selector with the component tag. Shadow-DOM components legitimately use
130
+ `static styles = css\`...\`` for scoped CSS.
117
131
  - Custom-element tag names are passed to `.register('tag-name')`. They are NOT
118
132
  a static field on the class.
119
133
  - One function per server action file (`*.server.ts`).
@@ -4,6 +4,11 @@
4
4
  "type": "stdio",
5
5
  "command": "npx",
6
6
  "args": ["@playwright/mcp@latest"]
7
+ },
8
+ "webjs": {
9
+ "type": "stdio",
10
+ "command": "npx",
11
+ "args": ["@webjsdev/cli", "mcp"]
7
12
  }
8
13
  }
9
14
  }
@@ -103,7 +103,8 @@ self-review loop.
103
103
 
104
104
  - No build step: source files are served as ES modules
105
105
  - **Erasable TypeScript only.** Node 24+ strips types via `module.stripTypeScriptTypes` (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.
106
- - Web components with shadow DOM: use `static styles = css` not inline styles
106
+ - **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.
107
+ - 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.
107
108
  - One function per server action file (*.server.ts)
108
109
  - Components must call customElements.define('tag', Class)
109
110
  - Server-only code (@prisma/client, 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. lib/ holds both server-only infra (lib/prisma.server.ts) 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.
@@ -100,7 +100,7 @@ each change must include.
100
100
  build tools or bundlers in the critical path.
101
101
  - **Erasable TypeScript only.** Node 24+ strips types via `module.stripTypeScriptTypes` (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
102
  - Tagged template: html`<div>${value}</div>` with css`...` for styles.
103
- Don't use inline `style="..."` on components (use `static styles = css\`...\``).
103
+ - **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
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.
105
105
  - Server actions: *.server.ts files with one exported async function each.
106
106
  - Server-only code (@prisma/client, 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. lib/ holds both server-only infra (lib/prisma.server.ts) and browser-safe utilities (lib/utils/cn.ts with cn); apply the same rule per file.
@@ -618,7 +618,7 @@ Practical consequences for agents writing webjs code.
618
618
  | Top-level `import` of a browser-only library | SSR crash | Dynamic `import()` inside `connectedCallback` |
619
619
  | Class-field initializer for a reactive property (`student: Student = {...}`) | Silently breaks reactivity (overwrites the framework accessor) | `declare student: Student` plus constructor default |
620
620
  | `@property()` decorator | Banned by invariant 10 (erasable TS) | `static properties = { ... }` plus `declare` |
621
- | `static styles = css` block without `static shadow = true` | Styles leak globally; the framework warns at runtime | Add `static shadow = true`, or use Tailwind utilities |
621
+ | 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 |
622
622
  | `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()` |
623
623
  | `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 |
624
624
  | `ContextProvider` for server-known data | Default value during SSR, content shift on hydration | Pass via props from the page function |
@@ -627,6 +627,30 @@ The full annotated catalog with code examples lives in the framework
627
627
  repo at
628
628
  [`agent-docs/lit-muscle-memory-gotchas.md`](https://github.com/webjsdev/webjs/blob/main/agent-docs/lit-muscle-memory-gotchas.md).
629
629
 
630
+ ### Styling: Tailwind-first (the most common lit reflex to unlearn)
631
+
632
+ **Tailwind utilities are the strong default for pages AND light-DOM
633
+ components (the default DOM mode).** Use them for layout, spacing, color
634
+ (via the `@theme` tokens), typography, borders, radius, shadows, and
635
+ interaction states (hover/focus/active/disabled, dark mode). Light DOM
636
+ does not scope styles, so utilities apply directly.
637
+
638
+ The lit habit is to scope CSS in a shadow root (`static styles =
639
+ css\`\``) or write an inline `<style>` with semantic class names
640
+ (`.hero`, `.card`). In a light-DOM webjs component the scoped block does
641
+ nothing without `static shadow = true`, and the inline class names leak
642
+ globally. Prefer Tailwind. When a utility bundle repeats, extract it into
643
+ a `lib/utils/ui.ts` helper returning an `` html`...` `` fragment, not a
644
+ CSS class.
645
+
646
+ Reserve raw CSS for what utilities cannot express: design-token `:root` /
647
+ `@theme` definitions, `@property` + `@keyframes` animations,
648
+ `::-webkit-scrollbar`, `prefers-reduced-motion` blocks, and complex
649
+ `color-mix()` / gradient effects. When custom CSS is unavoidable in a
650
+ light-DOM component, prefix every class selector with the component tag
651
+ (invariant below). Shadow-DOM components (`static shadow = true`)
652
+ legitimately use `static styles = css\`\`` for scoped CSS.
653
+
630
654
  ## Server action pattern
631
655
 
632
656
  ```ts
@@ -687,7 +687,7 @@ Both hydrate without flash on the client.
687
687
 
688
688
  ---
689
689
 
690
- ## Styling: Tailwind + JS helpers
690
+ ## Styling: Tailwind-first + JS helpers
691
691
 
692
692
  <!-- OVERRIDE -->
693
693
 
@@ -697,6 +697,31 @@ fluid type scale value, and motion duration is declared once in `@theme`
697
697
  and available everywhere via utility classes (`text-fg`, `bg-bg-elev`,
698
698
  `font-serif`, `duration-fast`, `text-display`).
699
699
 
700
+ **Tailwind-first is the strong default for pages AND light-DOM
701
+ components (the default DOM mode).** Use utilities for layout, spacing,
702
+ color (via the `@theme` tokens), typography, borders, radius, shadows,
703
+ and interaction states (hover/focus/active/disabled, dark mode). Light
704
+ DOM does not scope styles, so utilities apply directly.
705
+
706
+ **The lit muscle-memory trap.** If you have written lit, the habit is to
707
+ scope CSS in a shadow root (`static styles = css\`\``) or write an inline
708
+ `<style>` with semantic class names (`.hero`, `.feature`, `.card`) for
709
+ every component. In a webjs light-DOM component the scoped block does
710
+ nothing without `static shadow = true`, and the inline class names leak
711
+ into the global namespace. Prefer Tailwind utilities. When the same
712
+ bundle repeats, extract a `lib/utils/ui.ts` helper (below), not a CSS
713
+ class.
714
+
715
+ **Custom-CSS allowlist (the only things raw CSS is for).** Reserve raw
716
+ CSS for what utilities cannot express: design-token `:root` / `@theme`
717
+ definitions, `@property` animated custom properties with `@keyframes`,
718
+ `::-webkit-scrollbar` / `scrollbar-color`, `prefers-reduced-motion`
719
+ blocks, and complex `color-mix()` or gradient effects. When custom CSS is
720
+ unavoidable in a light-DOM component, the class-prefix rule (see the
721
+ Components section above) still applies. Shadow-DOM components
722
+ (`static shadow = true`) legitimately author `static styles = css\`\``;
723
+ that is the right home for scoped CSS.
724
+
700
725
  **Dedup repeated Tailwind class bundles with JS helpers, not `@apply`.**
701
726
  When the same string of classes appears in 2+ places, extract it into a
702
727
  small function in `lib/utils/ui.ts`: