@webjsdev/cli 0.10.67 → 0.10.69

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.
@@ -1,135 +1,571 @@
1
- ## Build a full-stack app (default template)
2
-
3
- This scaffold ships a browsable feature gallery to learn from: single-concept
4
- demos under `app/features/`, the `app/examples/todo` app, and an example design
5
- system under `components/ui/`, with logic in `modules/`. Build in this order.
6
-
7
- ### 1. Study the gallery, then clear it
8
-
9
- Read the demos under `app/features/` (and `app/examples/todo`) that match what
10
- you are building, so you copy the real idiom: server actions, queries,
11
- optimistic UI, component hydration, design tokens. Then run
12
- `npm run gallery:clear` to shed the whole gallery and reset `app/page.ts` and
13
- `app/layout.ts` to a blank slate. The clear also removes the example
14
- `components/ui/` primitives, the demo `todos` table, and the demo migrations;
15
- it keeps the agent skill, the database wiring, and `lib/utils/cn.ts` (needed by
16
- `npx webjsdev ui add`). The skill teaches the same patterns, so the gallery is
17
- a runnable copy you study first, not something you lose.
18
-
19
- ### 2. Model the data
20
-
21
- Define real models in `db/schema.server.ts`, then run `npm run db:generate` and
22
- `npm run db:migrate` (required after the clear, which removed the demo table and
23
- migrations). Write a seed script at `db/seed.server.ts` and run
24
- `npm run db:seed` so list and detail pages render real rows while you build,
25
- instead of empty states. Put reads in `modules/<feature>/queries/*.server.ts`
26
- and writes in `modules/<feature>/actions/*.server.ts`, one function per file.
27
-
28
- ### 3. Build a token-based design system
29
-
30
- Full reference: `.agents/skills/webjs/references/styling.md`.
31
-
32
- - Define your color tokens as CSS custom properties in `app/layout.ts`, each
33
- written ONCE with the native CSS `light-dark(LIGHT, DARK)` function, so light
34
- and dark modes come from one declaration.
35
- - Define at least: `--background`, `--foreground`, `--card`, `--primary`,
36
- `--secondary`, `--muted`, `--muted-foreground`, `--accent`, `--border`,
37
- `--ring`, `--destructive`. Add the matching `*-foreground` pair for each
38
- surface token you use, following the styling guide's reference palette.
39
- - Consume colors ONLY as token utilities: `bg-background`, `text-foreground`,
40
- `bg-card`, `border-border`, `text-primary`, `text-muted-foreground`,
41
- `bg-destructive`.
42
- - NEVER put a raw un-themed Tailwind color (`red-500`, `blue-600`, `gray-100`)
43
- on an element or a `@webjsdev/ui` helper.
44
- - Add an inline theme-detection script in the layout `<head>` so the first
45
- paint matches the saved theme with no flash.
46
-
47
- ### 4. Use the UI kit, do not hand-roll primitives
48
-
49
- Pull primitives with `npx webjsdev ui add <name>`; the source is copied into
50
- `components/ui/`, so you own it fully and can add, remove, restructure, or theme
51
- it however your app needs. Do NOT guess a helper or tag signature. Inspect the
52
- copied file `components/ui/<name>.ts`, or run
53
- `npx webjsdev ui view <name>`, for the exact exported names, variants, and
54
- sizes. The kit has two tiers:
55
-
56
- - **Tier 1, class helpers** for static primitives (button, card, input, badge,
57
- native-select, textarea). Spread the helper onto a native element, for example
58
- `class=${buttonClass({ variant: 'outline', size: 'sm' })}`.
59
- - **Tier 2, custom elements** for stateful controls and overlays (`<ui-tabs>`,
60
- `<ui-dialog>`, `<ui-dropdown-menu>`, `<ui-tooltip>`, sonner toasts). Use the
61
- registered tag; it owns its ARIA, focus trap, and keyboard navigation out of
62
- the box. Never hand-author a tab strip or a modal when a Tier-2 element
63
- covers it.
64
-
65
- Full reference: `.agents/skills/webjs/references/ui-kit.md`.
66
-
67
- ### 5. Build a multi-page app (MPA), not a single page
68
-
69
- Structure the product as real routes, not one page that swaps client state:
70
-
71
- - `/` a home or overview page.
72
- - `/<resource>` a list page with search, filters, sorting, and a create form or
73
- modal.
74
- - `/<resource>/[id]` a detail page for one item.
75
- - a couple of additional feature pages as the product needs.
76
-
77
- Give `app/layout.ts` a navbar that links the main pages, pinned with
78
- `position: fixed` (never `position: sticky`, which flickers on iOS during a
79
- client-router navigation), and reserve its height on the content with a
80
- `--header-height` offset. In a list or table, clicking a row or card navigates
81
- to that item's detail page. Wrap each row action button (edit, delete, status)
82
- so its handler calls `event.stopPropagation()`, letting the button run its own
83
- action without also triggering the row navigation.
84
-
85
- ### 6. Build components for interactivity
86
-
87
- Pages and layouts (`app/**/page.ts`, `app/**/layout.ts`) are server-only HTML
88
- generators, so put every interactive behavior inside a `WebComponent` custom
89
- element. Declare a component's reactive properties in the base-class factory,
90
- never as a class-field initializer (`items = []` clobbers the reactive
91
- accessor). Use the shorthand for primitives
92
- (`extends WebComponent({ name: String, count: Number, open: Boolean })`) and the
93
- `prop<T>()` helper for typed objects and arrays
94
- (`extends WebComponent({ items: prop<Item[]>(Array), user: prop<User>(Object) })`).
95
-
96
- ### 7. Verify before you call it done
97
-
98
- Run `npm run ci` and fix what it reports. It is one command for every gate,
99
- the step list declared in `package.json` under `webjs.ci`, with a result line
100
- per step:
101
-
102
- - `webjs check` (correctness: no browser-import or boundary violation).
103
- - `webjs doctor` (project health). It fails on whatever `package.json`
104
- `webjs.doctor.gate` marks `error`, plus the two hard toolchain checks that
105
- are fatal with no gate entry, `NODE_VERSION` and `TSCONFIG_ERASABLE`.
106
- - `webjs typecheck` (zero type errors).
107
- - A dependency audit.
108
- - The server, browser, and e2e test layers for the features you built.
109
-
110
- The GitHub workflow runs the same list, so a green local run predicts CI.
111
- While iterating, `npm run ci -- --only Tests` runs one layer. Then
112
- `npm run css:build` (compile Tailwind).
113
-
114
- Then boot `npm run dev`, confirm every page route returns HTTP 200, and open
115
- every route you changed in a real browser and play through its states: `check`
116
- and `typecheck` pass even when a layout collapses, so the browser is the real
117
- check for UI work.
1
+ ## Build an app (full-stack template)
2
+
3
+ ### Build steps
4
+
5
+ Everything a typical app needs (pages, forms, validation, auth, owner-scoped
6
+ CRUD, a component, Drizzle) is in this file, so build straight from it instead
7
+ of exploring. Write in a few large steps (one shell heredoc or one write per
8
+ group of files), not one file per turn.
9
+
10
+ 1. Branch, clear the demo gallery, and add the UI kit, in one command:
11
+ `git checkout -b feat/<name> && npm run gallery:clear && npx webjsdev ui add button input label textarea native-select card badge`.
12
+ The gallery (`app/features/`, `app/examples/`, the demo `modules/`) is only a
13
+ demo: never read it, everything it teaches is below.
14
+ 2. Write `db/schema.server.ts` (replace the whole file: its `users` table is a
15
+ placeholder), then `npm run db:generate && npm run db:migrate`.
16
+ 3. Write every `modules/` file (auth, queries, actions, components, utils).
17
+ 4. Write `app/layout.ts` and `app/page.ts` (replace both whole; no need to
18
+ read them first), every other page, `app/not-found.ts`, and
19
+ `test/<feature>/*.test.ts`.
20
+ 5. `npm run check && npm run typecheck && npm run test:server`, and fix what
21
+ they report.
22
+ 6. Walk the app once in a real browser. `curl` cannot submit a bound form
23
+ (the form carries a hidden action field), so use Playwright, which is
24
+ installed (if Chromium is missing: `npx playwright install chromium`).
25
+ First write one script, `walk.mjs` in the app folder, that signs up and
26
+ drives each feature once with `page.getByLabel(...)` and
27
+ `page.getByRole('button', { name })`. With JavaScript on, a submit is
28
+ applied in place, so wait for its outcome (`await page.waitForURL(...)` or
29
+ `await page.getByText('...').waitFor()`), never a fixed timeout. Save a
30
+ phone-width and a desktop-width screenshot under `/tmp`. Then start
31
+ `PORT=<port> npm run dev > dev.log 2>&1 &` (`*.log` is gitignored), run
32
+ `node walk.mjs`, look at the screenshots, fix what the walk shows in the
33
+ app, stop the server you started, and delete `walk.mjs`.
34
+ 7. Commit (see Git below).
35
+
36
+ ### How WebJs works
37
+
38
+ - **Pages and layouts run only on the server.** They return `html` and never
39
+ hydrate: an `@click` in a page does nothing. Interactivity lives in a
40
+ `WebComponent` custom element; a page imports the component file to
41
+ register it and writes its tag.
42
+ - **`*.server.ts` is the server boundary.** With `'use server';` as the first
43
+ line, its exported async functions are server actions: a page calls them
44
+ directly on the server, a component calls them over RPC (the import becomes
45
+ a typed stub). WITHOUT `'use server'` the file is a server-only utility (the
46
+ DB, secrets, `node:*`, `createAuth`): import it only from other `.server.ts`
47
+ files, `route.ts` or `middleware.ts`, never from a page, layout or component
48
+ (it crashes the browser). So a page reaches data and the session only
49
+ through `'use server'` queries.
50
+ - **Forms post to actions.** `<form action=${someAction}>` is the whole wiring
51
+ (no `method`, no `fetch`, works without JavaScript; with JavaScript the router
52
+ applies the result in place). The action receives the `FormData` and returns:
53
+ `{ success: true, redirect: '/path' }` (a 303 to that path), or
54
+ `{ success: false, error?, fieldErrors?, status? }`, which re-renders the
55
+ same page (422) with the result on the page's `actionData`;
56
+ `actionData.values` already holds every submitted text field. A returned
57
+ `Response` (for example from `signIn`) is sent as is.
58
+ - **Control flow:** `notFound()` and `redirect(url)` from `@webjsdev/core`
59
+ throw; use them in pages, layouts and form actions. In a `route.ts` return a
60
+ `Response` instead, and in an action called over RPC return
61
+ `{ success: false, error }` instead of throwing.
62
+
63
+ ### File map
64
+
65
+ ```
66
+ app/layout.ts root layout: the only file that writes <head> content
67
+ app/page.ts /
68
+ app/<seg>/[id]/page.ts dynamic route; params.id is a string
69
+ app/<seg>/[id]/edit/page.ts nested route
70
+ app/not-found.ts the 404 page, also rendered by notFound()
71
+ app/<path>/route.ts HTTP endpoint: export async function GET(req, { params })
72
+ modules/<feature>/queries/<verb-noun>.server.ts reads, 'use server', one function per file
73
+ modules/<feature>/actions/<verb-noun>.server.ts writes, 'use server', one function per file
74
+ modules/<feature>/components/<tag>.ts one custom element per file
75
+ modules/<feature>/utils/*.ts, types.ts pure browser-safe helpers and types
76
+ lib/utils/*.ts app-wide browser-safe helpers
77
+ db/schema.server.ts tables; `db` is in db/connection.server.ts
78
+ test/<feature>/*.test.ts server tests (node:test), run by `npm run test:server`
79
+ ```
80
+
81
+ Import app files through the `#` root alias with the `.ts` extension:
82
+ `import { db } from '#db/connection.server.ts'`.
83
+
84
+ ### Worked example: a signed-in CRUD feature
85
+
86
+ Each block is a whole file. Copy the shape and rename (`posts` becomes your
87
+ resource). Child resources (a project's tasks) follow the same pattern: the
88
+ child table references the parent with `onDelete: 'cascade'`, and every query
89
+ and action checks that the parent belongs to the signed-in user.
90
+
91
+ ```ts
92
+ // db/schema.server.ts (columns.server.ts provides table, pk, text, integer, createdAt, index)
93
+ import { defineRelations } from 'drizzle-orm';
94
+ import { table, pk, text, integer, createdAt, index } from './columns.server.ts';
95
+ import { POST_STATUSES } from '#modules/posts/types.ts';
96
+
97
+ export const users = table('users', {
98
+ id: pk(),
99
+ email: text().notNull().unique(),
100
+ passwordHash: text().notNull(),
101
+ createdAt: createdAt(),
102
+ });
103
+ export const posts = table('posts', {
104
+ id: pk(),
105
+ ownerId: integer().notNull().references(() => users.id, { onDelete: 'cascade' }),
106
+ title: text().notNull(),
107
+ body: text().notNull().default(''),
108
+ status: text({ enum: POST_STATUSES }).notNull().default('draft'),
109
+ publishOn: text(), // 'YYYY-MM-DD' from <input type="date">, or null
110
+ createdAt: createdAt(),
111
+ }, (t) => [index(t.ownerId)]);
112
+ export const relations = defineRelations({ users, posts }, () => ({}));
113
+ export type User = typeof users.$inferSelect;
114
+ export type Post = typeof posts.$inferSelect;
115
+ ```
116
+
117
+ ```ts
118
+ // modules/posts/types.ts (browser-safe: components import this, never the schema)
119
+ export const POST_STATUSES = ['draft', 'review', 'published'] as const;
120
+ export type PostStatus = (typeof POST_STATUSES)[number];
121
+ export interface StatusCounts { draft: number; review: number; published: number; total: number }
122
+ ```
123
+
124
+ ```ts
125
+ // lib/utils/form.ts
126
+ import { html } from '@webjsdev/core';
127
+ import { labelClass } from '#components/ui/label.ts';
128
+ import { inputClass } from '#components/ui/input.ts';
129
+
130
+ /** What a failed form action hands back to the page as `actionData`. */
131
+ export interface FormState { error?: string; fieldErrors?: Record<string, string>; values?: Record<string, string> }
132
+ export const isEmail = (s: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s);
133
+ export const str = (fd: FormData, k: string) => String(fd.get(k) ?? '').trim();
134
+ export const toId = (v: unknown) => { const n = Number(v); return Number.isInteger(n) && n > 0 ? n : null; };
135
+
136
+ /** A labelled input with its server error under it and the typed value kept. */
137
+ export function field(o: { label: string; name: string; type?: string; value?: string; error?: string; required?: boolean }) {
138
+ return html`
139
+ <div class="grid gap-1.5">
140
+ <label for=${o.name} class=${labelClass()}>${o.label}</label>
141
+ <input id=${o.name} name=${o.name} type=${o.type ?? 'text'} value=${o.value ?? ''} ?required=${o.required}
142
+ aria-invalid=${o.error ? 'true' : 'false'} class=${inputClass()}>
143
+ ${o.error ? html`<p class="text-sm text-destructive">${o.error}</p>` : ''}
144
+ </div>`;
145
+ }
146
+ ```
147
+
148
+ Auth uses the built-in `createAuth` (a signed session cookie) and `node:crypto`
149
+ scrypt. No extra package is needed.
150
+
151
+ ```ts
152
+ // modules/auth/password.server.ts
153
+ import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
154
+ import { promisify } from 'node:util';
155
+ const scryptAsync = promisify(scrypt);
156
+ export async function hashPassword(pw: string) {
157
+ const salt = randomBytes(16).toString('hex');
158
+ return salt + ':' + ((await scryptAsync(pw, salt, 64)) as Buffer).toString('hex');
159
+ }
160
+ export async function verifyPassword(pw: string, stored: string) {
161
+ const [salt, key] = stored.split(':');
162
+ return timingSafeEqual((await scryptAsync(pw, salt, 64)) as Buffer, Buffer.from(key, 'hex'));
163
+ }
164
+ ```
165
+
166
+ ```ts
167
+ // modules/auth/auth.server.ts (server-only: no 'use server')
168
+ import { createAuth, Credentials } from '@webjsdev/server';
169
+ import { db } from '#db/connection.server.ts';
170
+ import { verifyPassword } from './password.server.ts';
171
+
172
+ const secret = process.env.AUTH_SECRET;
173
+ if (!secret) throw new Error('AUTH_SECRET is not set');
174
+ export const { auth, signIn, signOut } = createAuth({
175
+ secret,
176
+ pages: { signIn: '/signin', error: '/signin' },
177
+ providers: [Credentials({
178
+ async authorize(c: { email: string; password: string }) {
179
+ const user = await db.query.users.findFirst({ where: { email: c.email } });
180
+ if (!user || !(await verifyPassword(c.password, user.passwordHash))) return null;
181
+ return { id: String(user.id), email: user.email };
182
+ },
183
+ })],
184
+ });
185
+ export interface SessionUser { id: number; email: string }
186
+ export async function getUser(): Promise<SessionUser | null> {
187
+ const u = (await auth())?.user;
188
+ return u?.id ? { id: Number(u.id), email: String(u.email) } : null;
189
+ }
190
+ ```
191
+
192
+ ```ts
193
+ // modules/auth/queries/current-user.server.ts (for the layout and public pages)
194
+ 'use server';
195
+ import { getUser, type SessionUser } from '../auth.server.ts';
196
+ export async function currentUser(): Promise<SessionUser | null> {
197
+ return getUser();
198
+ }
199
+
200
+ // modules/auth/queries/require-user.server.ts (call first in every signed-in page)
201
+ 'use server';
202
+ import { redirect } from '@webjsdev/core';
203
+ import { getUser, type SessionUser } from '../auth.server.ts';
204
+ export async function requireUser(): Promise<SessionUser> {
205
+ return (await getUser()) ?? redirect('/signin');
206
+ }
207
+ ```
208
+
209
+ ```ts
210
+ // modules/auth/actions/sign-up.server.ts
211
+ 'use server';
212
+ import { db } from '#db/connection.server.ts';
213
+ import { users } from '#db/schema.server.ts';
214
+ import { isEmail, str } from '#lib/utils/form.ts';
215
+ import { hashPassword } from '../password.server.ts';
216
+ import { signIn } from '../auth.server.ts';
217
+
218
+ export async function signUp(fd: FormData) {
219
+ const email = str(fd, 'email').toLowerCase();
220
+ const password = String(fd.get('password') ?? '');
221
+ const fieldErrors: Record<string, string> = {};
222
+ if (!isEmail(email)) fieldErrors.email = 'Enter a valid email address.';
223
+ if (password.length < 8) fieldErrors.password = 'Password must be at least 8 characters.';
224
+ if (!fieldErrors.email && (await db.query.users.findFirst({ where: { email } }))) {
225
+ fieldErrors.email = 'An account with this email already exists.';
226
+ }
227
+ if (Object.keys(fieldErrors).length) return { success: false, fieldErrors };
228
+ await db.insert(users).values({ email, passwordHash: await hashPassword(password) });
229
+ return signIn('credentials', { email, password }, { redirectTo: '/posts' }); // sets the cookie, 302
230
+ }
231
+ ```
232
+
233
+ Sign-in is the same shape: look the user up, `verifyPassword`, return
234
+ `{ success: false, error: 'Invalid email or password.' }` on a mismatch, else
235
+ `return signIn('credentials', { email, password }, { redirectTo: '/posts' })`.
236
+ Sign-out is an action bound to a form in the layout:
237
+
238
+ ```ts
239
+ // modules/auth/actions/sign-out.server.ts
240
+ 'use server';
241
+ import { signOut } from '../auth.server.ts';
242
+ export async function signOutUser(_fd: FormData) {
243
+ return signOut({ redirectTo: '/signin' }); // clears the cookie, 302
244
+ }
245
+ ```
246
+
247
+ ```ts
248
+ // modules/posts/utils/validate-post.ts (pure: shared by create and update, unit-tested)
249
+ import { str } from '#lib/utils/form.ts';
250
+ import { POST_STATUSES, type PostStatus } from '../types.ts';
251
+ export interface PostInput { title: string; body: string; status: PostStatus; publishOn: string | null }
252
+ export function validatePost(fd: FormData) {
253
+ const values = { title: str(fd, 'title'), body: str(fd, 'body'), status: str(fd, 'status') || 'draft', publishOn: str(fd, 'publishOn') };
254
+ const fieldErrors: Record<string, string> = {};
255
+ if (!values.title) fieldErrors.title = 'Title is required.';
256
+ if (!(POST_STATUSES as readonly string[]).includes(values.status)) fieldErrors.status = 'Pick a status.';
257
+ if (values.publishOn && !/^\d{4}-\d{2}-\d{2}$/.test(values.publishOn)) fieldErrors.publishOn = 'Use a valid date.';
258
+ if (Object.keys(fieldErrors).length) return { ok: false as const, fieldErrors, values };
259
+ const data: PostInput = { ...values, status: values.status as PostStatus, publishOn: values.publishOn || null };
260
+ return { ok: true as const, data };
261
+ }
262
+ ```
263
+
264
+ Reads use the relational API (`db.query.<table>.findMany/findFirst` with an
265
+ object `where` and `orderBy`) and always filter by the owner:
266
+
267
+ ```ts
268
+ // modules/posts/queries/get-post.server.ts (list-posts.server.ts is the same with findMany + orderBy: { createdAt: 'desc' })
269
+ 'use server';
270
+ import { db } from '#db/connection.server.ts';
271
+ import type { Post } from '#db/schema.server.ts';
272
+ import { getUser } from '#modules/auth/auth.server.ts';
273
+ import { toId } from '#lib/utils/form.ts';
274
+
275
+ /** The post when it exists AND belongs to the signed-in user, else null (the page throws notFound()). */
276
+ export async function getPost(id: string): Promise<Post | null> {
277
+ const user = await getUser();
278
+ const postId = toId(id);
279
+ if (!user || !postId) return null;
280
+ return (await db.query.posts.findFirst({ where: { id: postId, ownerId: user.id } })) ?? null;
281
+ }
282
+ ```
283
+
284
+ ```ts
285
+ // modules/posts/queries/count-posts.server.ts (one grouped query, never one per row)
286
+ 'use server';
287
+ import { count, eq } from 'drizzle-orm';
288
+ import { db } from '#db/connection.server.ts';
289
+ import { posts } from '#db/schema.server.ts';
290
+ import { getUser } from '#modules/auth/auth.server.ts';
291
+ import type { StatusCounts } from '../types.ts';
292
+
293
+ export async function countPosts(): Promise<StatusCounts> {
294
+ const c: StatusCounts = { draft: 0, review: 0, published: 0, total: 0 };
295
+ const user = await getUser();
296
+ if (!user) return c;
297
+ const rows = await db.select({ status: posts.status, n: count() }).from(posts)
298
+ .where(eq(posts.ownerId, user.id)).groupBy(posts.status);
299
+ for (const r of rows) { c[r.status] = r.n; c.total += r.n; }
300
+ return c;
301
+ }
302
+ ```
303
+
304
+ Writes use the query builder with `eq` / `and`, and put the owner in the
305
+ `where` so another user's id changes nothing. `create-post.server.ts` is
306
+ `validatePost`, then `db.insert(posts).values({ ...v.data, ownerId: user.id }).returning()`,
307
+ then `{ success: true, redirect: '/posts/' + post.id }`. `delete-post.server.ts`
308
+ reads the id from a hidden input and returns `{ success: true, redirect: '/posts' }`.
309
+
310
+ ```ts
311
+ // modules/posts/actions/update-post.server.ts
312
+ 'use server';
313
+ import { and, eq } from 'drizzle-orm';
314
+ import { db } from '#db/connection.server.ts';
315
+ import { posts } from '#db/schema.server.ts';
316
+ import { getUser } from '#modules/auth/auth.server.ts';
317
+ import { toId } from '#lib/utils/form.ts';
318
+ import { validatePost } from '../utils/validate-post.ts';
319
+
320
+ export async function updatePost(fd: FormData) {
321
+ const user = await getUser();
322
+ const id = toId(fd.get('id'));
323
+ if (!user || !id) return { success: false, error: 'Not found.', status: 404 };
324
+ const v = validatePost(fd);
325
+ if (!v.ok) return { success: false, fieldErrors: v.fieldErrors, values: v.values };
326
+ const rows = await db.update(posts).set(v.data).where(and(eq(posts.id, id), eq(posts.ownerId, user.id))).returning();
327
+ if (!rows.length) return { success: false, error: 'Not found.', status: 404 };
328
+ return { success: true, redirect: `/posts/${id}` };
329
+ }
330
+ ```
331
+
332
+ An action a component calls over RPC takes a typed object, checks it, and
333
+ returns a result (it never throws or redirects):
334
+
335
+ ```ts
336
+ // modules/posts/actions/set-post-status.server.ts
337
+ 'use server';
338
+ import { and, eq } from 'drizzle-orm';
339
+ import { db } from '#db/connection.server.ts';
340
+ import { posts } from '#db/schema.server.ts';
341
+ import { getUser } from '#modules/auth/auth.server.ts';
342
+ import { POST_STATUSES, type PostStatus } from '../types.ts';
343
+
344
+ export interface SetStatusInput { id: number; status: PostStatus }
345
+ export async function setPostStatus(input: SetStatusInput) {
346
+ const user = await getUser();
347
+ if (!user) return { success: false, error: 'Sign in first.', status: 401 };
348
+ if (!POST_STATUSES.includes(input.status)) return { success: false, error: 'Bad status.', status: 400 };
349
+ const rows = await db.update(posts).set({ status: input.status })
350
+ .where(and(eq(posts.id, Number(input.id)), eq(posts.ownerId, user.id))).returning();
351
+ return rows.length ? { success: true } : { success: false, error: 'Not found.', status: 404 };
352
+ }
353
+ ```
354
+
355
+ A component declares reactive properties in the `WebComponent({...})` factory
356
+ (attributes arrive kebab-cased: `postId` is `post-id`), keeps local state in
357
+ signals, and binds events with an unquoted `@event=${fn}`:
358
+
359
+ ```ts
360
+ // modules/posts/components/post-status.ts
361
+ import { WebComponent, html, signal } from '@webjsdev/core';
362
+ import { setPostStatus } from '../actions/set-post-status.server.ts';
363
+ import { POST_STATUSES, type PostStatus } from '../types.ts';
364
+ import { labelClass } from '#components/ui/label.ts';
365
+ import { nativeSelectClass } from '#components/ui/native-select.ts';
366
+
367
+ /** Status select that saves on change over RPC, with no page reload. */
368
+ export class PostStatusSelect extends WebComponent({ postId: Number, status: String }) {
369
+ note = signal('');
370
+ async onChange(e: Event) {
371
+ const select = e.target as HTMLSelectElement;
372
+ const before = this.status;
373
+ this.status = select.value;
374
+ const res = await setPostStatus({ id: this.postId, status: select.value as PostStatus });
375
+ if (res.success) this.note.set('Saved');
376
+ else { this.status = before; select.value = before; this.note.set(res.error ?? 'Could not save'); }
377
+ }
378
+ render() {
379
+ const id = `status-${this.postId}`;
380
+ return html`
381
+ <div class="flex items-center gap-2">
382
+ <label for=${id} class=${labelClass()}>Status</label>
383
+ <select id=${id} class=${nativeSelectClass()} @change=${(e: Event) => this.onChange(e)}>
384
+ ${POST_STATUSES.map((s) => html`<option value=${s} ?selected=${s === this.status}>${s}</option>`)}
385
+ </select>
386
+ <span class="text-xs text-muted-foreground" aria-live="polite">${this.note.get()}</span>
387
+ </div>`;
388
+ }
389
+ }
390
+ PostStatusSelect.register('post-status');
391
+ ```
392
+
393
+ ```ts
394
+ // app/layout.ts
395
+ import { html, asset } from '@webjsdev/core';
396
+ import type { LayoutProps } from '@webjsdev/core';
397
+ import { buttonClass } from '#components/ui/button.ts';
398
+ import { currentUser } from '#modules/auth/queries/current-user.server.ts';
399
+ import { signOutUser } from '#modules/auth/actions/sign-out.server.ts';
400
+
401
+ export const metadata = { title: { default: 'Posts', template: '%s | Posts' } };
402
+ export default async function RootLayout({ children }: LayoutProps) {
403
+ const user = await currentUser();
404
+ return html`
405
+ <meta name="viewport" content="width=device-width, initial-scale=1">
406
+ <link rel="stylesheet" href=${asset('/public/tailwind.css')}>
407
+ <script>if (matchMedia('(prefers-color-scheme: dark)').matches) document.documentElement.classList.add('dark');</script>
408
+ <style>
409
+ :root {
410
+ color-scheme: light dark;
411
+ --background: light-dark(#ffffff, #14161a); --foreground: light-dark(#17191c, #e6e8eb);
412
+ --card: light-dark(#f7f8fa, #1c1f24); --card-foreground: var(--foreground);
413
+ --primary: light-dark(#2f5bd3, #8fb0ff); --primary-foreground: light-dark(#ffffff, #0b1530);
414
+ --secondary: light-dark(#eef0f3, #2a2e34); --secondary-foreground: var(--foreground);
415
+ --muted: light-dark(#f1f3f5, #23272d); --muted-foreground: light-dark(#5b626b, #9aa1aa);
416
+ --accent: light-dark(#e9edf5, #2a3140); --accent-foreground: var(--foreground);
417
+ --border: light-dark(#e2e5e9, #343a42); --input: var(--border); --ring: light-dark(#8aa4e8, #5b78c4);
418
+ --destructive: light-dark(#c0362c, #f28b82);
419
+ }
420
+ body { margin: 0; background: var(--background); color: var(--foreground); font: 15px/1.6 system-ui, sans-serif; }
421
+ </style>
422
+ <header class="fixed inset-x-0 top-0 z-40 h-14 border-b border-border bg-background/95 backdrop-blur">
423
+ <nav class="mx-auto flex h-full max-w-4xl items-center gap-4 px-4">
424
+ <a href="/" class="font-semibold text-foreground no-underline">Posts</a>
425
+ ${user ? html`
426
+ <span class="ml-auto hidden text-sm text-muted-foreground sm:inline">${user.email}</span>
427
+ <form action=${signOutUser} class="ml-auto sm:ml-0"><button class=${buttonClass({ variant: 'outline', size: 'sm' })}>Sign out</button></form>`
428
+ : html`<a href="/signin" class="ml-auto text-sm">Sign in</a>`}
429
+ </nav>
430
+ </header>
431
+ <main class="mx-auto min-h-dvh max-w-4xl px-4 pb-16 pt-20 text-foreground">${children}</main>`;
432
+ }
433
+ ```
434
+
435
+ ```ts
436
+ // app/page.ts
437
+ import { redirect } from '@webjsdev/core';
438
+ import { currentUser } from '#modules/auth/queries/current-user.server.ts';
439
+ export default async function Home() {
440
+ redirect((await currentUser()) ? '/posts' : '/signin');
441
+ }
442
+ ```
443
+
444
+ A page with a form reads `actionData` (typed with `FormState`). The sign-in
445
+ and sign-up pages are this shape too, with `if (await currentUser()) redirect('/posts');`
446
+ first and `actionData.error` shown above the fields.
447
+
448
+ ```ts
449
+ // app/posts/page.ts
450
+ import { html } from '@webjsdev/core';
451
+ import type { PageProps } from '@webjsdev/core';
452
+ import { buttonClass } from '#components/ui/button.ts';
453
+ import { cardClass } from '#components/ui/card.ts';
454
+ import { field, type FormState } from '#lib/utils/form.ts';
455
+ import { requireUser } from '#modules/auth/queries/require-user.server.ts';
456
+ import { listPosts } from '#modules/posts/queries/list-posts.server.ts';
457
+ import { countPosts } from '#modules/posts/queries/count-posts.server.ts';
458
+ import { createPost } from '#modules/posts/actions/create-post.server.ts';
459
+
460
+ export const metadata = { title: 'Your posts' };
461
+ export default async function PostsPage({ actionData }: PageProps<'/posts'> & { actionData?: FormState }) {
462
+ await requireUser();
463
+ const [items, counts] = await Promise.all([listPosts(), countPosts()]);
464
+ const e = actionData?.fieldErrors ?? {};
465
+ const v = actionData?.values ?? {};
466
+ return html`
467
+ <h1 class="text-2xl font-semibold">Your posts</h1>
468
+ <p class="mt-1 text-sm text-muted-foreground">${counts.total} total, ${counts.published} published</p>
469
+ <form action=${createPost} class="${cardClass()} mt-6 grid gap-3 p-4 sm:grid-cols-[1fr_auto] sm:items-end">
470
+ ${field({ label: 'Title', name: 'title', value: v.title, error: e.title, required: true })}
471
+ <button class=${buttonClass()}>Create post</button>
472
+ </form>
473
+ <ul class="mt-6 grid gap-3 sm:grid-cols-2">
474
+ ${items.map((p) => html`
475
+ <li class="${cardClass()} p-4">
476
+ <a href="/posts/${p.id}" class="font-medium text-foreground">${p.title}</a>
477
+ <p class="mt-1 text-sm text-muted-foreground">${p.status}</p>
478
+ </li>`)}
479
+ </ul>
480
+ ${items.length ? '' : html`<p class="mt-6 text-muted-foreground">No posts yet.</p>`}`;
481
+ }
482
+ ```
483
+
484
+ ```ts
485
+ // app/posts/[id]/page.ts
486
+ import { html, notFound } from '@webjsdev/core';
487
+ import type { PageProps } from '@webjsdev/core';
488
+ import { buttonClass } from '#components/ui/button.ts';
489
+ import { requireUser } from '#modules/auth/queries/require-user.server.ts';
490
+ import { getPost } from '#modules/posts/queries/get-post.server.ts';
491
+ import { deletePost } from '#modules/posts/actions/delete-post.server.ts';
492
+ import '#modules/posts/components/post-status.ts'; // registers <post-status>
493
+
494
+ export default async function PostPage({ params }: PageProps<'/posts/[id]'>) {
495
+ await requireUser();
496
+ const post = await getPost(params.id);
497
+ if (!post) notFound();
498
+ return html`
499
+ <h1 class="text-2xl font-semibold">${post.title}</h1>
500
+ ${post.body ? html`<p class="mt-3 whitespace-pre-line">${post.body}</p>` : ''}
501
+ <div class="mt-6 flex flex-wrap items-center gap-3">
502
+ <post-status post-id=${post.id} status=${post.status}></post-status>
503
+ <a href="/posts/${post.id}/edit" class=${buttonClass({ variant: 'outline', size: 'sm' })}>Edit</a>
504
+ <form action=${deletePost} onsubmit="return confirm('Delete this post?')">
505
+ <input type="hidden" name="id" value=${post.id}>
506
+ <button class=${buttonClass({ variant: 'destructive', size: 'sm' })}>Delete</button>
507
+ </form>
508
+ </div>`;
509
+ }
510
+ ```
511
+
512
+ The edit page loads the row the same way, pre-fills from it
513
+ (`const v = actionData?.values ?? { title: post.title, ... }`), and posts a
514
+ hidden `id` to `updatePost`. A `<textarea class=${textareaClass()}>` holds its
515
+ value as text content; a `<select class=${nativeSelectClass()}>` marks the
516
+ current option with `?selected=${s === v.status}`. Both need a `<label for>`.
517
+
518
+ ```ts
519
+ // app/not-found.ts
520
+ import { html } from '@webjsdev/core';
521
+ export default function NotFound() {
522
+ return html`<h1 class="text-2xl font-semibold">Not found</h1><p class="mt-2 text-muted-foreground"><a href="/">Go home</a></p>`;
523
+ }
524
+ ```
525
+
526
+ ```ts
527
+ // test/posts/validate-post.test.ts
528
+ import { test } from 'node:test';
529
+ import assert from 'node:assert/strict';
530
+ import { validatePost } from '#modules/posts/utils/validate-post.ts';
531
+ const fd = (o: Record<string, string>) => { const f = new FormData(); for (const [k, v] of Object.entries(o)) f.set(k, v); return f; };
532
+ test('a post needs a title', () => {
533
+ const r = validatePost(fd({ title: ' ' }));
534
+ assert.equal(r.ok ? '' : r.fieldErrors.title, 'Title is required.');
535
+ });
536
+ ```
537
+
538
+ ### Look and the UI kit
539
+
540
+ - The palette is the token block in the layout's `<style>`, each colour
541
+ written once as `light-dark(LIGHT, DARK)`; `public/input.css` maps the tokens
542
+ into Tailwind. Pick values that fit the product, and style only with token
543
+ utilities:
544
+ `bg-background text-foreground bg-card text-card-foreground bg-primary
545
+ text-primary-foreground bg-muted text-muted-foreground border-border
546
+ text-destructive ring-ring`. Never a raw colour such as `bg-blue-600`.
547
+ - Pin the header with `position: fixed` (never `sticky`) and offset the
548
+ content by its height, as the layout above does. Mobile first: one column
549
+ that widens at `sm:` / `md:`.
550
+ - The kit copies class helpers into `components/ui/` (you own them; no need to
551
+ open them): `buttonClass({ variant?: 'default' | 'destructive' | 'outline' |
552
+ 'secondary' | 'ghost' | 'link', size?: 'default' | 'xs' | 'sm' | 'lg' |
553
+ 'icon' })`, `inputClass()`, `textareaClass()`, `labelClass()`,
554
+ `nativeSelectClass()`, `cardClass({ size?: 'default' | 'sm' })`,
555
+ `badgeClass({ variant?: 'default' | 'secondary' | 'destructive' | 'outline' })`.
556
+ Use them as `class=${buttonClass({ variant: 'outline' })}` on native
557
+ elements. Stateful widgets (dialog, tabs, dropdown menu, tooltip, toasts) are
558
+ custom elements: `npx webjsdev ui add dialog`, then `npx webjsdev ui view dialog`
559
+ for the tags.
118
560
 
119
561
  ### Commands
120
562
 
121
563
  ```sh
122
- npm install
123
- npm run gallery:clear # shed the demo gallery before building a real app
124
- npm run dev # dev server at http://localhost:8080
125
- npm run start # production server
126
- npm test # unit + browser tests
127
- npm run typecheck
128
- npm run css:build # compile Tailwind
129
- npm run ci # every gate, one command (the webjs.ci steps in package.json)
130
- npm run check # correctness checks
131
- npm run doctor # project health (severity per check: webjs.doctor.gate)
132
- npx webjsdev ui add <name> # copy a ui primitive into components/ui/
133
- npx webjsdev ui view <name> # inspect a primitive's exact signature
134
- npm run db:generate && npm run db:migrate
564
+ npm run dev # dev server; PORT=<port> to choose the port
565
+ npm run db:generate && npm run db:migrate # after every schema change
566
+ npm run check # framework rules (boundaries, forms, components)
567
+ npm run typecheck # TypeScript
568
+ npm run test:server # node:test files under test/
569
+ npm run ci # every gate, before you push
570
+ npx webjsdev ui add <name> # copy a UI kit primitive into components/ui/
135
571
  ```