@webjsdev/cli 0.10.30 → 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 (59) hide show
  1. package/bin/webjs.js +1 -1
  2. package/lib/api-gallery.js +229 -0
  3. package/lib/create.js +451 -158
  4. package/lib/saas-template.js +39 -15
  5. package/package.json +1 -1
  6. package/templates/.agents/rules/workflow.md +67 -1
  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 +40 -1
  11. package/templates/.github/copilot-instructions.md +40 -1
  12. package/templates/AGENTS.md +155 -9
  13. package/templates/CONVENTIONS.md +149 -10
  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
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;
@@ -783,6 +845,28 @@ export default cors({
783
845
  await writeFile(join(appDir, 'app', 'api', 'health', 'route.ts'), `export async function GET() {
784
846
  return Response.json({ status: 'ok', timestamp: Date.now() });
785
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
+ }
786
870
  `);
787
871
  await mkdir(join(appDir, 'modules', 'users', 'actions'), { recursive: true });
788
872
  await mkdir(join(appDir, 'modules', 'users', 'queries'), { recursive: true });
@@ -865,6 +949,13 @@ export type ActionResult<T> =
865
949
  | { success: true; data: T }
866
950
  | { success: false; error: string; status: number };
867
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);
868
959
  }
869
960
 
870
961
  if (!isApi) {
@@ -905,7 +996,7 @@ export type ActionResult<T> =
905
996
 
906
997
  // Pre-initialise @webjsdev/ui so the scaffold boots ready for
907
998
  // `webjs ui add <name>`: writes components.json + lib/utils/cn.ts +
908
- // app/globals.css (the shadcn theme).
999
+ // styles/globals.css (the @webjsdev/ui theme).
909
1000
  await writeUiBootstrap(appDir);
910
1001
 
911
1002
  // Copy the standard ui-* component kit the scaffold's example pages
@@ -915,13 +1006,21 @@ export type ActionResult<T> =
915
1006
  'button', 'card', 'alert', 'badge', 'separator', 'label', 'input',
916
1007
  ]);
917
1008
 
918
- // 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
919
1018
  // ui-* components consume. We read the registry's themes/index.css at
920
1019
  // create time and inline it into the layout's
921
1020
  // `<style type="text/tailwindcss">` block so the Tailwind browser
922
- // 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
923
1022
  // `webjsui` tooling.
924
- const SHADCN_THEME = (await readThemeCss())
1023
+ const UI_THEME = (await readThemeCss())
925
1024
  // Escape backticks + ${} so the CSS survives interpolation into the
926
1025
  // layout's template literal below.
927
1026
  .replace(/\\/g, '\\\\')
@@ -948,7 +1047,7 @@ import '#components/theme-toggle.ts';
948
1047
  *
949
1048
  * Light DOM + Tailwind by default. Design tokens live in :root and are
950
1049
  * mapped into the Tailwind palette via @theme, so classes like
951
- * 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.
952
1051
  *
953
1052
  * Nav + footer links repeat the same class bundle, so they're extracted
954
1053
  * into small JS helpers below. Each helper runs at SSR time inside
@@ -956,7 +1055,7 @@ import '#components/theme-toggle.ts';
956
1055
  */
957
1056
 
958
1057
  const navLink = (href: string, label: string) => html\`
959
- <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>
960
1059
  \`;
961
1060
 
962
1061
  export default function RootLayout({ children }: { children: unknown }) {
@@ -976,7 +1075,7 @@ export default function RootLayout({ children }: { children: unknown }) {
976
1075
  var el = document.documentElement;
977
1076
  if (t === 'light' || t === 'dark') el.dataset.theme = t;
978
1077
  else delete el.dataset.theme;
979
- // 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
980
1079
  // copied ui-* components (button, card, etc.) follow light/dark too.
981
1080
  // Dark is the default unless the OS prefers light or 'light' is set.
982
1081
  var dark = t === 'dark' || (t !== 'light' && !mq.matches);
@@ -1011,27 +1110,124 @@ export default function RootLayout({ children }: { children: unknown }) {
1011
1110
  <!--
1012
1111
  Webjs UI theme. Design tokens (--color-primary,
1013
1112
  --color-card, --radius, etc.) the ui-* components consume.
1014
- 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
1015
1114
  the Tailwind browser runtime resolves the tokens without a build step.
1016
1115
  Edit base palette via the :root / .dark blocks below.
1017
1116
  -->
1018
1117
  <style type="text/tailwindcss">
1019
- ${SHADCN_THEME}
1118
+ ${UI_THEME}
1020
1119
  </style>
1021
1120
  <style type="text/tailwindcss">
1022
- @theme {
1023
- --color-fg: var(--fg);
1024
- --color-fg-muted: var(--fg-muted);
1025
- --color-fg-subtle: var(--fg-subtle);
1026
- --color-bg: var(--bg);
1027
- --color-bg-elev: var(--bg-elev);
1028
- --color-bg-subtle: var(--bg-subtle);
1029
- --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. */
1030
1229
  --color-border-strong: var(--border-strong);
1031
- --color-accent: var(--accent);
1032
- --color-accent-hover: var(--accent-hover);
1033
- --color-accent-fg: var(--accent-fg);
1034
- --color-accent-tint: var(--accent-tint);
1230
+ --color-primary-tint: var(--primary-tint);
1035
1231
  --font-sans: var(--font-sans);
1036
1232
  --font-serif: var(--font-serif);
1037
1233
  --font-mono: var(--font-mono);
@@ -1044,71 +1240,21 @@ ${SHADCN_THEME}
1044
1240
  }
1045
1241
  </style>
1046
1242
  <style>
1047
- :root {
1048
- color-scheme: light dark;
1049
- /* ---------- dark (default) ---------- */
1050
- --fg: oklch(0.96 0.015 60);
1051
- --fg-muted: oklch(0.72 0.02 60);
1052
- --fg-subtle: oklch(0.55 0.02 60);
1053
- --bg: oklch(0.14 0.01 55);
1054
- --bg-elev: oklch(0.18 0.01 55);
1055
- --bg-subtle: oklch(0.16 0.01 55);
1056
- --border: oklch(0.26 0.012 55 / 0.9);
1057
- --border-strong: oklch(0.38 0.012 55 / 0.9);
1058
- --accent: oklch(0.78 0.14 55);
1059
- --accent-hover: oklch(0.85 0.14 55);
1060
- --accent-fg: oklch(0.15 0.01 55);
1061
- --accent-tint: oklch(0.78 0.14 55 / 0.14);
1062
- --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1063
- --font-serif: ui-serif, 'Iowan Old Style', Palatino, Georgia, serif;
1064
- --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1065
- }
1066
- :root[data-theme='light'] {
1067
- --fg: oklch(0.18 0.015 60);
1068
- --fg-muted: oklch(0.42 0.02 65);
1069
- --fg-subtle: oklch(0.62 0.015 70);
1070
- --bg: oklch(0.985 0.008 80);
1071
- --bg-elev: oklch(1 0 0);
1072
- --bg-subtle: oklch(0.96 0.008 80);
1073
- --border: oklch(0.88 0.01 75 / 0.95);
1074
- --border-strong: oklch(0.78 0.01 75 / 0.95);
1075
- --accent: oklch(0.58 0.15 55);
1076
- --accent-hover: oklch(0.5 0.15 55);
1077
- --accent-fg: oklch(1 0 0);
1078
- --accent-tint: oklch(0.58 0.15 55 / 0.1);
1079
- }
1080
- @media (prefers-color-scheme: light) {
1081
- :root:not([data-theme='dark']) {
1082
- --fg: oklch(0.18 0.015 60);
1083
- --fg-muted: oklch(0.42 0.02 65);
1084
- --fg-subtle: oklch(0.62 0.015 70);
1085
- --bg: oklch(0.985 0.008 80);
1086
- --bg-elev: oklch(1 0 0);
1087
- --bg-subtle: oklch(0.96 0.008 80);
1088
- --border: oklch(0.88 0.01 75 / 0.95);
1089
- --border-strong: oklch(0.78 0.01 75 / 0.95);
1090
- --accent: oklch(0.58 0.15 55);
1091
- --accent-hover: oklch(0.5 0.15 55);
1092
- --accent-fg: oklch(1 0 0);
1093
- --accent-tint: oklch(0.58 0.15 55 / 0.1);
1094
- }
1095
- }
1096
- /* Body + pseudo-elements utility classes can't reach. */
1243
+ /* Base styles utility classes can't reach. */
1097
1244
  html, body { margin: 0; }
1098
- :root { --header-h: 56px; } /* fixed-header offset, kept exact by the script above */
1099
1245
  body {
1100
1246
  padding-top: var(--header-h);
1101
- background: var(--bg);
1102
- color: var(--fg);
1247
+ background: var(--background);
1248
+ color: var(--foreground);
1103
1249
  font: 16px/1.65 var(--font-sans);
1104
1250
  -webkit-font-smoothing: antialiased;
1105
1251
  }
1106
- ::selection { background: var(--accent-tint); color: var(--fg); }
1252
+ ::selection { background: color-mix(in oklch, var(--primary) 22%, transparent); color: var(--foreground); }
1107
1253
  </style>
1108
1254
 
1109
- <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]">
1110
- <a href="/" class="mr-auto inline-flex items-center gap-2 no-underline text-fg font-semibold text-[15px] leading-none tracking-tight">
1111
- <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>
1112
1258
  </a>
1113
1259
  <nav class="flex gap-4 items-center">
1114
1260
  <!-- Example nav. Replace with the real navigation for your app. -->
@@ -1124,13 +1270,30 @@ ${SHADCN_THEME}
1124
1270
  drop the cap and mx-auto for an edge-to-edge layout. A wide layout left
1125
1271
  inside the 760px reading column overflows into a horizontal scrollbar.
1126
1272
  -->
1127
- <main class="block max-w-[760px] mx-auto px-4 sm:px-6 pt-[72px] pb-12 min-h-screen">
1128
- \${children}
1129
- </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>
1130
1289
  \`;
1131
1290
  }
1132
1291
  `);
1133
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) {
1134
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.
1135
1298
  import { html } from '@webjsdev/core';
1136
1299
  import { rubric, displayH1, accentLink } from '#lib/utils/ui.ts';
@@ -1141,69 +1304,189 @@ import {
1141
1304
  cardHeaderClass,
1142
1305
  cardTitleClass,
1143
1306
  cardDescriptionClass,
1144
- cardContentClass,
1145
1307
  } from '#components/ui/card.ts';
1146
- import { alertClass, alertTitleClass, alertDescriptionClass } from '#components/ui/alert.ts';
1147
- import { separatorClass } from '#components/ui/separator.ts';
1148
1308
 
1149
1309
  export const metadata = {
1150
- title: '${name}: built with webjs',
1310
+ title: '${displayName}: built with webjs',
1151
1311
  };
1152
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
+
1153
1351
  export default function Home() {
1154
1352
  return html\`
1155
- <section class="mb-18">
1353
+ <section class="mb-14">
1156
1354
  \${rubric('welcome')}
1157
- \${displayH1(html\`Hello from <span class="text-accent italic">${name}</span>.\`)}
1158
- <p class="text-lede leading-[1.5] text-fg-muted max-w-[56ch] m-0 mb-6">
1159
- Edit <code class="font-mono text-[0.9em]">app/page.ts</code> to get started.
1160
- Run \${accentLink('#', 'webjs test')} to run tests and
1161
- \${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.
1162
1361
  </p>
1163
1362
  <div class="flex gap-3 items-center">
1164
- <button class=\${buttonClass()}>Get started</button>
1165
- <button class=\${buttonClass({ variant: 'outline' })}>View docs</button>
1363
+ <a href="/examples/todo" class=\${buttonClass()}>Open the todo app</a>
1166
1364
  <span class=\${badgeClass({ variant: 'secondary' })}>v0.1</span>
1167
1365
  </div>
1168
1366
  </section>
1169
1367
 
1170
- <div class=\${cardClass()} style="margin-bottom: 3rem">
1171
- <div class=\${cardHeaderClass()}>
1172
- <h3 class=\${cardTitleClass()}>Web Components + Server Actions</h3>
1173
- <p class=\${cardDescriptionClass()}>
1174
- Drop a custom element anywhere. Call a server action like a local
1175
- function. webjs rewrites the import into a typed RPC stub.
1176
- </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)}
1177
1376
  </div>
1178
- <div class=\${cardContentClass()}>
1179
- <div class=\${alertClass()}>
1180
- <h5 class=\${alertTitleClass()}>AI-first component kit included</h5>
1181
- <div class=\${alertDescriptionClass()}>
1182
- button, card, alert, badge, separator, label, input are already
1183
- in <code class="font-mono text-[0.9em]">components/ui/</code> as
1184
- class-helper functions you call from a native element. Add more
1185
- with <code class="font-mono text-[0.9em]">webjs ui add &lt;name&gt;</code>.
1186
- </div>
1187
- </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>
1188
1443
  </div>
1189
1444
  </div>
1445
+ </a>
1446
+ \`;
1190
1447
 
1191
- <div class=\${separatorClass()} style="margin: 2.5rem 0"></div>
1192
-
1193
- <section class="mt-10">
1194
- <h2 class="font-serif text-[1.6rem] tracking-[-0.02em] font-bold m-0 mb-2">Light DOM + Tailwind</h2>
1195
- <p class="text-fg-muted text-sm m-0 mb-4">
1196
- Components render into light DOM by default. Tailwind utility classes
1197
- apply directly. Set <code class="font-mono text-[0.9em]">static shadow = true</code>
1198
- on a component when you need scoped styles or third-party-embed
1199
- isolation. &lt;slot&gt; projection works identically in both modes,
1200
- including named slots, fallback content, and the full
1201
- 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.
1202
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>
1203
1485
  </section>
1204
1486
  \`;
1205
1487
  }
1206
1488
  `);
1489
+ }
1207
1490
 
1208
1491
  // AGENTS.md is copied via the `templateFiles` loop above, from
1209
1492
  // `packages/cli/templates/AGENTS.md` with `{{APP_NAME}}` substitution.
@@ -1246,7 +1529,7 @@ export class ThemeToggle extends WebComponent {
1246
1529
  const el = document.documentElement;
1247
1530
  if (next === 'system') delete el.dataset.theme;
1248
1531
  else el.dataset.theme = next;
1249
- // 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.
1250
1533
  const dark = next === 'dark'
1251
1534
  || (next === 'system' && !window.matchMedia('(prefers-color-scheme: light)').matches);
1252
1535
  el.classList.toggle('dark', dark);
@@ -1258,7 +1541,7 @@ export class ThemeToggle extends WebComponent {
1258
1541
  const icon = t === 'light' ? ICONS.sun : t === 'dark' ? ICONS.moon : ICONS.system;
1259
1542
  return html\`
1260
1543
  <button
1261
- 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"
1262
1545
  @click=\${() => this.cycle()}
1263
1546
  aria-label="Cycle theme (currently \${label})"
1264
1547
  title="Theme: \${label.toLowerCase()}"
@@ -1308,7 +1591,7 @@ ThemeToggle.register('theme-toggle');
1308
1591
  app/layout.ts, page.ts, login/, signup/
1309
1592
  app/dashboard/{page,settings,middleware}.ts ← protected
1310
1593
  app/api/auth/[...path]/route.ts ← auth API
1311
- app/globals.css ← @webjsdev/ui theme tokens
1594
+ styles/globals.css ← @webjsdev/ui theme tokens
1312
1595
  components.json ← preconfigured for \`webjs ui add\`
1313
1596
  components/ui/{button,card,alert,badge,separator,label,input,
1314
1597
  dialog,form,field,switch,checkbox}.ts
@@ -1321,15 +1604,21 @@ ThemeToggle.register('theme-toggle');
1321
1604
  `);
1322
1605
  } else {
1323
1606
  console.log(` ${name}/
1324
- app/layout.ts, page.ts ← light DOM + Tailwind + @theme tokens
1325
- 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
1326
1613
  components.json ← preconfigured for \`webjs ui add\`
1327
1614
  components/ui/{button,card,alert,badge,separator,label,input}.ts
1328
1615
  components/theme-toggle.ts ← light DOM web component
1329
1616
  lib/utils/cn.ts ← cn() helper for ui-* components
1330
1617
  lib/utils/ui.ts ← Tailwind class-bundle helpers
1331
1618
  public/tailwind-browser.js ← Tailwind runtime
1332
- 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)
1333
1622
  CONVENTIONS.md, AGENTS.md, CLAUDE.md
1334
1623
  `);
1335
1624
  }
@@ -1346,14 +1635,17 @@ ThemeToggle.register('theme-toggle');
1346
1635
  ✓ Convention validation via \`webjs check\`
1347
1636
 
1348
1637
  For AI agents, read this before editing scaffolded files:
1349
- • The example layout / page / components / schema are REFERENCE ONLY.
1350
- Replace them with the app the user actually asked for. Don't ship
1351
- the scaffold's example User model or "Hello from …" page as the
1352
- final product.
1353
- • This fresh app intentionally FAILS \`webjs check\` with two
1354
- no-scaffold-placeholder violations (app/page.ts, app/layout.ts).
1355
- That is the signal to replace the example content. Delete each
1356
- 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.
1357
1649
  • Use Drizzle + SQLite for app data. It's already wired up. Define
1358
1650
  real models in db/schema.server.ts, then run \`webjs db generate\`
1359
1651
  and \`webjs db migrate\`. NEVER store app data in JSON files,
@@ -1363,7 +1655,7 @@ For AI agents, read this before editing scaffolded files:
1363
1655
  the closest scaffold and adapt it.
1364
1656
  • Read AGENTS.md + CONVENTIONS.md in the new project before writing
1365
1657
  any code. They are the contract.
1366
- • 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
1367
1659
  (every API, directive, recipe, and deployment guide).
1368
1660
  `);
1369
1661
 
@@ -1391,13 +1683,14 @@ For AI agents, read this before editing scaffolded files:
1391
1683
  // templates ship with @webjsdev/ui already initialised; the api
1392
1684
  // template has no UI but may add one later.
1393
1685
  const installSegment = installed ? '' : `${pm} install && `;
1394
- // The saas example queries the users table on its first request (auth), so it
1395
- // needs a migration authored first: `db:generate` writes it and the
1396
- // `webjs.dev.before` migrate applies it on `run dev` (Drizzle splits Prisma's
1397
- // `migrate dev` into generate-then-migrate). The full-stack / api examples do
1398
- // not query the db on first paint, so they boot with just `run dev`; once you
1399
- // add a db route, `db:generate` then `run dev` is the loop (dev auto-migrates).
1400
- 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 && `;
1401
1694
  const runCommand = `cd ${name} && ${installSegment}${dbSegment}${pm} run dev`;
1402
1695
  // Postgres needs a reachable DATABASE_URL before any migrate (sqlite uses a
1403
1696
  // local file with no .env). Point it at a running database; `dev` / `start`