@webjsdev/cli 0.10.69 → 0.10.71
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 +38 -0
- package/lib/app-icon.js +0 -10
- package/lib/create.js +20 -14
- package/lib/dev-reload.js +3 -1
- package/lib/doctor/codes.js +1 -0
- package/lib/doctor/probes/app-icon.js +5 -10
- package/lib/doctor/probes/dark-theme.js +91 -0
- package/lib/doctor/runner.js +2 -0
- package/lib/runtime-rewrite.js +18 -18
- package/package.json +3 -3
- package/templates/.agents/rules/workflow.md +9 -24
- package/templates/.agents/skills/webjs/SKILL.md +9 -17
- package/templates/.agents/skills/webjs/references/built-ins.md +3 -1
- package/templates/.agents/skills/webjs/references/runtime.md +5 -3
- package/templates/.agents/skills/webjs/references/styling.md +1 -1
- package/templates/.claude/hooks/nudge-uncommitted.sh +0 -7
- package/templates/AGENTS.md +45 -47
- package/templates/CLAUDE.md +12 -15
- package/templates/CONVENTIONS.md +15 -32
- package/templates/Dockerfile +7 -1
- package/templates/partials/agents-playbook-api.md +6 -14
- package/templates/partials/agents-playbook-fullstack.md +120 -566
- package/templates/scripts/clear-gallery.mjs +3 -3
|
@@ -1,571 +1,125 @@
|
|
|
1
|
-
## Build
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
`
|
|
41
|
-
|
|
42
|
-
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
app
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
`
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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.
|
|
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 never hydrate, so put every interactive behavior inside a
|
|
88
|
+
`WebComponent` custom element, and declare its reactive properties only in the
|
|
89
|
+
base-class factory (the skill's "Core WebJs Rules" 11). Use the shorthand for primitives
|
|
90
|
+
(`extends WebComponent({ name: String, count: Number, open: Boolean })`) and the
|
|
91
|
+
`prop<T>()` helper for typed objects and arrays
|
|
92
|
+
(`extends WebComponent({ items: prop<Item[]>(Array), user: prop<User>(Object) })`).
|
|
93
|
+
|
|
94
|
+
### 7. Verify before you call it done
|
|
95
|
+
|
|
96
|
+
Run `npm run ci` and fix what it reports. It runs every gate declared under
|
|
97
|
+
`webjs.ci` in `package.json` (`webjs check`, `webjs doctor`, `webjs typecheck`,
|
|
98
|
+
a dependency audit, then the server, browser, and e2e test layers for the
|
|
99
|
+
features you built), and the GitHub workflow runs the
|
|
100
|
+
same list; `.agents/rules/workflow.md` has what each gate checks. While
|
|
101
|
+
iterating, `npm run ci -- --only Tests` runs one layer. Then
|
|
102
|
+
`npm run css:build` (compile Tailwind).
|
|
103
|
+
|
|
104
|
+
Then boot `npm run dev`, confirm every page route returns HTTP 200, and open
|
|
105
|
+
every route you changed in a real browser and play through its states: `check`
|
|
106
|
+
and `typecheck` pass even when a layout collapses, so the browser is the real
|
|
107
|
+
check for UI work.
|
|
560
108
|
|
|
561
109
|
### Commands
|
|
562
110
|
|
|
563
111
|
```sh
|
|
564
|
-
npm
|
|
565
|
-
npm run
|
|
566
|
-
npm run
|
|
567
|
-
npm run
|
|
568
|
-
npm
|
|
569
|
-
npm run
|
|
570
|
-
|
|
112
|
+
npm install
|
|
113
|
+
npm run gallery:clear # shed the demo gallery before building a real app
|
|
114
|
+
npm run dev # dev server at http://localhost:8080
|
|
115
|
+
npm run start # production server
|
|
116
|
+
npm test # unit + browser tests
|
|
117
|
+
npm run typecheck
|
|
118
|
+
npm run css:build # compile Tailwind
|
|
119
|
+
npm run ci # every gate, one command (the webjs.ci steps in package.json)
|
|
120
|
+
npm run check # correctness checks
|
|
121
|
+
npm run doctor # project health (severity per check: webjs.doctor.gate)
|
|
122
|
+
npx webjsdev ui add <name> # copy a ui primitive into components/ui/
|
|
123
|
+
npx webjsdev ui view <name> # inspect a primitive's exact signature
|
|
124
|
+
npm run db:generate && npm run db:migrate
|
|
571
125
|
```
|