@webjsdev/cli 0.10.29 → 0.10.31

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.
Files changed (63) hide show
  1. package/bin/webjs.js +1 -1
  2. package/lib/api-gallery.js +229 -0
  3. package/lib/create.js +452 -158
  4. package/lib/saas-template.js +39 -15
  5. package/package.json +1 -1
  6. package/templates/.agents/rules/workflow.md +70 -2
  7. package/templates/.claude/hooks/check-server-imports.mjs +86 -0
  8. package/templates/.claude/hooks/check-server-imports.sh +26 -0
  9. package/templates/.claude/settings.json +9 -0
  10. package/templates/.cursorrules +41 -2
  11. package/templates/.github/copilot-instructions.md +41 -2
  12. package/templates/AGENTS.md +160 -10
  13. package/templates/CONVENTIONS.md +150 -11
  14. package/templates/gallery/app/examples/todo/page.ts +34 -0
  15. package/templates/gallery/app/features/async-render/page.ts +14 -0
  16. package/templates/gallery/app/features/broadcast/feed/route.ts +19 -0
  17. package/templates/gallery/app/features/broadcast/page.ts +24 -0
  18. package/templates/gallery/app/features/caching/page.ts +39 -0
  19. package/templates/gallery/app/features/client-router/page.ts +34 -0
  20. package/templates/gallery/app/features/client-router/second/page.ts +20 -0
  21. package/templates/gallery/app/features/components/page.ts +14 -0
  22. package/templates/gallery/app/features/directives/page.ts +14 -0
  23. package/templates/gallery/app/features/env/page.ts +36 -0
  24. package/templates/gallery/app/features/file-storage/file/[key]/route.ts +19 -0
  25. package/templates/gallery/app/features/file-storage/page.ts +62 -0
  26. package/templates/gallery/app/features/forms/page.ts +72 -0
  27. package/templates/gallery/app/features/metadata/page.ts +55 -0
  28. package/templates/gallery/app/features/optimistic-ui/page.ts +14 -0
  29. package/templates/gallery/app/features/rate-limit/page.ts +29 -0
  30. package/templates/gallery/app/features/rate-limit/ping/middleware.ts +8 -0
  31. package/templates/gallery/app/features/rate-limit/ping/route.ts +7 -0
  32. package/templates/gallery/app/features/route-handler/data/route.ts +8 -0
  33. package/templates/gallery/app/features/route-handler/page.ts +13 -0
  34. package/templates/gallery/app/features/routing/[id]/page.ts +25 -0
  35. package/templates/gallery/app/features/routing/page.ts +47 -0
  36. package/templates/gallery/app/features/server-actions/page.ts +14 -0
  37. package/templates/gallery/app/features/service-worker/page.ts +35 -0
  38. package/templates/gallery/app/features/websockets/echo/route.ts +19 -0
  39. package/templates/gallery/app/features/websockets/page.ts +25 -0
  40. package/templates/gallery/modules/async-render/components/server-clock.ts +26 -0
  41. package/templates/gallery/modules/async-render/queries/server-greeting.server.ts +9 -0
  42. package/templates/gallery/modules/broadcast/components/broadcast-feed.ts +61 -0
  43. package/templates/gallery/modules/components/components/counter-card.ts +35 -0
  44. package/templates/gallery/modules/directives/components/directive-demo.ts +53 -0
  45. package/templates/gallery/modules/file-storage/actions/store-upload.server.ts +18 -0
  46. package/templates/gallery/modules/optimistic-ui/actions/like-post.server.ts +9 -0
  47. package/templates/gallery/modules/optimistic-ui/components/like-button.ts +35 -0
  48. package/templates/gallery/modules/rate-limit/components/rate-probe.ts +49 -0
  49. package/templates/gallery/modules/server-actions/actions/greet.server.ts +12 -0
  50. package/templates/gallery/modules/server-actions/components/greeter.ts +30 -0
  51. package/templates/gallery/modules/server-actions/utils/format.server.ts +8 -0
  52. package/templates/gallery/modules/todo/actions/create-todo.server.ts +16 -0
  53. package/templates/gallery/modules/todo/actions/delete-todo.server.ts +15 -0
  54. package/templates/gallery/modules/todo/actions/toggle-todo.server.ts +21 -0
  55. package/templates/gallery/modules/todo/components/todo-app.ts +140 -0
  56. package/templates/gallery/modules/todo/queries/list-todos.server.ts +19 -0
  57. package/templates/gallery/modules/todo/types.ts +12 -0
  58. package/templates/gallery/modules/websockets/components/ws-echo.ts +62 -0
  59. package/templates/lib/utils/ui.ts +4 -4
  60. package/templates/test/hello/browser/hello.test.js +12 -6
  61. package/templates/test/hello/e2e/hello.test.ts +11 -9
  62. package/templates/test/hello/hello.test.ts +4 -6
  63. package/templates/web-test-runner.config.js +84 -9
package/lib/create.js CHANGED
@@ -101,7 +101,10 @@ async function readUiComponent(name) {
101
101
  // it to the scaffolded app's aliased path (cn lives at lib/utils/cn.ts).
102
102
  return raw
103
103
  .replaceAll("'../lib/utils.ts'", "'#lib/utils/cn.ts'")
104
- .replaceAll('"../lib/utils.ts"', '"#lib/utils/cn.ts"');
104
+ .replaceAll('"../lib/utils.ts"', '"#lib/utils/cn.ts"')
105
+ // onBeforeCache lives in its own client-only module so cn() stays pure (#819).
106
+ .replaceAll("'../lib/dom.ts'", "'#lib/utils/dom.ts'")
107
+ .replaceAll('"../lib/dom.ts"', '"#lib/utils/dom.ts"');
105
108
  }
106
109
 
107
110
  /**
@@ -131,6 +134,28 @@ async function copyUiComponents(appDir, names) {
131
134
  }
132
135
  }
133
136
 
137
+ /**
138
+ * Copy the example gallery (idiomatic, densely-commented working examples) into
139
+ * the scaffolded app. Merges `templates/gallery/{app,modules}` over the app so
140
+ * single-feature demos land under `app/features/<name>/`, whole example apps
141
+ * under `app/examples/<name>/`, and their logic under `modules/<name>/`, the
142
+ * app-thin + modules-logic split webjs prescribes.
143
+ *
144
+ * Ships verbatim (no `{{APP_NAME}}` substitution): the examples are self-
145
+ * contained and reference only `@webjsdev/*`, drizzle, `#db/*`, and each other.
146
+ * The scaffold's own `app/page.ts` / `app/layout.ts` are written AFTER this and
147
+ * the gallery ships neither, so there is no clobber. `cp` merges into existing
148
+ * `app/` and `modules/` dirs rather than replacing them.
149
+ *
150
+ * @param {string} appDir
151
+ */
152
+ async function copyGallery(appDir) {
153
+ const galleryDir = join(TEMPLATES, 'gallery');
154
+ for (const sub of ['app', 'modules']) {
155
+ await cp(join(galleryDir, sub), join(appDir, sub), { recursive: true });
156
+ }
157
+ }
158
+
134
159
  /**
135
160
  * Write `lib/utils/cn.ts` (the `cn()` helper) and `components.json` so the
136
161
  * scaffolded app is pre-initialised for `webjs ui add`. Reads the registry's
@@ -144,21 +169,31 @@ async function writeUiBootstrap(appDir) {
144
169
  // Caller (scaffoldApp) has already invoked assertUiRegistryAvailable(),
145
170
  // so the source files below are guaranteed to exist.
146
171
 
147
- // 1) lib/utils/cn.ts: the cn() helper
172
+ // 1) lib/utils/cn.ts: the cn() helper (pure; safe to import into a page).
148
173
  const utilsContent = await readFile(
149
174
  join(UI_REGISTRY_ROOT, 'lib', 'utils.ts'), 'utf8',
150
175
  );
151
176
  await mkdir(join(appDir, 'lib', 'utils'), { recursive: true });
152
177
  await writeFile(join(appDir, 'lib', 'utils', 'cn.ts'), utilsContent);
153
178
 
179
+ // 1b) lib/utils/dom.ts: the client-only DOM helper (onBeforeCache). Split out
180
+ // of cn.ts so importing cn() does not pin a page to the browser (#819). Any
181
+ // `webjs ui add` component that uses onBeforeCache imports it from here.
182
+ const domContent = await readFile(
183
+ join(UI_REGISTRY_ROOT, 'lib', 'dom.ts'), 'utf8',
184
+ );
185
+ await writeFile(join(appDir, 'lib', 'utils', 'dom.ts'), domContent);
186
+
154
187
  // 2) components.json: the same shape `webjsui init` writes for webjs
155
188
  // projects (see packages/ui/src/utils/detect-project.js). The utils alias
156
189
  // is lib/utils/cn so get-config.js's `+ '.ts'` resolves to lib/utils/cn.ts.
190
+ // The theme CSS lives at styles/globals.css, NOT app/globals.css: app/ is
191
+ // routing-only, so a non-routing stylesheet does not belong there.
157
192
  const componentsJson = {
158
193
  $schema: 'https://ui.webjs.dev/schema.json',
159
194
  style: 'default',
160
195
  tailwind: {
161
- css: 'app/globals.css',
196
+ css: 'styles/globals.css',
162
197
  baseColor: 'neutral',
163
198
  cssVariables: true,
164
199
  },
@@ -175,17 +210,20 @@ async function writeUiBootstrap(appDir) {
175
210
  JSON.stringify(componentsJson, null, 2) + '\n',
176
211
  );
177
212
 
178
- // 3) app/globals.css: copy the neutral theme verbatim. components.json
179
- // references this path, and future `webjs ui add` calls append to it.
213
+ // 3) styles/globals.css: copy the neutral theme verbatim. components.json
214
+ // references this path, and future `webjs ui add` calls append to it. It
215
+ // lives OUTSIDE app/ because app/ is routing-only; the layout inlines the
216
+ // same tokens into a <style type="text/tailwindcss"> block so the browser
217
+ // Tailwind runtime resolves them with no build step.
180
218
  const css = await readFile(
181
219
  join(UI_REGISTRY_ROOT, 'themes', 'index.css'), 'utf8',
182
220
  );
183
- await mkdir(join(appDir, 'app'), { recursive: true });
184
- await writeFile(join(appDir, 'app', 'globals.css'), css);
221
+ await mkdir(join(appDir, 'styles'), { recursive: true });
222
+ await writeFile(join(appDir, 'styles', 'globals.css'), css);
185
223
  }
186
224
 
187
225
  /**
188
- * Read the shadcn theme CSS so we can inline it into the layout's
226
+ * Read the @webjsdev/ui theme CSS so we can inline it into the layout's
189
227
  * `<style type="text/tailwindcss">` block. The Tailwind browser runtime
190
228
  * picks up inline `<style type="text/tailwindcss">` content, so the theme
191
229
  * tokens (`--color-primary`, `--color-card`, …) the registry components
@@ -200,7 +238,7 @@ async function readThemeCss() {
200
238
 
201
239
  /**
202
240
  * Fail loudly when the @webjsdev/ui registry is not on disk. The scaffold
203
- * reads component sources, the cn() helper, and the shadcn theme from
241
+ * reads component sources, the cn() helper, and the @webjsdev/ui theme from
204
242
  * UI_REGISTRY_ROOT and weaves them into a generated app/page.ts that
205
243
  * imports `components/ui/button.ts`. If the registry is missing, the
206
244
  * generated app boots to ERR_MODULE_NOT_FOUND on first request, which is
@@ -236,6 +274,11 @@ function assertUiRegistryAvailable() {
236
274
  */
237
275
  export async function scaffoldApp(name, cwd, opts = {}) {
238
276
  const template = opts.template || 'full-stack';
277
+ // A human-friendly display title for the example home page. The npm `name`
278
+ // stays the raw slug (lowercase, hyphenated), but showing a hyphenated slug as
279
+ // a hero title looks unpolished, so title-case it for display ("my-app" ->
280
+ // "My App"). Replace this with your real brand anyway.
281
+ const displayName = name.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
239
282
  // `install` is opt-in at the library level (so tests + programmatic
240
283
  // callers get a side-effect-free scaffold by default). The CLI entry
241
284
  // points (`webjs create` and `npx create-webjs-app`) explicitly set
@@ -251,6 +294,13 @@ export async function scaffoldApp(name, cwd, opts = {}) {
251
294
  }
252
295
  const isApi = template === 'api';
253
296
  const isSaas = template === 'saas';
297
+ // The example gallery ships in every UI scaffold (full-stack AND saas). The
298
+ // copyGallery gate below is !isApi, since only the api template has no UI. saas
299
+ // overwrites db/schema.server.ts with its own schema (which includes the
300
+ // gallery's todos table) and renders the gallery below its auth landing.
301
+ // isFullStack distinguishes the plain full-stack app from saas for the parts
302
+ // that differ (its own home page and the create.js-written schema).
303
+ const isFullStack = !isApi && !isSaas;
254
304
 
255
305
  // Database dialect (#563): sqlite (default) or postgres. Drizzle is the ORM;
256
306
  // the schema/queries/actions are identical across dialects, only db/columns
@@ -381,9 +431,10 @@ export async function scaffoldApp(name, cwd, opts = {}) {
381
431
  // tsserver plugin can't provide); tsserver dedupes by name, so loading
382
432
  // it both ways is a no-op. Standalone, no Lit dependency. Editor-only.
383
433
  '@webjsdev/intellisense': 'latest',
384
- // NOTE: @webjsdev/ui is intentionally NOT pinned. The UI kit is
385
- // shadcn-style copy-in: `webjs ui add <name>` copies component source
386
- // into components/ui/ (they import @webjsdev/core, not the kit), and the
434
+ // NOTE: @webjsdev/ui is intentionally NOT pinned. The UI kit uses a
435
+ // copy-in model (shadcn-compatible conventions): `webjs ui add <name>`
436
+ // copies component source into components/ui/ (they import
437
+ // @webjsdev/core, not the kit), and the
387
438
  // CLI resolves @webjsdev/ui from its own install.
388
439
  },
389
440
  // Dev + start task orchestration (#550). `webjs dev` / `webjs start` read
@@ -478,6 +529,8 @@ export async function scaffoldApp(name, cwd, opts = {}) {
478
529
  '.claude/hooks/guard-branch-context.sh',
479
530
  '.claude/hooks/nudge-uncommitted.sh',
480
531
  '.claude/hooks/require-tests-with-src.sh',
532
+ '.claude/hooks/check-server-imports.sh',
533
+ '.claude/hooks/check-server-imports.mjs',
481
534
  // Gemini CLI config + hooks
482
535
  '.gemini/settings.json',
483
536
  '.gemini/hooks/nudge-uncommitted.sh',
@@ -622,7 +675,7 @@ export const index = (...cols: PgColumn[]) =>
622
675
 
623
676
  // Example schema (dialect-agnostic). Replace the User model with your own.
624
677
  await writeFile(join(appDir, 'db', 'schema.server.ts'), `import { defineRelations } from 'drizzle-orm';
625
- import { table, pk, text, createdAt } from './columns.server.ts';
678
+ import { table, pk, ${isFullStack ? 'uuidPk, ' : ''}text, ${isFullStack ? 'bool, ' : ''}createdAt } from './columns.server.ts';
626
679
 
627
680
  // Example model. Feel free to delete or extend.
628
681
  export const users = table('users', {
@@ -631,10 +684,19 @@ export const users = table('users', {
631
684
  name: text(),
632
685
  createdAt: createdAt(),
633
686
  });
634
-
687
+ ${isFullStack ? `
688
+ // Backs the example-gallery /examples/todo route (modules/todo). Delete it with
689
+ // the gallery when you prune the examples you do not use.
690
+ export const todos = table('todos', {
691
+ id: uuidPk(),
692
+ title: text().notNull(),
693
+ completed: bool().notNull().default(false),
694
+ createdAt: createdAt(),
695
+ });
696
+ ` : ''}
635
697
  // Relations live here (one defineRelations for the whole schema). Empty
636
698
  // for now; add per-model relations as your schema grows.
637
- export const relations = defineRelations({ users }, () => ({}));
699
+ export const relations = defineRelations({ users${isFullStack ? ', todos' : ''} }, () => ({}));
638
700
 
639
701
  // Derived types, never hand-written.
640
702
  export type User = typeof users.$inferSelect;
@@ -667,6 +729,7 @@ function tune<T extends { exec(sql: string): unknown }>(client: T): T {
667
729
 
668
730
  async function open() {
669
731
  if ((globalThis as { Bun?: unknown }).Bun) {
732
+ // @ts-expect-error bun:sqlite is a Bun builtin with no Node typings
670
733
  const { Database } = await import('bun:sqlite');
671
734
  const { drizzle } = await import('drizzle-orm/bun-sqlite');
672
735
  return drizzle({ client: tune(new Database(url)), relations: schema.relations });
@@ -782,6 +845,28 @@ export default cors({
782
845
  await writeFile(join(appDir, 'app', 'api', 'health', 'route.ts'), `export async function GET() {
783
846
  return Response.json({ status: 'ok', timestamp: Date.now() });
784
847
  }
848
+ `);
849
+ // Root API index. The api template has no UI, so \`/\` is a route handler,
850
+ // not a page: it lists the available endpoints instead of returning a bare
851
+ // 404 (friendlier than an empty root for an API-only app).
852
+ await writeFile(join(appDir, 'app', 'route.ts'), `export async function GET(request: Request) {
853
+ const base = new URL(request.url).origin;
854
+ return Response.json({
855
+ name: '${name}',
856
+ endpoints: {
857
+ health: \`\${base}/api/health\`,
858
+ users: \`\${base}/api/users\`,
859
+ },
860
+ // The backend-features showcase (delete app/api/features + its modules to prune).
861
+ features: {
862
+ validate: \`\${base}/api/features/validate\`,
863
+ 'rate-limit': \`\${base}/api/features/rate-limit\`,
864
+ stream: \`\${base}/api/features/stream\`,
865
+ files: \`\${base}/api/features/files\`,
866
+ ws: \`\${base.replace(/^http/, 'ws')}/api/features/ws\`,
867
+ },
868
+ });
869
+ }
785
870
  `);
786
871
  await mkdir(join(appDir, 'modules', 'users', 'actions'), { recursive: true });
787
872
  await mkdir(join(appDir, 'modules', 'users', 'queries'), { recursive: true });
@@ -864,6 +949,13 @@ export type ActionResult<T> =
864
949
  | { success: true; data: T }
865
950
  | { success: false; error: string; status: number };
866
951
  `);
952
+
953
+ // The api backend-features showcase: endpoints under app/api/features/**
954
+ // demonstrating the server-side surface (route() adapter + validation, rate
955
+ // limiting, streaming, file storage, WebSockets + broadcast) plus env
956
+ // validation. The api counterpart of the UI gallery. Prune what you skip.
957
+ const { writeApiGallery } = await import('./api-gallery.js');
958
+ await writeApiGallery(appDir);
867
959
  }
868
960
 
869
961
  if (!isApi) {
@@ -904,7 +996,7 @@ export type ActionResult<T> =
904
996
 
905
997
  // Pre-initialise @webjsdev/ui so the scaffold boots ready for
906
998
  // `webjs ui add <name>`: writes components.json + lib/utils/cn.ts +
907
- // app/globals.css (the shadcn theme).
999
+ // styles/globals.css (the @webjsdev/ui theme).
908
1000
  await writeUiBootstrap(appDir);
909
1001
 
910
1002
  // Copy the standard ui-* component kit the scaffold's example pages
@@ -914,13 +1006,21 @@ export type ActionResult<T> =
914
1006
  'button', 'card', 'alert', 'badge', 'separator', 'label', 'input',
915
1007
  ]);
916
1008
 
917
- // The shadcn theme tokens (`--color-primary`, `--color-card`, …) the
1009
+ // The gallery: idiomatic, densely-commented single-feature demos under
1010
+ // app/features/ plus one whole example app under app/examples/, with logic
1011
+ // in modules/, linked from the home page. Shipped in the default full-stack
1012
+ // scaffold so an agent gains context by browsing real code; prune
1013
+ // per-feature (delete the route + its module) for what the app does not
1014
+ // use. See CONVENTIONS.md "prune what the app does not use".
1015
+ if (!isApi) await copyGallery(appDir);
1016
+
1017
+ // The @webjsdev/ui theme tokens (`--color-primary`, `--color-card`, …) the
918
1018
  // ui-* components consume. We read the registry's themes/index.css at
919
1019
  // create time and inline it into the layout's
920
1020
  // `<style type="text/tailwindcss">` block so the Tailwind browser
921
- // runtime picks it up. Same content also lives at app/globals.css for
1021
+ // runtime picks it up. Same content also lives at styles/globals.css for
922
1022
  // `webjsui` tooling.
923
- const SHADCN_THEME = (await readThemeCss())
1023
+ const UI_THEME = (await readThemeCss())
924
1024
  // Escape backticks + ${} so the CSS survives interpolation into the
925
1025
  // layout's template literal below.
926
1026
  .replace(/\\/g, '\\\\')
@@ -947,7 +1047,7 @@ import '#components/theme-toggle.ts';
947
1047
  *
948
1048
  * Light DOM + Tailwind by default. Design tokens live in :root and are
949
1049
  * mapped into the Tailwind palette via @theme, so classes like
950
- * text-fg, bg-bg-elev, font-serif, duration-fast, text-display all work.
1050
+ * text-foreground, bg-card, font-serif, duration-fast, text-display all work.
951
1051
  *
952
1052
  * Nav + footer links repeat the same class bundle, so they're extracted
953
1053
  * into small JS helpers below. Each helper runs at SSR time inside
@@ -955,7 +1055,7 @@ import '#components/theme-toggle.ts';
955
1055
  */
956
1056
 
957
1057
  const navLink = (href: string, label: string) => html\`
958
- <a href=\${href} class="text-fg-muted no-underline font-medium text-[13px] leading-none tracking-[0.005em] transition-colors duration-fast hover:text-fg">\${label}</a>
1058
+ <a href=\${href} class="text-muted-foreground no-underline font-medium text-[13px] leading-none tracking-[0.005em] transition-colors duration-fast hover:text-foreground">\${label}</a>
959
1059
  \`;
960
1060
 
961
1061
  export default function RootLayout({ children }: { children: unknown }) {
@@ -975,7 +1075,7 @@ export default function RootLayout({ children }: { children: unknown }) {
975
1075
  var el = document.documentElement;
976
1076
  if (t === 'light' || t === 'dark') el.dataset.theme = t;
977
1077
  else delete el.dataset.theme;
978
- // Keep shadcn's .dark class in sync with the effective theme so the
1078
+ // Keep the .dark class the @webjsdev/ui kit uses in sync with the effective theme so the
979
1079
  // copied ui-* components (button, card, etc.) follow light/dark too.
980
1080
  // Dark is the default unless the OS prefers light or 'light' is set.
981
1081
  var dark = t === 'dark' || (t !== 'light' && !mq.matches);
@@ -1010,27 +1110,124 @@ export default function RootLayout({ children }: { children: unknown }) {
1010
1110
  <!--
1011
1111
  Webjs UI theme. Design tokens (--color-primary,
1012
1112
  --color-card, --radius, etc.) the ui-* components consume.
1013
- The same content is also at app/globals.css; we inline it here so
1113
+ The same content is also at styles/globals.css. We inline it here so
1014
1114
  the Tailwind browser runtime resolves the tokens without a build step.
1015
1115
  Edit base palette via the :root / .dark blocks below.
1016
1116
  -->
1017
1117
  <style type="text/tailwindcss">
1018
- ${SHADCN_THEME}
1118
+ ${UI_THEME}
1019
1119
  </style>
1020
1120
  <style type="text/tailwindcss">
1021
- @theme {
1022
- --color-fg: var(--fg);
1023
- --color-fg-muted: var(--fg-muted);
1024
- --color-fg-subtle: var(--fg-subtle);
1025
- --color-bg: var(--bg);
1026
- --color-bg-elev: var(--bg-elev);
1027
- --color-bg-subtle: var(--bg-subtle);
1028
- --color-border: var(--border);
1121
+ /* ONE theme, canonical shadcn-style tokens. The @webjsdev/ui theme above
1122
+ provides the token STRUCTURE and the @theme inline mappings that generate
1123
+ bg-background, text-foreground, bg-card, bg-primary, bg-accent,
1124
+ text-muted-foreground, border-border, ring-ring, and the rest. Here we
1125
+ set those tokens' VALUES to this app's brand palette, so the ui-*
1126
+ components AND the example chrome read ONE source of truth. Any component
1127
+ added later with webjs ui add <name> inherits it automatically. This
1128
+ block is emitted after the ui theme, so these values win on every Tailwind
1129
+ recompile. Dark-first, with light via the theme toggle (data-theme) or the
1130
+ OS. Follows the shadcn model: --primary is the BRAND color (orange, used
1131
+ for primary buttons, links, and emphasis), while --accent stays a NEUTRAL
1132
+ hover tint so the ui kit's outline/ghost/dropdown hover states keep proper
1133
+ contrast. Use bg-primary / text-primary for brand, bg-accent for hovers.
1134
+ Add a new design token the canonical way, a --x variable below plus a
1135
+ --color-x: var(--x) line in the @theme inline block, then use it as bg-x /
1136
+ text-x. Reach for opacity modifiers
1137
+ (bg-primary/10, hover:bg-primary/90, text-muted-foreground/70) before
1138
+ inventing a new token. */
1139
+ :root {
1140
+ --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1141
+ --font-serif: ui-serif, 'Iowan Old Style', Palatino, Georgia, serif;
1142
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1143
+ --header-h: 56px;
1144
+ /* A translucent brand tint, derived from --primary so it tracks
1145
+ light/dark automatically. Used for the logo glow and focus ring. */
1146
+ --primary-tint: color-mix(in oklch, var(--primary) 20%, transparent);
1147
+ }
1148
+ /* dark (the default, and the explicit .dark the toggle sets) */
1149
+ :root, .dark {
1150
+ color-scheme: dark;
1151
+ --background: oklch(0.14 0.01 55);
1152
+ --foreground: oklch(0.96 0.015 60);
1153
+ --card: oklch(0.18 0.01 55);
1154
+ --card-foreground: oklch(0.96 0.015 60);
1155
+ --popover: oklch(0.18 0.01 55);
1156
+ --popover-foreground: oklch(0.96 0.015 60);
1157
+ /* --primary is the orange BRAND color (shadcn model: primary = brand). */
1158
+ --primary: oklch(0.7 0.16 52);
1159
+ --primary-foreground: oklch(0.17 0.02 52);
1160
+ --secondary: oklch(0.22 0.01 55);
1161
+ --secondary-foreground: oklch(0.96 0.015 60);
1162
+ --muted: oklch(0.16 0.01 55);
1163
+ --muted-foreground: oklch(0.72 0.02 60);
1164
+ /* --accent stays a NEUTRAL hover tint (shadcn model), so the ui kit's
1165
+ outline/ghost/dropdown hover states keep proper contrast. */
1166
+ --accent: oklch(0.27 0.008 55);
1167
+ --accent-foreground: oklch(0.96 0.015 60);
1168
+ --border: oklch(0.26 0.012 55 / 0.9);
1169
+ --border-strong: oklch(0.38 0.012 55 / 0.9);
1170
+ --input: oklch(0.26 0.012 55 / 0.9);
1171
+ --ring: oklch(0.7 0.16 52);
1172
+ --logo-from: oklch(0.8 0.16 58);
1173
+ --logo-to: oklch(0.62 0.18 44);
1174
+ }
1175
+ /* light (explicit via the toggle) */
1176
+ :root[data-theme='light'] {
1177
+ color-scheme: light;
1178
+ --background: oklch(0.985 0.008 80);
1179
+ --foreground: oklch(0.18 0.015 60);
1180
+ --card: oklch(1 0 0);
1181
+ --card-foreground: oklch(0.18 0.015 60);
1182
+ --popover: oklch(1 0 0);
1183
+ --popover-foreground: oklch(0.18 0.015 60);
1184
+ --primary: oklch(0.54 0.16 52);
1185
+ --primary-foreground: oklch(1 0 0);
1186
+ --secondary: oklch(0.96 0.008 80);
1187
+ --secondary-foreground: oklch(0.18 0.015 60);
1188
+ --muted: oklch(0.96 0.008 80);
1189
+ --muted-foreground: oklch(0.42 0.02 65);
1190
+ --accent: oklch(0.955 0.006 80);
1191
+ --accent-foreground: oklch(0.18 0.015 60);
1192
+ --border: oklch(0.88 0.01 75 / 0.95);
1193
+ --border-strong: oklch(0.78 0.01 75 / 0.95);
1194
+ --input: oklch(0.88 0.01 75 / 0.95);
1195
+ --ring: oklch(0.54 0.16 52);
1196
+ --logo-from: oklch(0.63 0.17 50);
1197
+ --logo-to: oklch(0.44 0.11 52);
1198
+ }
1199
+ /* light (OS preference, when the user has made no explicit choice) */
1200
+ @media (prefers-color-scheme: light) {
1201
+ :root:not(.dark):not([data-theme='dark']) {
1202
+ color-scheme: light;
1203
+ --background: oklch(0.985 0.008 80);
1204
+ --foreground: oklch(0.18 0.015 60);
1205
+ --card: oklch(1 0 0);
1206
+ --card-foreground: oklch(0.18 0.015 60);
1207
+ --popover: oklch(1 0 0);
1208
+ --popover-foreground: oklch(0.18 0.015 60);
1209
+ --primary: oklch(0.54 0.16 52);
1210
+ --primary-foreground: oklch(1 0 0);
1211
+ --secondary: oklch(0.96 0.008 80);
1212
+ --secondary-foreground: oklch(0.18 0.015 60);
1213
+ --muted: oklch(0.96 0.008 80);
1214
+ --muted-foreground: oklch(0.42 0.02 65);
1215
+ --accent: oklch(0.955 0.006 80);
1216
+ --accent-foreground: oklch(0.18 0.015 60);
1217
+ --border: oklch(0.88 0.01 75 / 0.95);
1218
+ --border-strong: oklch(0.78 0.01 75 / 0.95);
1219
+ --input: oklch(0.88 0.01 75 / 0.95);
1220
+ --ring: oklch(0.54 0.16 52);
1221
+ --logo-from: oklch(0.63 0.17 50);
1222
+ --logo-to: oklch(0.44 0.11 52);
1223
+ }
1224
+ }
1225
+ @theme inline {
1226
+ /* Only tokens the @webjsdev/ui theme does not already map live here. It
1227
+ already maps --color-background/foreground/card/primary/secondary/
1228
+ muted/accent/border/input/ring/destructive. */
1029
1229
  --color-border-strong: var(--border-strong);
1030
- --color-accent: var(--accent);
1031
- --color-accent-hover: var(--accent-hover);
1032
- --color-accent-fg: var(--accent-fg);
1033
- --color-accent-tint: var(--accent-tint);
1230
+ --color-primary-tint: var(--primary-tint);
1034
1231
  --font-sans: var(--font-sans);
1035
1232
  --font-serif: var(--font-serif);
1036
1233
  --font-mono: var(--font-mono);
@@ -1043,71 +1240,21 @@ ${SHADCN_THEME}
1043
1240
  }
1044
1241
  </style>
1045
1242
  <style>
1046
- :root {
1047
- color-scheme: light dark;
1048
- /* ---------- dark (default) ---------- */
1049
- --fg: oklch(0.96 0.015 60);
1050
- --fg-muted: oklch(0.72 0.02 60);
1051
- --fg-subtle: oklch(0.55 0.02 60);
1052
- --bg: oklch(0.14 0.01 55);
1053
- --bg-elev: oklch(0.18 0.01 55);
1054
- --bg-subtle: oklch(0.16 0.01 55);
1055
- --border: oklch(0.26 0.012 55 / 0.9);
1056
- --border-strong: oklch(0.38 0.012 55 / 0.9);
1057
- --accent: oklch(0.78 0.14 55);
1058
- --accent-hover: oklch(0.85 0.14 55);
1059
- --accent-fg: oklch(0.15 0.01 55);
1060
- --accent-tint: oklch(0.78 0.14 55 / 0.14);
1061
- --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1062
- --font-serif: ui-serif, 'Iowan Old Style', Palatino, Georgia, serif;
1063
- --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1064
- }
1065
- :root[data-theme='light'] {
1066
- --fg: oklch(0.18 0.015 60);
1067
- --fg-muted: oklch(0.42 0.02 65);
1068
- --fg-subtle: oklch(0.62 0.015 70);
1069
- --bg: oklch(0.985 0.008 80);
1070
- --bg-elev: oklch(1 0 0);
1071
- --bg-subtle: oklch(0.96 0.008 80);
1072
- --border: oklch(0.88 0.01 75 / 0.95);
1073
- --border-strong: oklch(0.78 0.01 75 / 0.95);
1074
- --accent: oklch(0.58 0.15 55);
1075
- --accent-hover: oklch(0.5 0.15 55);
1076
- --accent-fg: oklch(1 0 0);
1077
- --accent-tint: oklch(0.58 0.15 55 / 0.1);
1078
- }
1079
- @media (prefers-color-scheme: light) {
1080
- :root:not([data-theme='dark']) {
1081
- --fg: oklch(0.18 0.015 60);
1082
- --fg-muted: oklch(0.42 0.02 65);
1083
- --fg-subtle: oklch(0.62 0.015 70);
1084
- --bg: oklch(0.985 0.008 80);
1085
- --bg-elev: oklch(1 0 0);
1086
- --bg-subtle: oklch(0.96 0.008 80);
1087
- --border: oklch(0.88 0.01 75 / 0.95);
1088
- --border-strong: oklch(0.78 0.01 75 / 0.95);
1089
- --accent: oklch(0.58 0.15 55);
1090
- --accent-hover: oklch(0.5 0.15 55);
1091
- --accent-fg: oklch(1 0 0);
1092
- --accent-tint: oklch(0.58 0.15 55 / 0.1);
1093
- }
1094
- }
1095
- /* Body + pseudo-elements utility classes can't reach. */
1243
+ /* Base styles utility classes can't reach. */
1096
1244
  html, body { margin: 0; }
1097
- :root { --header-h: 56px; } /* fixed-header offset, kept exact by the script above */
1098
1245
  body {
1099
1246
  padding-top: var(--header-h);
1100
- background: var(--bg);
1101
- color: var(--fg);
1247
+ background: var(--background);
1248
+ color: var(--foreground);
1102
1249
  font: 16px/1.65 var(--font-sans);
1103
1250
  -webkit-font-smoothing: antialiased;
1104
1251
  }
1105
- ::selection { background: var(--accent-tint); color: var(--fg); }
1252
+ ::selection { background: color-mix(in oklch, var(--primary) 22%, transparent); color: var(--foreground); }
1106
1253
  </style>
1107
1254
 
1108
- <header class="fixed inset-x-0 top-0 z-20 flex items-center gap-6 px-4 sm:px-6 py-3 border-b border-border bg-[color-mix(in_oklch,var(--bg)_75%,transparent)] backdrop-blur-[18px]">
1109
- <a href="/" class="mr-auto inline-flex items-center gap-2 no-underline text-fg font-semibold text-[15px] leading-none tracking-tight">
1110
- <span>${name}</span>
1255
+ <header class="fixed inset-x-0 top-0 z-20 flex items-center gap-6 px-4 sm:px-6 py-3 border-b border-border bg-[color-mix(in_oklch,var(--background)_75%,transparent)] backdrop-blur-[18px]">
1256
+ <a href="/" class="mr-auto inline-flex items-center gap-2 no-underline text-foreground font-semibold text-[15px] leading-none tracking-tight">
1257
+ <span>${displayName}</span>
1111
1258
  </a>
1112
1259
  <nav class="flex gap-4 items-center">
1113
1260
  <!-- Example nav. Replace with the real navigation for your app. -->
@@ -1123,13 +1270,30 @@ ${SHADCN_THEME}
1123
1270
  drop the cap and mx-auto for an edge-to-edge layout. A wide layout left
1124
1271
  inside the 760px reading column overflows into a horizontal scrollbar.
1125
1272
  -->
1126
- <main class="block max-w-[760px] mx-auto px-4 sm:px-6 pt-[72px] pb-12 min-h-screen">
1127
- \${children}
1128
- </main>
1273
+ <div class="flex flex-col min-h-[calc(100dvh-var(--header-h))]">
1274
+ <main class="flex-1 w-full max-w-[760px] mx-auto px-4 sm:px-6 pt-[72px] pb-12">
1275
+ \${children}
1276
+ </main>
1277
+ <!-- "Built with webjs" attribution. Keep it or replace it with your own
1278
+ footer; the gradient mark uses the --logo-from/--logo-to tokens. -->
1279
+ <footer class="border-t border-border">
1280
+ <div class="max-w-[760px] mx-auto px-4 sm:px-6 py-6 flex items-center justify-center">
1281
+ <a href="https://webjs.dev" class="inline-flex items-center gap-2 no-underline text-sm text-muted-foreground hover:text-foreground transition-colors">
1282
+ <span>Built with</span>
1283
+ <span class="w-[18px] h-[18px] rounded-[6px] bg-gradient-to-br from-[var(--logo-from)] to-[var(--logo-to)] shadow-[0_2px_10px_var(--primary-tint)]"></span>
1284
+ <span class="font-semibold text-foreground">webjs</span>
1285
+ </a>
1286
+ </div>
1287
+ </footer>
1288
+ </div>
1129
1289
  \`;
1130
1290
  }
1131
1291
  `);
1132
1292
 
1293
+ // The gallery-index home (links every feature demo + the example app) ships
1294
+ // only in the full-stack scaffold, which is the only one that ships the
1295
+ // gallery. saas gets its own landing below.
1296
+ if (isFullStack) {
1133
1297
  await writeFile(join(appDir, 'app', 'page.ts'), `// webjs-scaffold-placeholder. This is the example homepage. Replace it with your app's real page, then delete this line. webjs check fails while the marker remains.
1134
1298
  import { html } from '@webjsdev/core';
1135
1299
  import { rubric, displayH1, accentLink } from '#lib/utils/ui.ts';
@@ -1140,69 +1304,189 @@ import {
1140
1304
  cardHeaderClass,
1141
1305
  cardTitleClass,
1142
1306
  cardDescriptionClass,
1143
- cardContentClass,
1144
1307
  } from '#components/ui/card.ts';
1145
- import { alertClass, alertTitleClass, alertDescriptionClass } from '#components/ui/alert.ts';
1146
- import { separatorClass } from '#components/ui/separator.ts';
1147
1308
 
1148
1309
  export const metadata = {
1149
- title: '${name}: built with webjs',
1310
+ title: '${displayName}: built with webjs',
1150
1311
  };
1151
1312
 
1313
+ // Two kinds of reference the scaffold ships. FEATURES are single-concept demos
1314
+ // (one webjs feature each, under app/features/, logic in modules/). EXAMPLES are
1315
+ // whole apps that compose several features (under app/examples/). Prune what you
1316
+ // do not use (delete the route AND its modules/<name>), then reshape this page.
1317
+ const features = [
1318
+ { href: '/features/routing', title: 'Routing', blurb: 'A static route plus a dynamic [id] segment that reads params. The file-based router in miniature.' },
1319
+ { href: '/features/components', title: 'Components', blurb: 'The WebComponent factory, reactive props, instance signals, and slot projection in light DOM.' },
1320
+ { href: '/features/server-actions', title: 'Server actions', blurb: 'A use-server RPC action next to a server-only .server.ts utility, and why the boundary matters.' },
1321
+ { href: '/features/optimistic-ui', title: 'Optimistic UI', blurb: 'The imperative optimistic(signal, value, action) flip: instant update, automatic rollback on failure.' },
1322
+ { href: '/features/async-render', title: 'Async render', blurb: 'A component that awaits server data in async render(), so the resolved value is in the first paint.' },
1323
+ { href: '/features/directives', title: 'Directives', blurb: 'The lit-html directive set: repeat for keyed lists, watch(signal) for a fine-grained node swap.' },
1324
+ { href: '/features/route-handler', title: 'Route handlers', blurb: 'A server-only route.ts HTTP endpoint returning JSON, the webjs equivalent of a Next route handler.' },
1325
+ { href: '/features/forms', title: 'Forms', blurb: 'A no-JS progressive-enhancement form posting to the page action, with server-side validation errors.' },
1326
+ { href: '/features/metadata', title: 'Metadata', blurb: 'Static metadata plus generateMetadata(ctx), which reads the request to compute the title and Open Graph tags.' },
1327
+ { href: '/features/caching', title: 'Caching', blurb: 'export const revalidate caches the page HTML per URL, with the safety rule for when a shared cache is allowed.' },
1328
+ { href: '/features/env', title: 'Env vars', blurb: 'The server-only vs WEBJS_PUBLIC_ boundary, read during SSR so secrets never reach the browser.' },
1329
+ { href: '/features/client-router', title: 'Client router', blurb: 'Automatic soft navigation: fragment-only fetches, hover prefetch, scroll restore, and graceful no-JS fallback.' },
1330
+ { href: '/features/service-worker', title: 'Service worker', blurb: 'The opt-in offline enhancement, registered from a browser-only lifecycle hook (never a page or layout).' },
1331
+ { href: '/features/websockets', title: 'WebSockets', blurb: 'A WS(ws, req) route endpoint plus the connectWS() client, echoing messages over a live socket.' },
1332
+ { href: '/features/broadcast', title: 'Broadcast', blurb: 'Fan a message out to every connected client on a WebSocket path, so all open tabs stay in sync.' },
1333
+ { href: '/features/rate-limit', title: 'Rate limiting', blurb: 'The rateLimit() middleware scoped to one endpoint, returning a 429 with Retry-After past the window.' },
1334
+ { href: '/features/file-storage', title: 'File storage', blurb: 'A no-JS multipart upload streamed into the FileStore, then served back through a streaming route.' },
1335
+ ];
1336
+ const examples = [
1337
+ { href: '/examples/todo', title: 'Optimistic todo', blurb: 'A whole app composing several features: the declarative optimistic() list API, progressive-enhancement forms, accessible labels, the modules split, and SQLite.' },
1338
+ ];
1339
+
1340
+ const galleryCard = (item: { href: string; title: string; blurb: string }) => html\`
1341
+ <a href=\${item.href} class="block no-underline">
1342
+ <div class="\${cardClass()} h-full transition-colors hover:border-border-strong">
1343
+ <div class=\${cardHeaderClass()}>
1344
+ <h3 class=\${cardTitleClass()}>\${item.title}</h3>
1345
+ <p class=\${cardDescriptionClass()}>\${item.blurb}</p>
1346
+ </div>
1347
+ </div>
1348
+ </a>
1349
+ \`;
1350
+
1152
1351
  export default function Home() {
1153
1352
  return html\`
1154
- <section class="mb-18">
1353
+ <section class="mb-14">
1155
1354
  \${rubric('welcome')}
1156
- \${displayH1(html\`Hello from <span class="text-accent italic">${name}</span>.\`)}
1157
- <p class="text-lede leading-[1.5] text-fg-muted max-w-[56ch] m-0 mb-6">
1158
- Edit <code class="font-mono text-[0.9em]">app/page.ts</code> to get started.
1159
- Run \${accentLink('#', 'webjs test')} to run tests and
1160
- \${accentLink('#', 'webjs check')} to catch correctness issues.
1355
+ \${displayH1(html\`Hello from <span class="text-primary italic">${displayName}</span>.\`)}
1356
+ <p class="text-lede leading-[1.5] text-muted-foreground max-w-[56ch] m-0 mb-6">
1357
+ This scaffold ships a gallery below: single-feature demos and one whole
1358
+ example app, all small, idiomatic, and heavily commented. Browse them for
1359
+ context, then replace this page with your own. See
1360
+ \${accentLink('https://docs.webjs.dev', 'the docs')} for the full reference.
1161
1361
  </p>
1162
1362
  <div class="flex gap-3 items-center">
1163
- <button class=\${buttonClass()}>Get started</button>
1164
- <button class=\${buttonClass({ variant: 'outline' })}>View docs</button>
1363
+ <a href="/examples/todo" class=\${buttonClass()}>Open the todo app</a>
1165
1364
  <span class=\${badgeClass({ variant: 'secondary' })}>v0.1</span>
1166
1365
  </div>
1167
1366
  </section>
1168
1367
 
1169
- <div class=\${cardClass()} style="margin-bottom: 3rem">
1170
- <div class=\${cardHeaderClass()}>
1171
- <h3 class=\${cardTitleClass()}>Web Components + Server Actions</h3>
1172
- <p class=\${cardDescriptionClass()}>
1173
- Drop a custom element anywhere. Call a server action like a local
1174
- function. webjs rewrites the import into a typed RPC stub.
1175
- </p>
1368
+ <section class="mb-12">
1369
+ <h2 class="font-serif text-[1.6rem] tracking-[-0.02em] font-bold m-0 mb-1">Features</h2>
1370
+ <p class="text-muted-foreground text-sm m-0 mb-5">
1371
+ One webjs concept each, under <code class="font-mono text-[0.9em]">app/features/</code>
1372
+ with logic in <code class="font-mono text-[0.9em]">modules/</code>. Delete the ones you do not need.
1373
+ </p>
1374
+ <div class="grid gap-4 sm:grid-cols-2">
1375
+ \${features.map(galleryCard)}
1176
1376
  </div>
1177
- <div class=\${cardContentClass()}>
1178
- <div class=\${alertClass()}>
1179
- <h5 class=\${alertTitleClass()}>AI-first component kit included</h5>
1180
- <div class=\${alertDescriptionClass()}>
1181
- button, card, alert, badge, separator, label, input are already
1182
- in <code class="font-mono text-[0.9em]">components/ui/</code> as
1183
- class-helper functions you call from a native element. Add more
1184
- with <code class="font-mono text-[0.9em]">webjs ui add &lt;name&gt;</code>.
1185
- </div>
1186
- </div>
1377
+ </section>
1378
+
1379
+ <section>
1380
+ <h2 class="font-serif text-[1.6rem] tracking-[-0.02em] font-bold m-0 mb-1">Example apps</h2>
1381
+ <p class="text-muted-foreground text-sm m-0 mb-5">
1382
+ Whole apps that compose several features, under <code class="font-mono text-[0.9em]">app/examples/</code>.
1383
+ </p>
1384
+ <div class="grid gap-4 sm:grid-cols-2">
1385
+ \${examples.map(galleryCard)}
1386
+ </div>
1387
+ </section>
1388
+ \`;
1389
+ }
1390
+ `);
1391
+ } else {
1392
+ // saas home: the auth landing (hero + login/signup/dashboard) stays the
1393
+ // headline, and the webjs feature gallery sits BELOW it, so a saas app is
1394
+ // also a learning surface. Keep the `features` list in sync with the
1395
+ // full-stack home above (both ship the same gallery).
1396
+ await writeFile(join(appDir, 'app', 'page.ts'), `// webjs-scaffold-placeholder. This is the example homepage. Replace it with your app's real landing page, then delete this line. webjs check fails while the marker remains.
1397
+ import { html } from '@webjsdev/core';
1398
+ import { rubric, displayH1, accentLink } from '#lib/utils/ui.ts';
1399
+ import {
1400
+ cardClass,
1401
+ cardHeaderClass,
1402
+ cardTitleClass,
1403
+ cardDescriptionClass,
1404
+ } from '#components/ui/card.ts';
1405
+
1406
+ export const metadata = {
1407
+ title: '${displayName}: built with webjs',
1408
+ };
1409
+
1410
+ // The webjs feature gallery, shown below the auth landing. FEATURES are
1411
+ // single-concept demos (under app/features/, logic in modules/); EXAMPLES are
1412
+ // whole apps (under app/examples/). Prune what you do not use (delete the route
1413
+ // AND its modules/<name>). Keep this list in sync with the full-stack home.
1414
+ const features = [
1415
+ { href: '/features/routing', title: 'Routing', blurb: 'A static route plus a dynamic [id] segment that reads params. The file-based router in miniature.' },
1416
+ { href: '/features/components', title: 'Components', blurb: 'The WebComponent factory, reactive props, instance signals, and slot projection in light DOM.' },
1417
+ { href: '/features/server-actions', title: 'Server actions', blurb: 'A use-server RPC action next to a server-only .server.ts utility, and why the boundary matters.' },
1418
+ { href: '/features/optimistic-ui', title: 'Optimistic UI', blurb: 'The imperative optimistic(signal, value, action) flip: instant update, automatic rollback on failure.' },
1419
+ { href: '/features/async-render', title: 'Async render', blurb: 'A component that awaits server data in async render(), so the resolved value is in the first paint.' },
1420
+ { href: '/features/directives', title: 'Directives', blurb: 'The lit-html directive set: repeat for keyed lists, watch(signal) for a fine-grained node swap.' },
1421
+ { href: '/features/route-handler', title: 'Route handlers', blurb: 'A server-only route.ts HTTP endpoint returning JSON, the webjs equivalent of a Next route handler.' },
1422
+ { href: '/features/forms', title: 'Forms', blurb: 'A no-JS progressive-enhancement form posting to the page action, with server-side validation errors.' },
1423
+ { href: '/features/metadata', title: 'Metadata', blurb: 'Static metadata plus generateMetadata(ctx), which reads the request to compute the title and Open Graph tags.' },
1424
+ { href: '/features/caching', title: 'Caching', blurb: 'export const revalidate caches the page HTML per URL, with the safety rule for when a shared cache is allowed.' },
1425
+ { href: '/features/env', title: 'Env vars', blurb: 'The server-only vs WEBJS_PUBLIC_ boundary, read during SSR so secrets never reach the browser.' },
1426
+ { href: '/features/client-router', title: 'Client router', blurb: 'Automatic soft navigation: fragment-only fetches, hover prefetch, scroll restore, and graceful no-JS fallback.' },
1427
+ { href: '/features/service-worker', title: 'Service worker', blurb: 'The opt-in offline enhancement, registered from a browser-only lifecycle hook (never a page or layout).' },
1428
+ { href: '/features/websockets', title: 'WebSockets', blurb: 'A WS(ws, req) route endpoint plus the connectWS() client, echoing messages over a live socket.' },
1429
+ { href: '/features/broadcast', title: 'Broadcast', blurb: 'Fan a message out to every connected client on a WebSocket path, so all open tabs stay in sync.' },
1430
+ { href: '/features/rate-limit', title: 'Rate limiting', blurb: 'The rateLimit() middleware scoped to one endpoint, returning a 429 with Retry-After past the window.' },
1431
+ { href: '/features/file-storage', title: 'File storage', blurb: 'A no-JS multipart upload streamed into the FileStore, then served back through a streaming route.' },
1432
+ ];
1433
+ const examples = [
1434
+ { href: '/examples/todo', title: 'Optimistic todo', blurb: 'A whole app composing several features: the declarative optimistic() list API, progressive-enhancement forms, accessible labels, the modules split, and SQLite.' },
1435
+ ];
1436
+
1437
+ const galleryCard = (item: { href: string; title: string; blurb: string }) => html\`
1438
+ <a href=\${item.href} class="block no-underline">
1439
+ <div class="\${cardClass()} h-full transition-colors hover:border-border-strong">
1440
+ <div class=\${cardHeaderClass()}>
1441
+ <h3 class=\${cardTitleClass()}>\${item.title}</h3>
1442
+ <p class=\${cardDescriptionClass()}>\${item.blurb}</p>
1187
1443
  </div>
1188
1444
  </div>
1445
+ </a>
1446
+ \`;
1189
1447
 
1190
- <div class=\${separatorClass()} style="margin: 2.5rem 0"></div>
1191
-
1192
- <section class="mt-10">
1193
- <h2 class="font-serif text-[1.6rem] tracking-[-0.02em] font-bold m-0 mb-2">Light DOM + Tailwind</h2>
1194
- <p class="text-fg-muted text-sm m-0 mb-4">
1195
- Components render into light DOM by default. Tailwind utility classes
1196
- apply directly. Set <code class="font-mono text-[0.9em]">static shadow = true</code>
1197
- on a component when you need scoped styles or third-party-embed
1198
- isolation. &lt;slot&gt; projection works identically in both modes,
1199
- including named slots, fallback content, and the full
1200
- assignedNodes / slotchange API.
1448
+ export default function Home() {
1449
+ return html\`
1450
+ <section class="mb-14">
1451
+ \${rubric('welcome')}
1452
+ \${displayH1(html\`Hello from <span class="text-primary italic">${displayName}</span>.\`)}
1453
+ <p class="text-lede leading-[1.5] text-muted-foreground max-w-[56ch] m-0 mb-6">
1454
+ The saas starter: email and password auth, a protected dashboard, and a
1455
+ User model, all wired up. Replace this hero with your product's landing;
1456
+ the webjs feature gallery below is reference, prune what you do not need.
1457
+ See \${accentLink('https://docs.webjs.dev', 'the docs')} for the full reference.
1201
1458
  </p>
1459
+ <div class="flex gap-3 items-center">
1460
+ <a href="/login" class="inline-flex items-center px-4 py-2 rounded-xl bg-primary text-primary-foreground font-semibold text-sm no-underline transition-all hover:bg-primary/90 active:scale-[0.97]">Log in</a>
1461
+ <a href="/signup" class="text-primary no-underline font-medium text-sm">Create an account</a>
1462
+ <a href="/dashboard" class="text-muted-foreground no-underline font-medium text-sm hover:text-foreground transition-colors">Dashboard</a>
1463
+ </div>
1464
+ </section>
1465
+
1466
+ <section class="mb-12">
1467
+ <h2 class="font-serif text-[1.6rem] tracking-[-0.02em] font-bold m-0 mb-1">Features</h2>
1468
+ <p class="text-muted-foreground text-sm m-0 mb-5">
1469
+ One webjs concept each, under <code class="font-mono text-[0.9em]">app/features/</code>
1470
+ with logic in <code class="font-mono text-[0.9em]">modules/</code>. Delete the ones you do not need.
1471
+ </p>
1472
+ <div class="grid gap-4 sm:grid-cols-2">
1473
+ \${features.map(galleryCard)}
1474
+ </div>
1475
+ </section>
1476
+
1477
+ <section>
1478
+ <h2 class="font-serif text-[1.6rem] tracking-[-0.02em] font-bold m-0 mb-1">Example apps</h2>
1479
+ <p class="text-muted-foreground text-sm m-0 mb-5">
1480
+ Whole apps that compose several features, under <code class="font-mono text-[0.9em]">app/examples/</code>.
1481
+ </p>
1482
+ <div class="grid gap-4 sm:grid-cols-2">
1483
+ \${examples.map(galleryCard)}
1484
+ </div>
1202
1485
  </section>
1203
1486
  \`;
1204
1487
  }
1205
1488
  `);
1489
+ }
1206
1490
 
1207
1491
  // AGENTS.md is copied via the `templateFiles` loop above, from
1208
1492
  // `packages/cli/templates/AGENTS.md` with `{{APP_NAME}}` substitution.
@@ -1245,7 +1529,7 @@ export class ThemeToggle extends WebComponent {
1245
1529
  const el = document.documentElement;
1246
1530
  if (next === 'system') delete el.dataset.theme;
1247
1531
  else el.dataset.theme = next;
1248
- // Keep shadcn's .dark class in sync so the ui-* components follow the theme.
1532
+ // Keep the .dark class the @webjsdev/ui kit uses in sync so the ui-* components follow the theme.
1249
1533
  const dark = next === 'dark'
1250
1534
  || (next === 'system' && !window.matchMedia('(prefers-color-scheme: light)').matches);
1251
1535
  el.classList.toggle('dark', dark);
@@ -1257,7 +1541,7 @@ export class ThemeToggle extends WebComponent {
1257
1541
  const icon = t === 'light' ? ICONS.sun : t === 'dark' ? ICONS.moon : ICONS.system;
1258
1542
  return html\`
1259
1543
  <button
1260
- class="inline-flex items-center justify-center w-9 h-9 p-0 border border-border rounded-full bg-bg-elev text-fg-muted cursor-pointer transition-all duration-150 hover:text-fg hover:border-border-strong active:scale-[0.94] focus-visible:outline-none focus-visible:border-accent focus-visible:ring-[3px] focus-visible:ring-accent-tint"
1544
+ class="inline-flex items-center justify-center w-9 h-9 p-0 border border-border rounded-full bg-card text-muted-foreground cursor-pointer transition-all duration-150 hover:text-foreground hover:border-border-strong active:scale-[0.94] focus-visible:outline-none focus-visible:border-primary focus-visible:ring-[3px] focus-visible:ring-primary-tint"
1261
1545
  @click=\${() => this.cycle()}
1262
1546
  aria-label="Cycle theme (currently \${label})"
1263
1547
  title="Theme: \${label.toLowerCase()}"
@@ -1307,7 +1591,7 @@ ThemeToggle.register('theme-toggle');
1307
1591
  app/layout.ts, page.ts, login/, signup/
1308
1592
  app/dashboard/{page,settings,middleware}.ts ← protected
1309
1593
  app/api/auth/[...path]/route.ts ← auth API
1310
- app/globals.css ← @webjsdev/ui theme tokens
1594
+ styles/globals.css ← @webjsdev/ui theme tokens
1311
1595
  components.json ← preconfigured for \`webjs ui add\`
1312
1596
  components/ui/{button,card,alert,badge,separator,label,input,
1313
1597
  dialog,form,field,switch,checkbox}.ts
@@ -1320,15 +1604,21 @@ ThemeToggle.register('theme-toggle');
1320
1604
  `);
1321
1605
  } else {
1322
1606
  console.log(` ${name}/
1323
- app/layout.ts, page.ts ← light DOM + Tailwind + @theme tokens
1324
- app/globals.css ← @webjsdev/ui theme tokens
1607
+ app/layout.ts, page.ts ← home links to the gallery
1608
+ app/features/{routing,components,server-actions,optimistic-ui,
1609
+ async-render,directives,route-handler}/
1610
+ ← single-feature demos
1611
+ app/examples/todo/ ← one whole example app (composes features)
1612
+ styles/globals.css ← @webjsdev/ui theme tokens
1325
1613
  components.json ← preconfigured for \`webjs ui add\`
1326
1614
  components/ui/{button,card,alert,badge,separator,label,input}.ts
1327
1615
  components/theme-toggle.ts ← light DOM web component
1328
1616
  lib/utils/cn.ts ← cn() helper for ui-* components
1329
1617
  lib/utils/ui.ts ← Tailwind class-bundle helpers
1330
1618
  public/tailwind-browser.js ← Tailwind runtime
1331
- modules/
1619
+ modules/{components,server-actions,optimistic-ui,async-render,
1620
+ directives,todo}/ ← feature + example logic (prune what you skip)
1621
+ db/{schema,columns,connection}.server.ts ← Drizzle (User + Todo)
1332
1622
  CONVENTIONS.md, AGENTS.md, CLAUDE.md
1333
1623
  `);
1334
1624
  }
@@ -1345,14 +1635,17 @@ ThemeToggle.register('theme-toggle');
1345
1635
  ✓ Convention validation via \`webjs check\`
1346
1636
 
1347
1637
  For AI agents, read this before editing scaffolded files:
1348
- • The example layout / page / components / schema are REFERENCE ONLY.
1349
- Replace them with the app the user actually asked for. Don't ship
1350
- the scaffold's example User model or "Hello from …" page as the
1351
- final product.
1352
- • This fresh app intentionally FAILS \`webjs check\` with two
1353
- no-scaffold-placeholder violations (app/page.ts, app/layout.ts).
1354
- That is the signal to replace the example content. Delete each
1355
- marker comment line as you do, and the check goes green.
1638
+ • The example layout, home page, the gallery under app/features/ and
1639
+ app/examples/ (+ their modules) and schema are REFERENCE ONLY. Replace
1640
+ them with the app the user asked for, and prune the routes you do not
1641
+ use (delete the app/features/<name> or app/examples/<name> route AND
1642
+ its modules/<name>). Don't ship the scaffold's examples as the final
1643
+ product.
1644
+ • This fresh app intentionally FAILS \`webjs check\` with
1645
+ no-scaffold-placeholder violations on the example content (the home
1646
+ page, the layout, and each gallery route). That is the signal to
1647
+ replace or prune each. Delete the marker comment line as you do, and
1648
+ the check goes green.
1356
1649
  • Use Drizzle + SQLite for app data. It's already wired up. Define
1357
1650
  real models in db/schema.server.ts, then run \`webjs db generate\`
1358
1651
  and \`webjs db migrate\`. NEVER store app data in JSON files,
@@ -1362,7 +1655,7 @@ For AI agents, read this before editing scaffolded files:
1362
1655
  the closest scaffold and adapt it.
1363
1656
  • Read AGENTS.md + CONVENTIONS.md in the new project before writing
1364
1657
  any code. They are the contract.
1365
- • Need more detail? Full hosted docs are at https://docs.webjs.com
1658
+ • Need more detail? Full hosted docs are at https://docs.webjs.dev
1366
1659
  (every API, directive, recipe, and deployment guide).
1367
1660
  `);
1368
1661
 
@@ -1390,13 +1683,14 @@ For AI agents, read this before editing scaffolded files:
1390
1683
  // templates ship with @webjsdev/ui already initialised; the api
1391
1684
  // template has no UI but may add one later.
1392
1685
  const installSegment = installed ? '' : `${pm} install && `;
1393
- // The saas example queries the users table on its first request (auth), so it
1394
- // needs a migration authored first: `db:generate` writes it and the
1395
- // `webjs.dev.before` migrate applies it on `run dev` (Drizzle splits Prisma's
1396
- // `migrate dev` into generate-then-migrate). The full-stack / api examples do
1397
- // not query the db on first paint, so they boot with just `run dev`; once you
1398
- // add a db route, `db:generate` then `run dev` is the loop (dev auto-migrates).
1399
- const dbSegment = isSaas ? `${pm} run db:generate && ` : '';
1686
+ // Some examples query the db on their first request, so a migration must be
1687
+ // authored first: `db:generate` writes it and the `webjs.dev.before` migrate
1688
+ // applies it on `run dev` (Drizzle splits Prisma's `migrate dev` into
1689
+ // generate-then-migrate). The saas example queries users (auth); the
1690
+ // full-stack scaffold ships the gallery's /examples/todo route (queries todos).
1691
+ // The api template has no such first-request query, so it boots with just
1692
+ // `run dev`; once you add a db route, `db:generate` then `run dev` is the loop.
1693
+ const dbSegment = isApi ? '' : `${pm} run db:generate && `;
1400
1694
  const runCommand = `cd ${name} && ${installSegment}${dbSegment}${pm} run dev`;
1401
1695
  // Postgres needs a reachable DATABASE_URL before any migrate (sqlite uses a
1402
1696
  // local file with no .env). Point it at a running database; `dev` / `start`