diff --git a/.agents/skills/webjs/SKILL.md b/.agents/skills/webjs/SKILL.md index 1b828043b..cecb99c3d 100644 --- a/.agents/skills/webjs/SKILL.md +++ b/.agents/skills/webjs/SKILL.md @@ -9,8 +9,6 @@ Use this skill for end-to-end WebJs app work. It helps you choose the right laye ## Full Documentation -In a scaffolded app, `AGENTS.md` carries the build steps and a worked example of every common pattern (pages, layouts, form-bound actions with validation, queries, owner-scoped CRUD, `createAuth`, a component with signals, a test). Build from it first, and come here for a surface it does not show. - This skill is the quick guide. When you need the full API reference for a surface, load the matching file in `references/` (listed below). For even deeper framework detail, WebJs ships buildless, so the source you run IS the source you read: look in `node_modules/@webjsdev/{core,server,cli}/` (each package ships its own `AGENTS.md`). The complete hosted docs live at https://webjs.dev/docs. ## What WebJs Is diff --git a/packages/cli/lib/create.js b/packages/cli/lib/create.js index f4d6f0320..7156bfe05 100644 --- a/packages/cli/lib/create.js +++ b/packages/cli/lib/create.js @@ -1723,8 +1723,8 @@ ThemeToggle.register('theme-toggle'); `); } console.log(`For AI agents, read this before editing: - • Read AGENTS.md first: it carries the build steps and a worked example of - every common pattern. .agents/skills/webjs/ is the deeper reference. + • Read AGENTS.md, then .agents/skills/webjs/SKILL.md. The skill is the guide + to building a WebJs app and routes to focused references on demand. • This scaffold is a minimal starting point, not a demo to prune. Grow the app in place: add routes under app/, components under components/, and features under modules//, and keep server-only code behind .server.ts. diff --git a/packages/cli/templates/.agents/rules/workflow.md b/packages/cli/templates/.agents/rules/workflow.md index d2614cd93..a587e56ba 100644 --- a/packages/cli/templates/.agents/rules/workflow.md +++ b/packages/cli/templates/.agents/rules/workflow.md @@ -2,21 +2,23 @@ You are working on a WebJs app (AI-first, no-build, web-components-first). This file is the WORKFLOW contract (git, tests, review). For HOW to build (routing, -components, actions, styling, the framework API), read `AGENTS.md`; the -deeper reference set is `.agents/skills/webjs/SKILL.md`. Full hosted docs are -at https://webjs.dev/docs. +components, actions, styling, the framework API), read +`.agents/skills/webjs/SKILL.md`, which routes to focused references on demand. +Read `AGENTS.md` first. Full hosted docs are at https://webjs.dev/docs. ## Grow the app in place (non-negotiable) -- **Clear the showcase, then build.** The scaffold is a starting point with a - browsable demo showcase plus a database wired up. A full-stack app ships a UI - feature gallery (`app/features/`, `app/examples/todo`); the api template ships - a backend-features showcase (`app/api/features/`), with logic in `modules/`. - Building a real app: run `npm run gallery:clear` to shed the showcase (it - keeps the agent docs and the database wiring, and resets to a clean base), - then regenerate the database and grow the app in place under `app/`, - `components/`, and `modules//`. `AGENTS.md` carries the - template-specific build steps and the order to follow. +- **Study the shipped examples, then clear them and build.** The scaffold is a + starting point with a browsable showcase to learn the real idioms from, plus a + database wired up. A full-stack app ships a UI feature gallery (`app/features/`, + `app/examples/todo`); the api template ships a backend-features showcase + (`app/api/features/`), with logic in `modules/`. Building a real app: study the + parts that match your task (the skill teaches the same and SURVIVES the clear), + run `npm run gallery:clear` to shed the showcase (it keeps the agent skill and + the database wiring, and resets to a clean base), then regenerate the database + and grow the app in place under `app/`, `components/`, and `modules//`. + `AGENTS.md` carries the full template-specific build playbook and the order to + follow. - **Use the wired-up database (Drizzle), never JSON files.** For any data the app stores, define a Drizzle table in `db/schema.server.ts`, then `npm run db:generate` and `npm run db:migrate`. Never use a JSON file, a @@ -24,8 +26,9 @@ at https://webjs.dev/docs. - **`app/` is routing-only.** Only routing files live in `app/` (page, layout, route, middleware, metadata routes). Browser-safe helpers go in `lib/utils/`, feature logic in `modules/`, server-only code behind `.server.ts`. -- **For a UI app, render and LOOK before calling it done.** Give the design - tokens in `public/input.css` a palette that fits the app, then open every +- **For a UI app, render and LOOK before calling it done.** Define design tokens + in `app/layout.ts` with a palette that fits the app + (`.agents/skills/webjs/references/styling.md` is the guide), then open every route you changed in a real browser and play through its states. `npm run check` and `npm run typecheck` pass even when a layout collapses, so the browser is the real check. diff --git a/packages/cli/templates/.claude/hooks/nudge-uncommitted.sh b/packages/cli/templates/.claude/hooks/nudge-uncommitted.sh index 6df32e3d1..e3a170c42 100755 --- a/packages/cli/templates/.claude/hooks/nudge-uncommitted.sh +++ b/packages/cli/templates/.claude/hooks/nudge-uncommitted.sh @@ -30,13 +30,6 @@ fi # Read stdin so we don't break Claude Code's hook contract. cat /dev/stdin >/dev/null 2>&1 || true -# A repository with no commit yet is a first build from the scaffold: the whole -# build is one logical unit (CLAUDE.md), committed once at the end, so nudging -# mid-build would only split it. The Stop hook still asks for that commit. -if ! git rev-parse --verify -q HEAD >/dev/null 2>&1; then - exit 0 -fi - CHANGED=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ') if [ -z "$CHANGED" ] || [ "$CHANGED" -lt "$THRESHOLD" ]; then diff --git a/packages/cli/templates/AGENTS.md b/packages/cli/templates/AGENTS.md index 60d3c5a67..7da873cae 100644 --- a/packages/cli/templates/AGENTS.md +++ b/packages/cli/templates/AGENTS.md @@ -1,71 +1,82 @@ # AGENTS.md for {{APP_NAME}} -This is a WebJs app: server-rendered pages, web components for interactivity, -server actions, Drizzle, and no build step. WebJs is its own framework, not -React, Next.js or Lit, so write it from the patterns in this file rather than -from that memory. Read this whole file before you edit anything. +This is a WebJs app: AI-first, web-components-first, buildless, and +progressively enhanced. Read this whole file before you edit anything, then +follow it. The steps here are required, not optional. -{{PLAYBOOK}} +## Gather context BEFORE you build (required) + +WebJs is its own framework. It is not React, Next, or Lit, so writing code from +that muscle memory produces broken WebJs code. Before you write or change +anything, gather context from these sources. Do not skip a step to save time. +This is what separates a working app from a broken one. -## Rules that hold everywhere +1. **Read the skill.** Start with `.agents/skills/webjs/SKILL.md`, then load the + `references/*.md` files it routes to for the surface you are touching. The + skill is the guide to building a WebJs app: it helps you choose the right + layer, reach for the right export, and avoid the mistakes Next.js or Lit + habits cause. Reading it is never wasted work: it survives the + gallery-clearing step in the playbook below. +2. **Study the shipped examples, then build on a clean slate.** The template + playbook below says what ships and the exact order to follow. The workflow + rules (git, tests, review) are in `.agents/rules/workflow.md`; follow them + too. +3. **Read the framework source for exact contracts.** WebJs is 100% buildless + native ES modules, so the source you run IS the source you read. When you + need a precise API signature or behavior, open the package source under + `node_modules/@webjsdev/*` directly (each package ships its own `AGENTS.md`). + The full hosted docs are at https://webjs.dev/docs. + +{{PLAYBOOK}} -- Type every boundary from its source: rows are `typeof posts.$inferSelect` - (into browser code with `import type`), action inputs a named `interface`, - routing files `PageProps<'/posts/[id]'>` / `LayoutProps`, object props - `prop(Object)`. `unknown` only for an untrusted payload - narrowed on the next line. Never reach for `any`. -- Never add `'use server'` to a file only server code imports (the DB - connection, the schema). -- Store data with Drizzle in the wired-up database, never in a JSON file, an - in-memory array or localStorage. Every schema change is `npm run db:generate` - then `npm run db:migrate`, and the migrations are committed. -- TypeScript is erasable: no `enum`, `namespace`, parameter properties or - decorators. Never put a backtick inside an `html` template, even in a comment. -- Descriptive names (`formData`, `values`, `errors`, never `fd`, `v`, `e`) and a - one-line doc comment on every exported function. -- `npm run check` and `npm run typecheck` name the file and the fix; do what - they say. +## Type everything (all templates) -## Git +Full-stack type safety is what the `.server.ts` boundary buys you: a client +component importing a server action resolves to that action's real signature at +type-check time, with no build step and no code generation in between. So +DERIVE the type at every boundary instead of widening it: -Commit per logical unit (CLAUDE.md has the rule). When you build a whole app -from a spec in one session, the build is one unit: work on a feature branch -(commits to `main` are refused), and commit once at the end after the checks -pass (`git add -A && git commit -m ""`). Team workflow -(pull requests, CI, worktrees) is in `.agents/rules/workflow.md`. +- A database row: `export type Todo = typeof todos.$inferSelect` in + `db/schema.server.ts` (`$inferInsert` for a write), carried into a + browser-shipped component with `import type` (erased before it reaches the + browser, so it does not trip the server-import boundary). +- An action's input: a named `interface`. Its result: `ActionResult`. + Narrow with `if (result.success && result.data)`. +- Routing files: `PageProps<'/blog/[slug]'>`, `LayoutProps`, + `RouteHandlerContext`, all from `@webjsdev/core`. Run `npx webjsdev types` + for the typed `Route` union and per-route `params`. +- A reactive property: `prop(Object)`, `prop(Array)`. -## Before you build these, read the reference first +Never reach for `any` or a loose `as any` cast, and do not reach for `unknown` +either just because it looks safer. `unknown` is right for a payload nothing +has vouched for yet, narrowed on the very next line (a `route.ts` `await +req.json()`, an action's `export const validate` or a validator it delegates +to, a `catch` binding), and for a parameter of YOUR OWN helper that forwards +into an `html` template hole (a hole renders a string, a number, a +`TemplateResult`, or an array of those, so `TemplateResult` alone is too +narrow). That second case is about a value you accept, never one the framework +already types. Everywhere else it is a missing type, not a safe one: `unknown` +that survives into a return type, a component prop, a layout's `children`, or +an action signature is the shape to fix. +Nothing enforces this (both are valid TypeScript, so `webjs check` and `tsc` +pass either way), which is exactly why it is written down. The full ladder, +with an end-to-end example, is in +`.agents/skills/webjs/references/typescript.md`. -This file covers the common app. For any surface below, read the named -section of `.agents/skills/webjs/references/` BEFORE writing that part (one -`sed -n '/^## Heading/,/^## /p' ` call), because the API is WebJs's own -and guessing it from another framework is wrong. +Keep server-only code (database drivers, secrets, `node:*` builtins) in +`.server.ts` modules. There are exactly two kinds: -| Building | Read | -| --- | --- | -| Live updates beyond the example above (presence, two-way sockets) | `client-router-and-streaming.md` "WebSockets", `built-ins.md` "Broadcast" | -| Streaming beyond the example above (Suspense, cancellation) | `client-router-and-streaming.md` "Streaming (Suspense and RPC)" | -| Refreshing one region, or one element after a write (``, ``) | `client-router-and-streaming.md` | -| File uploads and serving them (`FileStore`, `signedUrl`) | `built-ins.md` "File storage" | -| Caching, `revalidate`, `cache()`, rate limits | `built-ins.md` "Caching", "Rate limiting" | -| Middleware (`middleware.ts`, per segment) and JSON API routes (`route.ts`, `route()` adapter, CORS) | `routing-and-pages.md`, `data-and-actions.md` | -| SEO: `metadata`, `generateMetadata`, sitemap, robots, social images; the app icon | `routing-and-pages.md` "Metadata", "App icon and manifest" | -| Browser-only code (`window`, `localStorage`, timers) and component lifecycle, slots, context, directives | `components.md` | -| Optimistic updates | `optimistic-ui.md` | -| OAuth sign-in, cookie sessions, `forbidden()` / `unauthorized()` | `auth-and-sessions.md` | -| Env vars and secrets, boot-time code (`instrumentation.ts` `register()`) | `built-ins.md` "Environment variables", "Observability" | -| Scheduled jobs beyond the timer above | an external cron calling a `route.ts` with a secret | -| Email, payments, i18n | no built-in: use the provider's SDK from a server-only `.server.ts` utility called by actions; secrets from `process.env` | -| Browser and e2e tests | `testing.md` | +- A `.server.ts` file WITH `'use server';` as its first line is a server + action: WebJs exposes its exported async functions to browser code as RPC + calls, so browser modules may import it directly. +- A `.server.ts` file WITHOUT `'use server'` is a server-only utility: + importing it from a page, layout, or component CRASHES in the browser at + module load. Reach it only from `'use server'` actions, `route.ts` handlers, + or middleware. Never add `'use server'` to a file only other server code + imports (the DB connection, the schema). -## When you need more +## Data (all templates) -The reference set is `.agents/skills/webjs/` (`SKILL.md` routes to -`references/*.md`). Open one only for a surface this file does not show: -streaming and Suspense (`client-router-and-streaming.md`), optimistic UI -(`optimistic-ui.md`), caching, rate limits, file uploads, env vars -(`built-ins.md`), OAuth providers and sessions (`auth-and-sessions.md`), -browser and e2e tests (`testing.md`), deeper component topics such as slots, -shadow DOM, context and directives (`components.md`). The exact framework -source is under `node_modules/@webjsdev/*`, and the hosted docs are at -https://webjs.dev/docs. +Use the wired-up database (Drizzle) for every piece of data the app stores; +the playbook above has the modeling step. Never store app data in a JSON file, +an in-memory array, or localStorage. diff --git a/packages/cli/templates/CLAUDE.md b/packages/cli/templates/CLAUDE.md index bff4637a6..2264bdc4f 100644 --- a/packages/cli/templates/CLAUDE.md +++ b/packages/cli/templates/CLAUDE.md @@ -3,19 +3,21 @@ # Committing per logical unit (this OVERRIDES Claude Code's default) Claude Code's built-in default is to NEVER commit unless the user explicitly -asks. **For this project that default does NOT apply.** Commit per logical -unit (one feature, one fix, one rename, one doc rewrite) as soon as it is -complete and its checks pass, WITHOUT being asked, then push when there is a -remote. Git history is the user's revert and cherry-pick safety net. +asks. **For this project that default does NOT apply.** Commit and push per +logical unit (one feature, one fix, one rename, one doc rewrite) as soon as it +is complete, WITHOUT being asked. Do not save all the work for one commit at the +end. A finished implementation with zero commits is a mistake here, because git +history is the user's revert and cherry-pick safety net. -- Building a whole app from a spec in one session is ONE unit: build it, - verify it, then commit once. Later changes are one commit each. -- Never commit to `main`. Work on a feature branch (the pre-commit hook - refuses `main`). -- Imperative subject under 72 characters. No AI-attribution trailers - (`Co-Authored-By`, `Generated by`). +- After each completed unit whose tests pass, `git add` the related files and + `git commit` with an imperative subject under 72 chars, then push. If 5+ files + span more than one concern, you already waited too long. +- Never commit to `main`. Work on a feature branch (the + `.claude/hooks/guard-branch-context.sh` hook enforces this). +- No AI-attribution trailers (`Co-Authored-By`, `Generated by`). -Two hooks back this up: `.claude/hooks/nudge-uncommitted.sh` reminds you while -uncommitted changes pile up on a branch that already has commits, and -`.claude/hooks/commit-before-stop.sh` stops you from ending a turn with -uncommitted work on a feature branch. +See AGENTS.md "Git workflow" for the full contract. Two hooks back this up: the +`.claude/hooks/nudge-uncommitted.sh` PostToolUse hook reminds you while +uncommitted changes pile up during work, and the +`.claude/hooks/commit-before-stop.sh` Stop hook stops you from ending a turn +with a pile of uncommitted work still on a feature branch. diff --git a/packages/cli/templates/CONVENTIONS.md b/packages/cli/templates/CONVENTIONS.md index b42c78fa7..576116c75 100644 --- a/packages/cli/templates/CONVENTIONS.md +++ b/packages/cli/templates/CONVENTIONS.md @@ -1,9 +1,9 @@ # Conventions for {{APP_NAME}} -The conventions for building a WebJs app live in **`AGENTS.md`**, which shows -every common pattern as a worked example. The deeper reference set is -`.agents/skills/webjs/` (`SKILL.md` routes to `references/*.md`), for the rarer -surfaces. This file is the short version. +The conventions for building a WebJs app live in the agent skill. **Read +`AGENTS.md` first, then `.agents/skills/webjs/SKILL.md`** (it routes to focused +references under `.agents/skills/webjs/references/`, loaded on demand). This file +is the short version. ## The essentials @@ -17,12 +17,13 @@ surfaces. This file is the short version. - **Use the wired-up database (Drizzle).** Define real models in `db/schema.server.ts`, then `npm run db:generate` and `npm run db:migrate`. Never persist to a JSON file, an in-memory array or Map, or localStorage. -- **The scaffold ships a demo showcase.** A full-stack app ships a UI feature - gallery (`app/features/`, `app/examples/todo`); the api template ships a - backend-features showcase (`app/api/features/`), with logic in `modules/`. - When you build a real app, run `npm run gallery:clear` first to shed the - showcase, then grow the app in place. `AGENTS.md` has the template-specific - build steps. +- **The scaffold ships a showcase to learn from.** A full-stack app ships a UI + feature gallery (`app/features/`, `app/examples/todo`); the api template ships + a backend-features showcase (`app/api/features/`), with logic in `modules/`. + When you build a real app, study the parts that match your task (the skill + teaches the same and survives the clear), run `npm run gallery:clear` to shed + the showcase, then grow the app in place. `AGENTS.md` has the full + template-specific playbook. - **Derive types at every boundary.** Rows from `$inferSelect`, action inputs from an `interface`, routing files from `PageProps` / `LayoutProps`. Never `any`, and never `unknown` where a real type exists. diff --git a/packages/cli/templates/partials/agents-playbook-fullstack.md b/packages/cli/templates/partials/agents-playbook-fullstack.md index 4a97e2d8c..832f05e5d 100644 --- a/packages/cli/templates/partials/agents-playbook-fullstack.md +++ b/packages/cli/templates/partials/agents-playbook-fullstack.md @@ -1,534 +1,135 @@ -## Build an app (full-stack template) - -Everything a typical app needs is in this file: build straight from it, in a -few large writes (one heredoc per group of files), without exploring. - -### Build steps - -1. One command: `git checkout -b feat/ && npm run gallery:clear && npx webjsdev ui add button input label textarea native-select card badge`. - The gallery is a demo for people; never read it. -2. Replace `db/schema.server.ts` whole, then `npm run db:generate && npm run db:migrate`. -3. Write every `modules/` file, then `app/layout.ts`, `app/page.ts` (replace - both whole, no need to read them), the other pages, `app/not-found.ts`, and - a `test//*.test.ts` for the validation helpers. -4. `npm run check && npm run typecheck && npm run test:server`; fix what they say. -5. Walk it once in a browser (`curl` cannot submit a bound form: it carries a - hidden action field). Write `walk.mjs` in the app folder FIRST (Playwright is - installed: `import { chromium } from 'playwright'`), then start - `PORT= npm run dev > /tmp/-dev.log 2>&1 &` (never a log or - any other file inside the app folder while it runs: every write reloads the - page). In the script: `page.on('dialog', (d) => d.accept())` for - `confirm()`, `getByRole('button', { name, exact: true })`, and wait for each - outcome with `waitForURL(...)` or `getByText(...).waitFor()` (a submit is - applied in place, no full load), never a fixed timeout. Save a phone and a - desktop screenshot under `/tmp` and look at them. Then stop the server and - delete `walk.mjs`. -6. Commit once (see Git). - -### How WebJs works - -- Pages and layouts run only on the server and never hydrate (`@click` in a - page does nothing). Interactivity is a `WebComponent` custom element; the - page imports its file and writes its tag. -- `*.server.ts` with `'use server';` first: its exported async functions are - actions, called directly by pages and over RPC by components. Without - `'use server'` it is server-only (DB, secrets, `node:*`, `createAuth`): - import it only from other `.server.ts` files, `route.ts` or `middleware.ts`, - never from a page, layout or component. -- `
` is the whole form wiring (no method, no fetch, - works without JS). The action gets the `FormData` and returns - `{ success: true, redirect: '/path' }` (303), or - `{ success: false, error?, fieldErrors?, status? }`: the same page re-renders - (422) with it as `actionData`, and `actionData.values` holds every submitted - text field. A returned `Response` (from `signIn`) is sent as is. -- `notFound()` and `redirect(url)` from `@webjsdev/core` throw: pages, layouts, - form actions. In `route.ts` return a `Response`; in an RPC action return - `{ success: false, error }`. - -### Files - -``` -app/layout.ts app/page.ts app/not-found.ts root layout, /, 404 -app//[id]/page.ts, app//[id]/edit/page.ts params.id is a string -app//route.ts export async function GET(req, { params }) -modules//queries/*.server.ts, modules//actions/*.server.ts 'use server', one function per file -modules//components/.ts, modules//utils/*.ts, modules//types.ts browser-safe -lib/utils/*.ts app-wide browser-safe helpers -db/schema.server.ts tables; `db` is exported by db/connection.server.ts -``` - -Imports use the `#` alias with `.ts`: `import { db } from '#db/connection.server.ts'`. - -### Worked example: signed-in, owner-scoped CRUD - -Whole files; rename `posts` to your resource. A child resource (a project's -tasks) references its parent with `onDelete: 'cascade'`, and every query and -action checks the parent belongs to the user. - -```ts -// db/schema.server.ts -import { defineRelations } from 'drizzle-orm'; -import { table, pk, text, integer, createdAt, index } from './columns.server.ts'; -import { POST_STATUSES } from '#modules/posts/types.ts'; -export const users = table('users', { - id: pk(), email: text().notNull().unique(), passwordHash: text().notNull(), createdAt: createdAt(), -}); -export const posts = table('posts', { - id: pk(), - ownerId: integer().notNull().references(() => users.id, { onDelete: 'cascade' }), - title: text().notNull(), - body: text().notNull().default(''), - status: text({ enum: POST_STATUSES }).notNull().default('draft'), - publishOn: text(), // 'YYYY-MM-DD' from , or null - createdAt: createdAt(), -}, (t) => [index(t.ownerId)]); -export const relations = defineRelations({ users, posts }, () => ({})); -export type Post = typeof posts.$inferSelect; - -// modules/posts/types.ts (browser-safe; components never import the schema) -export const POST_STATUSES = ['draft', 'review', 'published'] as const; -export type PostStatus = (typeof POST_STATUSES)[number]; -``` - -```ts -// lib/utils/form.ts (labelled controls: the label, the server error under it, the typed value kept) -import { html } from '@webjsdev/core'; -import { labelClass } from '#components/ui/label.ts'; -import { inputClass } from '#components/ui/input.ts'; -import { textareaClass } from '#components/ui/textarea.ts'; -import { nativeSelectClass, nativeSelectWrapperClass, nativeSelectIconClass } from '#components/ui/native-select.ts'; -export interface FormState { error?: string; fieldErrors?: Record; values?: Record } -export const isEmail = (s: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s); -/** A trimmed text field from a submitted form ('' when absent). */ -export const str = (formData: FormData, name: string) => String(formData.get(name) ?? '').trim(); -/** A positive integer id from a param or field, or null. */ -export const toId = (v: unknown) => { const n = Number(v); return Number.isInteger(n) && n > 0 ? n : null; }; -interface Field { label: string; name: string; value?: string; error?: string; required?: boolean; id?: string } -const wrap = (o: Field, control: unknown) => html` -
- ${control} - ${o.error ? html`

${o.error}

` : ''} -
`; -export const field = (o: Field & { type?: string }) => wrap(o, html``); -export const textareaField = (o: Field) => wrap(o, html``); -/** A native select with the kit's chevron. Pass `name: ''` for a select no form submits. */ -export const selectControl = (o: { id: string; name?: string; value?: string; options: readonly string[]; attrs?: unknown }) => html` -
- - -
`; -export const selectField = (o: Field & { options: readonly string[] }) => wrap(o, selectControl({ id: o.id ?? o.name, name: o.name, value: o.value, options: o.options })); -``` - -Auth is the built-in `createAuth` (a signed session cookie). Hash passwords in -`modules/auth/password.server.ts` with `node:crypto` `scrypt`, a random salt -stored as `salt:hash`, and `timingSafeEqual` (`hashPassword`, `verifyPassword`). - -```ts -// modules/auth/auth.server.ts (server-only: no 'use server') -import { createAuth, Credentials } from '@webjsdev/server'; -import { db } from '#db/connection.server.ts'; -import { verifyPassword } from './password.server.ts'; -const secret = process.env.AUTH_SECRET; -if (!secret) throw new Error('AUTH_SECRET is not set'); -export const { auth, signIn, signOut } = createAuth({ - secret, - pages: { signIn: '/signin', error: '/signin' }, - providers: [Credentials({ - async authorize(c: { email: string; password: string }) { - const user = await db.query.users.findFirst({ where: { email: c.email } }); - if (!user || !(await verifyPassword(c.password, user.passwordHash))) return null; - return { id: String(user.id), email: user.email }; - }, - })], -}); -export interface SessionUser { id: number; email: string } -export async function getUser(): Promise { - const u = (await auth())?.user; - return u?.id ? { id: Number(u.id), email: String(u.email) } : null; -} - -// modules/auth/queries/current-user.server.ts: 'use server' + export async function currentUser() { return getUser(); } - -// modules/auth/queries/require-user.server.ts (first line of every signed-in page) -'use server'; -import { redirect } from '@webjsdev/core'; -import { getUser, type SessionUser } from '../auth.server.ts'; -export async function requireUser(): Promise { - return (await getUser()) ?? redirect('/signin'); -} -``` - -```ts -// modules/auth/actions/sign-up.server.ts -'use server'; -import { db } from '#db/connection.server.ts'; -import { users } from '#db/schema.server.ts'; -import { isEmail, str } from '#lib/utils/form.ts'; -import { hashPassword } from '../password.server.ts'; -import { signIn } from '../auth.server.ts'; -/** Creates the account, then signs in. Fails with a message per field. */ -export async function signUp(formData: FormData) { - const email = str(formData, 'email').toLowerCase(); - const password = String(formData.get('password') ?? ''); - const fieldErrors: Record = {}; - if (!isEmail(email)) fieldErrors.email = 'Enter a valid email address.'; - if (password.length < 8) fieldErrors.password = 'Password must be at least 8 characters.'; - if (!fieldErrors.email && (await db.query.users.findFirst({ where: { email } }))) fieldErrors.email = 'An account with this email already exists.'; - if (Object.keys(fieldErrors).length) return { success: false, fieldErrors }; - await db.insert(users).values({ email, passwordHash: await hashPassword(password) }); - return signIn('credentials', { email, password }, { redirectTo: '/posts' }); // cookie + 302 -} -``` - -Sign-in: look the user up, `verifyPassword`, return -`{ success: false, error: 'Invalid email or password.' }` on a mismatch, else -the same `return signIn(...)`. Sign-out: an action -`export async function signOutUser(_formData: FormData) { return signOut({ redirectTo: '/signin' }); }` -bound to a form in the layout. - -```ts -// modules/posts/utils/validate-post.ts (pure, shared by create and update, unit-tested) -import { str } from '#lib/utils/form.ts'; -import { POST_STATUSES, type PostStatus } from '../types.ts'; -/** Checks a submitted post form: the clean data, or the errors with what was typed. */ -export function validatePost(formData: FormData) { - const values = { title: str(formData, 'title'), body: str(formData, 'body'), status: str(formData, 'status') || 'draft', publishOn: str(formData, 'publishOn') }; - const fieldErrors: Record = {}; - if (!values.title) fieldErrors.title = 'Title is required.'; - if (!(POST_STATUSES as readonly string[]).includes(values.status)) fieldErrors.status = 'Pick a status.'; - if (values.publishOn && !/^\d{4}-\d{2}-\d{2}$/.test(values.publishOn)) fieldErrors.publishOn = 'Use a valid date.'; - if (Object.keys(fieldErrors).length) return { ok: false as const, fieldErrors, values }; - return { ok: true as const, data: { ...values, status: values.status as PostStatus, publishOn: values.publishOn || null } }; -} -``` - -Reads use `db.query..findFirst/findMany` with an object `where` and -`orderBy`, always filtered by the owner; counts are one grouped query. - -```ts -// modules/posts/queries/get-post.server.ts (list-posts: findMany({ where: { ownerId }, orderBy: { createdAt: 'desc' } })) -'use server'; -import { db } from '#db/connection.server.ts'; -import type { Post } from '#db/schema.server.ts'; -import { getUser } from '#modules/auth/auth.server.ts'; -import { toId } from '#lib/utils/form.ts'; -export async function getPost(id: string): Promise { - const user = await getUser(); - const postId = toId(id); - if (!user || !postId) return null; - return (await db.query.posts.findFirst({ where: { id: postId, ownerId: user.id } })) ?? null; -} - -// modules/posts/queries/count-posts.server.ts (inside the function, after the user check) -const rows = await db.select({ status: posts.status, n: count() }).from(posts) // count, eq from 'drizzle-orm' - .where(eq(posts.ownerId, user.id)).groupBy(posts.status); -``` - -Writes use the query builder with `eq` / `and` and put the owner in the -`where`, so another user's id changes nothing: - -```ts -// modules/posts/actions/update-post.server.ts ('use server'; create and delete are the same shape) -/** Saves an edited post the signed-in user owns; a foreign or missing id is not found. */ -export async function updatePost(formData: FormData) { - const user = await getUser(); - const id = toId(formData.get('id')); - if (!user || !id) return { success: false, error: 'Not found.', status: 404 }; - const result = validatePost(formData); - if (!result.ok) return { success: false, fieldErrors: result.fieldErrors, values: result.values }; - const rows = await db.update(posts).set(result.data).where(and(eq(posts.id, id), eq(posts.ownerId, user.id))).returning(); - return rows.length ? { success: true, redirect: `/posts/${id}` } : { success: false, error: 'Not found.', status: 404 }; -} -// create: const [post] = await db.insert(posts).values({ ...result.data, ownerId: user.id }).returning(); then redirect to `/posts/${post.id}` -``` - -File uploads use the built-in `FileStore` (bytes under `.webjs/uploads`, -outside the watched source, never `public/`). The bound form posts the file -in its `FormData`; store the key and the original name on the row, and serve -it from a `route.ts` that checks access: - -```ts -// in the action: import { getFileStore, generateKey } from '@webjsdev/server'; -const file = formData.get('attachment'); -if (file instanceof File && file.size > 5 * 1024 * 1024) return { success: false, fieldErrors: { attachment: 'File must be 5 MB or smaller.' } }; -const fileKey = file instanceof File && file.size ? generateKey(file.name) : null; -if (fileKey) await getFileStore().put(fileKey, file as File); -// save { fileKey, fileName: (file as File).name } with the row - -// app/files/[key]/route.ts -import { getFileStore } from '@webjsdev/server'; -export async function GET(_req: Request, { params }: { params: { key: string } }) { - const row = await findFile(params.key); // your server-only lookup: the row with this key the user may see (auth(req) in a route) - const f = row && (await getFileStore().get(params.key)); - if (!f) return new Response('Not found', { status: 404 }); - return new Response(f.body as ReadableStream, { headers: { 'content-type': 'application/octet-stream', 'content-disposition': `attachment; filename="${encodeURIComponent(row.fileName)}"`, 'x-content-type-options': 'nosniff' } }); -} -``` - -A component calls an RPC action with a typed object; the action checks the -user and the input and returns a result (never throws or redirects), for -example `setPostStatus(input: { id: number; status: PostStatus })` returning -`{ success: true }` or `{ success: false, error }`. Reactive properties are -declared in the `WebComponent({...})` factory (`postId` arrives as the -`post-id` attribute), local state is a signal, events are unquoted `@event=${fn}`: - -```ts -// modules/posts/components/post-status.ts -import { WebComponent, html, signal } from '@webjsdev/core'; -import { setPostStatus } from '../actions/set-post-status.server.ts'; -import { POST_STATUSES, type PostStatus } from '../types.ts'; -import { nativeSelectClass, nativeSelectWrapperClass, nativeSelectIconClass } from '#components/ui/native-select.ts'; -export class PostStatusSelect extends WebComponent({ postId: Number, status: String }) { - note = signal(''); - async onChange(e: Event) { - const select = e.target as HTMLSelectElement; - const before = this.status; - this.status = select.value; - this.note.set('Saving…'); - const res = await setPostStatus({ id: this.postId, status: select.value as PostStatus }); - if (res.success) this.note.set('Saved'); - else { this.status = before; select.value = before; this.note.set(res.error ?? 'Could not save'); } - } - render() { - const id = `status-${this.postId}`; - return html` -
- -
- - -
- ${this.note.get()} -
`; - } -} -PostStatusSelect.register('post-status'); -``` - -Live updates: a `route.ts` with a `WS` export is the socket, an action calls -`broadcast()` after a write, and a tiny component reloads only the region that -changed (a ``), so a form someone is typing in is untouched. -Streaming: an action that is an async generator streams over RPC, and a -component appends each chunk to a signal. - -```ts -// app/live/posts/route.ts -/** The posts list's live channel; clients connect, actions broadcast. */ -export function WS() {} - -// in the create action, after the insert: import { broadcast } from '@webjsdev/server'; -broadcast('/live/posts', JSON.stringify({ id: post.id })); - -// modules/posts/components/live-refresh.ts -import { WebComponent, connectWS, loadFrame } from '@webjsdev/core'; -/** Reloads `` whenever the server broadcasts on `path`. */ -export class LiveRefresh extends WebComponent({ path: String, frame: String }) { - connection?: { close(): void }; - connectedCallback() { - super.connectedCallback(); - this.connection = connectWS(this.path, { onMessage: () => { - const frame = document.getElementById(this.frame); - if (frame) void loadFrame(frame, location.pathname); - } }); - } - disconnectedCallback() { super.disconnectedCallback(); this.connection?.close(); } -} -LiveRefresh.register('live-refresh'); -// in the page: -// and the list inside ... - -// modules/posts/actions/stream-summary.server.ts ('use server') -/** Streams a summary word by word. */ -export async function* streamSummary(): AsyncGenerator { - const text = `You have ${(await listPosts()).length} posts.`; - for (const word of text.split(' ')) { yield word + ' '; await new Promise((resolve) => setTimeout(resolve, 80)); } -} - -// modules/posts/components/post-summary.ts (render: a Summarize button and

${this.text.get()}

) -async summarize() { - this.text.set(''); this.busy.set(true); - try { for await (const chunk of await streamSummary()) this.text.set(this.text.get() + chunk); } - catch { this.text.set('Could not summarize. Try again.'); } - finally { this.busy.set(false); } -} -``` - -A job on a timer starts once at boot, in `instrumentation.ts`: -`export function register() { setInterval(() => void runDigest().catch(console.error), Number(process.env.DIGEST_INTERVAL_MS) || 3_600_000); }` -(`runDigest` lives in a server-only `.server.ts` module). - -```ts -// app/layout.ts -import { html, asset } from '@webjsdev/core'; -import type { LayoutProps } from '@webjsdev/core'; -import { buttonClass } from '#components/ui/button.ts'; -import { currentUser } from '#modules/auth/queries/current-user.server.ts'; -import { signOutUser } from '#modules/auth/actions/sign-out.server.ts'; -export const metadata = { title: { default: 'Posts', template: '%s | Posts' } }; -export default async function RootLayout({ children }: LayoutProps) { - const user = await currentUser(); - return html` - - - - -
- -
-
${children}
`; -} -``` - -`app/page.ts`: `export default async function Home() { redirect((await currentUser()) ? '/posts' : '/signin'); }`. -`app/not-found.ts` default-exports a function returning the 404 `html`. - -Every page has the same anatomy: a header (h1, a one-line summary, the main -actions on the right), then sections, each with an h2. A form is a card with a -title and its fields in a grid; a list is ONE card of divided rows, each row the -title, a muted meta line, and small actions on the right. Do not put -`cardClass()` on list rows (it carries `gap-6 py-6` for card sections and makes -rows sprawl). Pages with a form read `actionData` typed as `FormState`. - -```ts -// app/posts/page.ts (a list page with a create form and row actions; projects or tasks look the same) -import { html } from '@webjsdev/core'; -import type { PageProps } from '@webjsdev/core'; -import { buttonClass } from '#components/ui/button.ts'; -import { field, selectField, type FormState } from '#lib/utils/form.ts'; -import { requireUser } from '#modules/auth/queries/require-user.server.ts'; -import { listPosts } from '#modules/posts/queries/list-posts.server.ts'; -import { createPost } from '#modules/posts/actions/create-post.server.ts'; -import { deletePost } from '#modules/posts/actions/delete-post.server.ts'; -import { POST_STATUSES } from '#modules/posts/types.ts'; -import '#modules/posts/components/post-status.ts'; // registers -export const metadata = { title: 'Posts' }; -const CARD = 'rounded-xl border border-border bg-card text-card-foreground shadow-sm'; -export default async function PostsPage({ actionData }: PageProps<'/posts'> & { actionData?: FormState }) { - await requireUser(); - const items = await listPosts(); - const errors = actionData?.fieldErrors ?? {}; - const typed = actionData?.values ?? {}; - return html` -
-

Posts

-

${items.length} posts

-
-
-

New post

-
- ${field({ label: 'Title', name: 'title', value: typed.title, error: errors.title, required: true })} - ${selectField({ label: 'Status', name: 'status', value: typed.status ?? 'draft', options: POST_STATUSES })} -
- -
-
-

All posts

- ${items.length ? html` -
    - ${items.map((post) => html` -
  • -
    - ${post.title} -

    ${[post.body, post.publishOn && `Publish ${post.publishOn}`].filter(Boolean).join(' · ')}

    -
    -
    - - Edit -
    - - - -
    -
  • `)} -
` - : html`

No posts yet. Create the first one above.

`} -
`; -} -``` - -A detail or edit page loads its row with the owner check and stops on a miss: -`const post = await getPost(params.id); if (!post) notFound();` -(`PageProps<'/posts/[id]/edit'>`, `notFound` from `@webjsdev/core`). The edit -form is the same card, pre-filled (`const typed = actionData?.values ?? { title: post.title, ... }`), -with a hidden `id` input, `textareaField` / `selectField` for long text and -choices, and Save plus a ghost Cancel link. Sign-in and sign-up pages are one -narrow form card (`mx-auto max-w-sm`), starting with -`if (await currentUser()) redirect('/posts');` and showing `actionData.error`. -Destructive buttons are `ghost` or `outline` with `text-destructive`; a solid -red button is too loud for a row or a page header. - -Write one test file per feature (`test//.test.ts`, node:test) -that asserts each validation message, the clean result, and every pure helper -(password hashing, summaries, counts): - -```ts -// test/posts/posts.test.ts -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { validatePost } from '#modules/posts/utils/validate-post.ts'; -const form = (fields: Record) => { const f = new FormData(); for (const [k, v] of Object.entries(fields)) f.set(k, v); return f; }; -test('a post without a title is refused with the message', () => { - const result = validatePost(form({ title: ' ' })); - assert.equal(result.ok, false); - assert.equal(!result.ok && result.fieldErrors.title, 'Title is required.'); -}); -test('a valid post is trimmed and typed', () => { - assert.deepEqual(validatePost(form({ title: ' Hi ', status: 'review' })), { ok: true, data: { title: 'Hi', body: '', status: 'review', publishOn: null } }); -}); - -// test/auth/auth.test.ts (keep sign-up and sign-in checks in a pure validate function so they are testable) -test('a password hashes with a salt and verifies only itself', async () => { - const stored = await hashPassword('correct horse'); - assert.notEqual(stored, await hashPassword('correct horse')); - assert.equal(await verifyPassword('correct horse', stored), true); - assert.equal(await verifyPassword('wrong', stored), false); -}); -``` - -### Look and the UI kit - -- The palette is the token block in the layout, one `light-dark(LIGHT, DARK)` - per colour; `public/input.css` maps the tokens into Tailwind. Choose values - for the product, and style only with token utilities (`bg-background - text-foreground bg-card bg-primary text-primary-foreground bg-muted - text-muted-foreground border-border text-destructive`), never a raw colour - such as `bg-blue-600`. -- Keep the kit's visible focus ring (`--ring` is a strong colour, as above) and - hover states; never `outline-none` without a replacement. Numbers that matter - (counts, totals) are small tiles: `
2
To do
` - in a `grid grid-cols-4 gap-2`. -- The header is `position: fixed` (never `sticky`) with the content offset by - its height. Mobile first: one column that widens at `sm:` / `md:`. -- UI kit helpers in `components/ui/` (no need to open them): - `buttonClass({ variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link', size?: 'default' | 'xs' | 'sm' | 'lg' | 'icon' })`, - `inputClass()`, `textareaClass()`, `labelClass()`, `nativeSelectClass()`, - `cardClass()`, `badgeClass({ variant?: 'default' | 'secondary' | 'destructive' | 'outline' })`, - used as `class=${buttonClass({ variant: 'outline' })}`. Dialogs, tabs, menus - and toasts are custom elements: `npx webjsdev ui add dialog`, then - `npx webjsdev ui view dialog`. +## Build a full-stack app (default template) + +This scaffold ships a browsable feature gallery to learn from: single-concept +demos under `app/features/`, the `app/examples/todo` app, and an example design +system under `components/ui/`, with logic in `modules/`. Build in this order. + +### 1. Study the gallery, then clear it + +Read the demos under `app/features/` (and `app/examples/todo`) that match what +you are building, so you copy the real idiom: server actions, queries, +optimistic UI, component hydration, design tokens. Then run +`npm run gallery:clear` to shed the whole gallery and reset `app/page.ts` and +`app/layout.ts` to a blank slate. The clear also removes the example +`components/ui/` primitives, the demo `todos` table, and the demo migrations; +it keeps the agent skill, the database wiring, and `lib/utils/cn.ts` (needed by +`npx webjsdev ui add`). The skill teaches the same patterns, so the gallery is +a runnable copy you study first, not something you lose. + +### 2. Model the data + +Define real models in `db/schema.server.ts`, then run `npm run db:generate` and +`npm run db:migrate` (required after the clear, which removed the demo table and +migrations). Write a seed script at `db/seed.server.ts` and run +`npm run db:seed` so list and detail pages render real rows while you build, +instead of empty states. Put reads in `modules//queries/*.server.ts` +and writes in `modules//actions/*.server.ts`, one function per file. + +### 3. Build a token-based design system + +Full reference: `.agents/skills/webjs/references/styling.md`. + +- Define your color tokens as CSS custom properties in `app/layout.ts`, each + written ONCE with the native CSS `light-dark(LIGHT, DARK)` function, so light + and dark modes come from one declaration. +- Define at least: `--background`, `--foreground`, `--card`, `--primary`, + `--secondary`, `--muted`, `--muted-foreground`, `--accent`, `--border`, + `--ring`, `--destructive`. Add the matching `*-foreground` pair for each + surface token you use, following the styling guide's reference palette. +- Consume colors ONLY as token utilities: `bg-background`, `text-foreground`, + `bg-card`, `border-border`, `text-primary`, `text-muted-foreground`, + `bg-destructive`. +- NEVER put a raw un-themed Tailwind color (`red-500`, `blue-600`, `gray-100`) + on an element or a `@webjsdev/ui` helper. +- Add an inline theme-detection script in the layout `` so the first + paint matches the saved theme with no flash. + +### 4. Use the UI kit, do not hand-roll primitives + +Pull primitives with `npx webjsdev ui add `; the source is copied into +`components/ui/`, so you own it fully and can add, remove, restructure, or theme +it however your app needs. Do NOT guess a helper or tag signature. Inspect the +copied file `components/ui/.ts`, or run +`npx webjsdev ui view `, for the exact exported names, variants, and +sizes. The kit has two tiers: + +- **Tier 1, class helpers** for static primitives (button, card, input, badge, + native-select, textarea). Spread the helper onto a native element, for example + `class=${buttonClass({ variant: 'outline', size: 'sm' })}`. +- **Tier 2, custom elements** for stateful controls and overlays (``, + ``, ``, ``, sonner toasts). Use the + registered tag; it owns its ARIA, focus trap, and keyboard navigation out of + the box. Never hand-author a tab strip or a modal when a Tier-2 element + covers it. + +Full reference: `.agents/skills/webjs/references/ui-kit.md`. + +### 5. Build a multi-page app (MPA), not a single page + +Structure the product as real routes, not one page that swaps client state: + +- `/` a home or overview page. +- `/` a list page with search, filters, sorting, and a create form or + modal. +- `//[id]` a detail page for one item. +- a couple of additional feature pages as the product needs. + +Give `app/layout.ts` a navbar that links the main pages, pinned with +`position: fixed` (never `position: sticky`, which flickers on iOS during a +client-router navigation), and reserve its height on the content with a +`--header-height` offset. In a list or table, clicking a row or card navigates +to that item's detail page. Wrap each row action button (edit, delete, status) +so its handler calls `event.stopPropagation()`, letting the button run its own +action without also triggering the row navigation. + +### 6. Build components for interactivity + +Pages and layouts (`app/**/page.ts`, `app/**/layout.ts`) are server-only HTML +generators, so put every interactive behavior inside a `WebComponent` custom +element. Declare a component's reactive properties in the base-class factory, +never as a class-field initializer (`items = []` clobbers the reactive +accessor). Use the shorthand for primitives +(`extends WebComponent({ name: String, count: Number, open: Boolean })`) and the +`prop()` helper for typed objects and arrays +(`extends WebComponent({ items: prop(Array), user: prop(Object) })`). + +### 7. Verify before you call it done + +Run `npm run ci` and fix what it reports. It is one command for every gate, +the step list declared in `package.json` under `webjs.ci`, with a result line +per step: + +- `webjs check` (correctness: no browser-import or boundary violation). +- `webjs doctor` (project health). It fails on whatever `package.json` + `webjs.doctor.gate` marks `error`, plus the two hard toolchain checks that + are fatal with no gate entry, `NODE_VERSION` and `TSCONFIG_ERASABLE`. +- `webjs typecheck` (zero type errors). +- A dependency audit. +- The server, browser, and e2e test layers for the features you built. + +The GitHub workflow runs the same list, so a green local run predicts CI. +While iterating, `npm run ci -- --only Tests` runs one layer. Then +`npm run css:build` (compile Tailwind). + +Then boot `npm run dev`, confirm every page route returns HTTP 200, and open +every route you changed in a real browser and play through its states: `check` +and `typecheck` pass even when a layout collapses, so the browser is the real +check for UI work. ### Commands -`npm run dev` (PORT=), `npm run db:generate && npm run db:migrate`, -`npm run check`, `npm run typecheck`, `npm run test:server`, `npm run ci` -(every gate, before you push), `npx webjsdev ui add `. +```sh +npm install +npm run gallery:clear # shed the demo gallery before building a real app +npm run dev # dev server at http://localhost:8080 +npm run start # production server +npm test # unit + browser tests +npm run typecheck +npm run css:build # compile Tailwind +npm run ci # every gate, one command (the webjs.ci steps in package.json) +npm run check # correctness checks +npm run doctor # project health (severity per check: webjs.doctor.gate) +npx webjsdev ui add # copy a ui primitive into components/ui/ +npx webjsdev ui view # inspect a primitive's exact signature +npm run db:generate && npm run db:migrate +``` diff --git a/packages/cli/templates/scripts/clear-gallery.mjs b/packages/cli/templates/scripts/clear-gallery.mjs index 167d3893f..a6e9fe243 100644 --- a/packages/cli/templates/scripts/clear-gallery.mjs +++ b/packages/cli/templates/scripts/clear-gallery.mjs @@ -147,8 +147,8 @@ for (const f of ['db/dev.db', 'db/dev.db-shm', 'db/dev.db-wal']) rm(f); // children before test/ so it reads as empty. for (const d of ['app/api', 'test/unit', 'test/e2e', 'test']) if (pruneEmpty(d)) removed++; -console.log(`Gallery cleared (${removed} paths removed). The agent docs and your database wiring are kept.`); -console.log('Next: follow the build steps in AGENTS.md (schema, then db:generate and db:migrate, then modules/ and app/).'); +console.log(`Gallery cleared (${removed} paths removed). The agent skill and your database wiring are kept. Build your own design system: run \`npx webjsdev ui add \` and theme it (see .agents/skills/webjs/references/styling.md).`); +console.log('Next: regenerate the database (db:generate then db:migrate), then start the dev server and build your app in app/ and modules/.'); function MINIMAL_PAGE() { return `import { html } from '@webjsdev/core'; @@ -163,7 +163,7 @@ export default function Home() {

Your app

The gallery is cleared. This is app/page.ts. Build your - app from here. The guide is AGENTS.md. + app from here. The guide is .agents/skills/webjs/SKILL.md.