@webjsdev/cli 0.10.45 → 0.10.47

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 (92) hide show
  1. package/bin/webjs.js +15 -12
  2. package/lib/create.js +109 -141
  3. package/package.json +1 -1
  4. package/templates/.agents/rules/workflow.md +7 -3
  5. package/templates/.agents/skills/webjs/SKILL.md +4 -2
  6. package/templates/.agents/skills/webjs/references/auth-and-sessions.md +78 -16
  7. package/templates/.agents/skills/webjs/references/built-ins.md +16 -2
  8. package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +28 -4
  9. package/templates/.agents/skills/webjs/references/components.md +82 -2
  10. package/templates/.agents/skills/webjs/references/data-and-actions.md +19 -2
  11. package/templates/.agents/skills/webjs/references/optimistic-ui.md +18 -0
  12. package/templates/.agents/skills/webjs/references/routing-and-pages.md +25 -2
  13. package/templates/.agents/skills/webjs/references/styling.md +82 -2
  14. package/templates/.agents/skills/webjs/references/ui-kit.md +8 -3
  15. package/templates/AGENTS.md +13 -5
  16. package/templates/gallery/app/apple-icon.ts +2 -5
  17. package/templates/gallery/app/examples/layout.ts +7 -2
  18. package/templates/gallery/app/examples/todo/page.ts +2 -1
  19. package/templates/gallery/app/features/async-render/page.ts +3 -2
  20. package/templates/gallery/app/features/auth/dashboard/layout.ts +2 -1
  21. package/templates/gallery/app/features/auth/dashboard/page.ts +4 -2
  22. package/templates/gallery/app/features/auth/dashboard/settings/page.ts +2 -1
  23. package/templates/gallery/app/features/auth/login/middleware.ts +15 -0
  24. package/templates/gallery/app/features/auth/login/page.ts +7 -4
  25. package/templates/gallery/app/features/auth/page.ts +6 -5
  26. package/templates/gallery/app/features/auth/signup/middleware.ts +11 -0
  27. package/templates/gallery/app/features/auth/signup/page.ts +7 -4
  28. package/templates/gallery/app/features/boundaries/error.ts +5 -4
  29. package/templates/gallery/app/features/boundaries/gated/forbidden.ts +5 -4
  30. package/templates/gallery/app/features/boundaries/not-found.ts +5 -4
  31. package/templates/gallery/app/features/boundaries/page.ts +11 -10
  32. package/templates/gallery/app/features/boundaries/private/unauthorized.ts +5 -4
  33. package/templates/gallery/app/features/broadcast/page.ts +4 -3
  34. package/templates/gallery/app/features/caching/page.ts +5 -4
  35. package/templates/gallery/app/features/client-router/page.ts +7 -5
  36. package/templates/gallery/app/features/client-router/second/page.ts +4 -3
  37. package/templates/gallery/app/features/components/page.ts +3 -2
  38. package/templates/gallery/app/features/directives/page.ts +3 -2
  39. package/templates/gallery/app/features/env/page.ts +4 -3
  40. package/templates/gallery/app/features/file-storage/page.ts +8 -5
  41. package/templates/gallery/app/features/forms/page.ts +10 -6
  42. package/templates/gallery/app/features/frames/page.ts +16 -8
  43. package/templates/gallery/app/features/layout.ts +60 -5
  44. package/templates/gallery/app/features/metadata/page.ts +7 -6
  45. package/templates/gallery/app/features/optimistic-ui/page.ts +3 -2
  46. package/templates/gallery/app/features/rate-limit/page.ts +6 -5
  47. package/templates/gallery/app/features/route-handler/page.ts +4 -3
  48. package/templates/gallery/app/features/routing/[id]/page.ts +7 -6
  49. package/templates/gallery/app/features/routing/page.ts +10 -9
  50. package/templates/gallery/app/features/server-actions/page.ts +5 -4
  51. package/templates/gallery/app/features/service-worker/page.ts +4 -3
  52. package/templates/gallery/app/features/sessions/page.ts +5 -4
  53. package/templates/gallery/app/features/stream/page.ts +4 -3
  54. package/templates/gallery/app/features/streaming/page.ts +4 -3
  55. package/templates/gallery/app/features/suspense/page.ts +4 -3
  56. package/templates/gallery/app/features/view-transitions/page.ts +6 -3
  57. package/templates/gallery/app/features/view-transitions/second/page.ts +4 -2
  58. package/templates/gallery/app/features/websockets/page.ts +4 -3
  59. package/templates/gallery/app/global-error.ts +2 -5
  60. package/templates/gallery/app/global-not-found.ts +4 -6
  61. package/templates/gallery/app/icon.ts +2 -5
  62. package/templates/gallery/app/manifest.ts +1 -4
  63. package/templates/gallery/app/opengraph-image.ts +3 -6
  64. package/templates/gallery/app/robots.ts +0 -3
  65. package/templates/gallery/app/sitemap.ts +0 -3
  66. package/templates/gallery/app/twitter-image.ts +3 -6
  67. package/templates/gallery/components/ui/badge.ts +41 -0
  68. package/templates/gallery/components/ui/button.ts +86 -0
  69. package/templates/gallery/components/ui/card.ts +36 -0
  70. package/templates/gallery/components/ui/input.ts +50 -0
  71. package/templates/gallery/lib/utils/ui.ts +31 -0
  72. package/templates/gallery/modules/broadcast/components/broadcast-feed.ts +4 -2
  73. package/templates/gallery/modules/caching/components/cache-buster.ts +2 -1
  74. package/templates/gallery/modules/client-router/components/router-controls.ts +4 -3
  75. package/templates/gallery/modules/components/components/counter-card.ts +4 -2
  76. package/templates/gallery/modules/components/components/reactive-meter.ts +9 -1
  77. package/templates/gallery/modules/components/components/task-loader.ts +3 -2
  78. package/templates/gallery/modules/components/components/theme-context.ts +5 -3
  79. package/templates/gallery/modules/directives/components/directive-demo.ts +17 -10
  80. package/templates/gallery/modules/gallery/components/gallery-nav.ts +54 -0
  81. package/templates/gallery/modules/gallery/nav.ts +79 -0
  82. package/templates/gallery/modules/optimistic-ui/components/like-button.ts +19 -1
  83. package/templates/gallery/modules/rate-limit/components/rate-probe.ts +2 -1
  84. package/templates/gallery/modules/route-handler/components/rich-data.ts +2 -1
  85. package/templates/gallery/modules/server-actions/components/greeter.ts +7 -4
  86. package/templates/gallery/modules/stream/components/stream-demo.ts +10 -5
  87. package/templates/gallery/modules/streaming/components/token-stream.ts +10 -3
  88. package/templates/gallery/modules/suspense/components/slow-fact.ts +2 -1
  89. package/templates/gallery/modules/todo/components/todo-app.ts +8 -4
  90. package/templates/gallery/modules/websockets/components/ws-echo.ts +5 -3
  91. package/templates/public/favicon.svg +10 -3
  92. package/templates/scripts/clear-gallery.mjs +126 -19
package/bin/webjs.js CHANGED
@@ -497,28 +497,31 @@ async function main() {
497
497
  break;
498
498
  }
499
499
  case 'ui': {
500
- // Delegate to @webjsdev/ui. Bundled as a hard dependency of
501
- // @webjsdev/cli, so `npm install -g webjsdev` pulls it in
502
- // automatically, and `webjs ui add button` works out of the box
503
- // without an extra install in user projects.
504
- const { createRequire } = await import('node:module');
505
- const req = createRequire(import.meta.url);
500
+ // Delegate to @webjsdev/ui's bin. It is a hard dependency of
501
+ // @webjsdev/cli, so `npm install -g webjsdev` pulls it in automatically
502
+ // and `webjs ui add button` works without an extra install.
503
+ //
504
+ // Resolve via resolveBin, NOT req.resolve('@webjsdev/ui/bin/webjsui.js'):
505
+ // the ui package's `exports` map does not list the bin subpath, so a
506
+ // direct subpath resolve throws ERR_PACKAGE_PATH_NOT_EXPORTED even though
507
+ // the file exists, which surfaced as a misleading "could not be resolved"
508
+ // (#1073). resolveBin resolves the `.` export, walks to the package root,
509
+ // and reads the `bin` map, exactly as `db` / `test --browser` do.
506
510
  let entry;
507
511
  try {
508
- entry = req.resolve('@webjsdev/ui/bin/webjsui.js');
512
+ // Hard-dep path: @webjsdev/ui in the CLI's own node_modules.
513
+ entry = resolveBin(join(__dirname, '..'), '@webjsdev/ui', 'webjsui');
509
514
  } catch {
510
- // Fallback: try resolving from the user's cwd in case of weird
511
- // workspace setups.
515
+ // Fallback: the user installed @webjsdev/ui directly in their project.
512
516
  try {
513
- const userReq = createRequire(join(process.cwd(), 'package.json'));
514
- entry = userReq.resolve('@webjsdev/ui/bin/webjsui.js');
517
+ entry = resolveBin(process.cwd(), '@webjsdev/ui', 'webjsui');
515
518
  } catch {
516
519
  console.error('@webjsdev/ui could not be resolved.');
517
520
  console.error('Reinstall the CLI: npm install -g webjsdev');
518
521
  process.exit(1);
519
522
  }
520
523
  }
521
- const child = spawn('node', [entry, ...rest], { stdio: 'inherit', cwd: process.cwd() });
524
+ const child = spawn(process.execPath, [entry, ...rest], { stdio: 'inherit', cwd: process.cwd() });
522
525
  child.on('exit', (code) => process.exit(code ?? 0));
523
526
  break;
524
527
  }
package/lib/create.js CHANGED
@@ -124,7 +124,16 @@ async function copyGallery(appDir) {
124
124
  const galleryDir = join(TEMPLATES, 'gallery');
125
125
  // `test` carries the auth card's real request-pipeline test (test/auth); it
126
126
  // ships with the gallery and is pruned by gallery:clear alongside the card.
127
- for (const sub of ['app', 'modules', 'test']) {
127
+ // `components` carries the gallery's EXAMPLE design system (components/ui/ class
128
+ // helpers) the demos import. It ships here so a fresh app's demos work, but it
129
+ // is an example to learn from, so gallery:clear REMOVES it (the agent then runs
130
+ // `webjs ui add` and themes its own components/ui/); only cn.ts is kept as the
131
+ // `webjs ui add` prerequisite. It merges into the app's components/ alongside
132
+ // the separately-written theme toggle (also removed by gallery:clear).
133
+ // `lib` merges the gallery's markup-chunk helpers (lib/utils/ui.ts) alongside
134
+ // the ui bootstrap's cn.ts/dom.ts (written earlier); gallery:clear removes just
135
+ // ui.ts. cp is recursive-merge, so the pre-written lib/utils/ files are kept.
136
+ for (const sub of ['app', 'modules', 'test', 'components', 'lib']) {
128
137
  await cp(join(galleryDir, sub), join(appDir, sub), { recursive: true });
129
138
  }
130
139
  }
@@ -1111,26 +1120,34 @@ import '#components/theme-toggle.ts';
1111
1120
  * mapped into Tailwind via @theme in public/input.css, so bg-background,
1112
1121
  * text-foreground, bg-card, bg-primary, and border-border all work.
1113
1122
  */
1123
+
1124
+ // Declare the favicon via metadata.icons (NOT a hand-written <link> in the
1125
+ // template): the framework emits metadata links into <head>, whereas a <link>
1126
+ // written in the layout body stays in <body>, where browsers ignore it. The SVG
1127
+ // lives at public/favicon.svg and serves at /public/favicon.svg.
1128
+ export const metadata = { icons: '/public/favicon.svg' };
1129
+
1114
1130
  export default function RootLayout({ children }: { children: unknown }) {
1115
1131
  // Read the in-flight request's CSP nonce so the theme-detection inline script
1116
1132
  // passes strict CSP. Returns '' when no CSP nonce is set.
1117
1133
  const nonce = cspNonce();
1118
1134
  return html\`
1119
1135
  <script nonce="\${nonce}">
1120
- // Light/dark theme: read the saved or OS choice and set data-theme plus the
1121
- // .dark class the tokens key off. Delete this block (and the light blocks
1122
- // below) for a single-theme app.
1136
+ // Light/dark theme: apply the saved choice before paint (no flash). The
1137
+ // tokens follow color-scheme, which [data-theme] forces and otherwise
1138
+ // follows the OS, so an unset choice needs NO inline work here. The .dark
1139
+ // class is synced only for @webjsdev/ui components (they key off .dark).
1140
+ // Delete this block and the [data-theme] rules below for a single-theme app.
1123
1141
  (function(){
1124
1142
  try {
1125
- var mq = window.matchMedia('(prefers-color-scheme: light)');
1143
+ var mq = window.matchMedia('(prefers-color-scheme: dark)');
1126
1144
  function apply(){
1127
1145
  var t = null;
1128
1146
  try { t = localStorage.getItem('webjs_theme'); } catch (_) {}
1129
1147
  var el = document.documentElement;
1130
1148
  if (t === 'light' || t === 'dark') el.dataset.theme = t;
1131
1149
  else delete el.dataset.theme;
1132
- var dark = t === 'dark' || (t !== 'light' && !mq.matches);
1133
- el.classList.toggle('dark', dark);
1150
+ el.classList.toggle('dark', t === 'dark' || (t !== 'light' && mq.matches));
1134
1151
  }
1135
1152
  apply();
1136
1153
  mq.addEventListener('change', apply);
@@ -1144,7 +1161,9 @@ export default function RootLayout({ children }: { children: unknown }) {
1144
1161
  function measure(){
1145
1162
  try {
1146
1163
  var hdr = document.querySelector('header');
1147
- if (!hdr) return;
1164
+ // Only a FIXED header leaves normal flow and needs its height
1165
+ // reserved; a normal in-flow header (the gallery's navbar) does not.
1166
+ if (!hdr || getComputedStyle(hdr).position !== 'fixed') return;
1148
1167
  var apply = function(){
1149
1168
  document.documentElement.style.setProperty('--header-h', hdr.offsetHeight + 'px');
1150
1169
  };
@@ -1157,7 +1176,6 @@ export default function RootLayout({ children }: { children: unknown }) {
1157
1176
  })();
1158
1177
  </script>
1159
1178
  <meta name="color-scheme" content="light dark">
1160
- <link rel="icon" href="/public/favicon.svg" type="image/svg+xml">
1161
1179
  <!-- JetBrains Mono for body/UI (its monospaced, developer-console feel) and
1162
1180
  Bricolage Grotesque for the display wordmark. Swap these for your own
1163
1181
  fonts (and update --font-sans / --font-display below). -->
@@ -1172,89 +1190,59 @@ export default function RootLayout({ children }: { children: unknown }) {
1172
1190
 
1173
1191
  <link rel="stylesheet" href="/public/tailwind.css">
1174
1192
  <style>
1175
- /* Design tokens. The token NAMES are infrastructure (public/input.css maps
1176
- them into Tailwind via @theme). The VALUES are a cool neutral-grey palette
1177
- with a monospaced type system: change them here to give the app its own
1178
- look. bg-background / text-foreground / bg-card / bg-primary / border-border
1179
- all resolve from these. */
1193
+ /* Design tokens: ONE definition per colour via light-dark(LIGHT, DARK), so
1194
+ a palette change lands in a single place (DRY). The token NAMES are
1195
+ infrastructure (public/input.css maps them into Tailwind via @theme); the
1196
+ VALUES are a cool neutral-grey palette with a monospaced type system.
1197
+ color-scheme decides which side of each light-dark() applies: the default
1198
+ 'light dark' follows the OS, and the toggle FORCES one via [data-theme]
1199
+ below. The light theme is a crisp WHITE page with near-black text, a
1200
+ readable muted grey, and visible borders (a washed-out light theme comes
1201
+ from a grey page + too-light muted text + faint borders). For a
1202
+ single-theme app, delete the [data-theme] rules and give each token a
1203
+ single colour instead of light-dark().
1204
+ EDGE CASES: light-dark() is COLOUR-only. A colour needed in just one
1205
+ theme sets the unused side to a no-op, e.g. light-dark(#fff, transparent).
1206
+ A DERIVED token that references a light-dark() one (like --primary-tint
1207
+ below) tracks both themes for free. A NON-colour token that must differ
1208
+ per theme (a shadow's geometry, a gradient, a size, an image) cannot use
1209
+ light-dark(); give it a :root[data-theme='dark'] override plus an
1210
+ @media (prefers-color-scheme: dark) { :root:not([data-theme]) { ... } }
1211
+ rule for the OS default. */
1180
1212
  :root {
1181
1213
  --font-sans: 'JetBrains Mono', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
1182
1214
  --font-serif: ui-serif, 'Iowan Old Style', Palatino, Georgia, serif;
1183
1215
  --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
1184
1216
  --font-display: 'Bricolage Grotesque', 'JetBrains Mono', ui-sans-serif, system-ui, sans-serif;
1185
1217
  --header-h: 0px;
1218
+
1219
+ color-scheme: light dark; /* default: follow the OS */
1220
+ --background: light-dark(#ffffff, #1e2226);
1221
+ --foreground: light-dark(#191c20, #dee2e6);
1222
+ --card: light-dark(#f7f8fa, #313539);
1223
+ --card-foreground: light-dark(#191c20, #dee2e6);
1224
+ --popover: light-dark(#ffffff, #313539);
1225
+ --popover-foreground: light-dark(#191c20, #dee2e6);
1226
+ --primary: light-dark(#1e2226, #dee2e6);
1227
+ --primary-foreground: light-dark(#ffffff, #1e2226);
1228
+ --secondary: light-dark(#eef0f2, #363a3e);
1229
+ --secondary-foreground: light-dark(#191c20, #dee2e6);
1230
+ --muted: light-dark(#eef0f2, #313539);
1231
+ --muted-foreground: light-dark(#565c64, #94989c);
1232
+ --accent: light-dark(#e9ebef, #363a3e);
1233
+ --accent-foreground: light-dark(#191c20, #f7fbff);
1234
+ --border: light-dark(#e2e5e9, #3d434b);
1235
+ --border-strong: light-dark(#ccd1d7, #454b51);
1236
+ --input: light-dark(#e2e5e9, #34393e);
1237
+ --ring: light-dark(#8b9198, #6b7075);
1186
1238
  /* A translucent tint of the primary, tracked automatically across
1187
1239
  light/dark. Used for focus rings (ring-primary-tint). */
1188
1240
  --primary-tint: color-mix(in srgb, var(--primary) 22%, transparent);
1189
1241
  }
1190
- /* dark (the default, and the explicit .dark the toggle sets) */
1191
- :root, .dark {
1192
- color-scheme: dark;
1193
- --background: #1e2226;
1194
- --foreground: #dee2e6;
1195
- --card: #313539;
1196
- --card-foreground: #dee2e6;
1197
- --popover: #313539;
1198
- --popover-foreground: #dee2e6;
1199
- --primary: #dee2e6;
1200
- --primary-foreground: #1e2226;
1201
- --secondary: #363a3e;
1202
- --secondary-foreground: #dee2e6;
1203
- --muted: #313539;
1204
- --muted-foreground: #94989c;
1205
- --accent: #363a3e;
1206
- --accent-foreground: #f7fbff;
1207
- --border: #34393e;
1208
- --border-strong: #454b51;
1209
- --input: #34393e;
1210
- --ring: #6b7075;
1211
- }
1212
- /* light (explicit via the toggle) */
1213
- :root[data-theme='light'] {
1214
- color-scheme: light;
1215
- --background: #dee2e6;
1216
- --foreground: #313539;
1217
- --card: #f0f4f7;
1218
- --card-foreground: #313539;
1219
- --popover: #f0f4f7;
1220
- --popover-foreground: #313539;
1221
- --primary: #313539;
1222
- --primary-foreground: #f7fbff;
1223
- --secondary: #f7fbff;
1224
- --secondary-foreground: #313539;
1225
- --muted: #eaeef1;
1226
- --muted-foreground: #767b80;
1227
- --accent: #f7fbff;
1228
- --accent-foreground: #313539;
1229
- --border: #c9d0d6;
1230
- --border-strong: #b3bbc2;
1231
- --input: #c9d0d6;
1232
- --ring: #9aa0a5;
1233
- }
1234
- /* light (OS preference, when the user has made no explicit choice) */
1235
- @media (prefers-color-scheme: light) {
1236
- :root:not(.dark):not([data-theme='dark']) {
1237
- color-scheme: light;
1238
- --background: #dee2e6;
1239
- --foreground: #313539;
1240
- --card: #f0f4f7;
1241
- --card-foreground: #313539;
1242
- --popover: #f0f4f7;
1243
- --popover-foreground: #313539;
1244
- --primary: #313539;
1245
- --primary-foreground: #f7fbff;
1246
- --secondary: #f7fbff;
1247
- --secondary-foreground: #313539;
1248
- --muted: #eaeef1;
1249
- --muted-foreground: #767b80;
1250
- --accent: #f7fbff;
1251
- --accent-foreground: #313539;
1252
- --border: #c9d0d6;
1253
- --border-strong: #b3bbc2;
1254
- --input: #c9d0d6;
1255
- --ring: #9aa0a5;
1256
- }
1257
- }
1242
+ /* The toggle writes data-theme to FORCE a scheme; with neither attribute
1243
+ the default 'color-scheme: light dark' above follows the OS. */
1244
+ :root[data-theme='light'] { color-scheme: light; }
1245
+ :root[data-theme='dark'] { color-scheme: dark; }
1258
1246
  </style>
1259
1247
  <style>
1260
1248
  /* Base styles utility classes can't reach. */
@@ -1268,7 +1256,24 @@ export default function RootLayout({ children }: { children: unknown }) {
1268
1256
  -moz-osx-font-smoothing: grayscale;
1269
1257
  }
1270
1258
  </style>
1271
- <main class="min-h-dvh px-4 sm:px-6 py-8">
1259
+ <!-- Top navbar, on every page: brand on the left, links + theme toggle on
1260
+ the right. It floats (no separator) and is in normal flow, so it just
1261
+ scrolls with the page; make it a fixed header only if you want it pinned
1262
+ (position: fixed, never sticky, which flickers on iOS during a nav). -->
1263
+ <header class="max-w-5xl mx-auto px-4 sm:px-6 h-14 flex items-center justify-between gap-4">
1264
+ <a href="/" class="inline-flex items-center gap-2 no-underline text-foreground font-bold tracking-tight" style="font-family: var(--font-display)">
1265
+ <span class="w-[22px] h-[22px] rounded-[7px] bg-gradient-to-br from-foreground to-muted-foreground" aria-hidden="true"></span>
1266
+ WebJs Gallery
1267
+ </a>
1268
+ <nav class="flex items-center gap-4 text-sm" aria-label="Primary">
1269
+ <a href="https://docs.webjs.dev" target="_blank" rel="noopener" class="hidden sm:inline text-muted-foreground hover:text-foreground no-underline transition-colors">Docs</a>
1270
+ <a href="https://github.com/webjsdev/webjs" target="_blank" rel="noopener" class="hidden sm:inline text-muted-foreground hover:text-foreground no-underline transition-colors">GitHub</a>
1271
+ <theme-toggle></theme-toggle>
1272
+ </nav>
1273
+ </header>
1274
+ <!-- Fill the viewport minus the h-14 (3.5rem) navbar, so a short page has no
1275
+ spurious scrollbar (min-h-dvh alone would overflow by the navbar height). -->
1276
+ <main class="min-h-[calc(100dvh-3.5rem)] max-w-5xl mx-auto px-4 sm:px-6 py-8">
1272
1277
  \${children}
1273
1278
  </main>
1274
1279
  \`;
@@ -1281,74 +1286,34 @@ export default function RootLayout({ children }: { children: unknown }) {
1281
1286
  // app/features/<x> route AND its modules/<x>), then reshape this page into the
1282
1287
  // app's real landing page.
1283
1288
  await writeFile(join(appDir, 'app', 'page.ts'), `import { html } from '@webjsdev/core';
1289
+ import { cardClass } from '#components/ui/card.ts';
1290
+ import { badgeClass } from '#components/ui/badge.ts';
1291
+ // The demo index is defined once in modules/gallery/nav.ts (the same source the
1292
+ // left sidebar reads), so the home cards and the sidebar can never drift.
1293
+ import { FEATURES, EXAMPLES } from '#modules/gallery/nav.ts';
1284
1294
 
1285
1295
  export const metadata = {
1286
1296
  title: '${displayName}',
1287
1297
  };
1288
1298
 
1289
- // The gallery this page links. FEATURES are single-concept demos (one WebJs
1290
- // concept each, under app/features/, logic in modules/). EXAMPLES are whole apps
1291
- // composing several features (under app/examples/). Prune what you do not use
1292
- // (delete the route AND its modules/<name>), then reshape this page.
1293
- const FEATURES = [
1294
- { href: '/features/routing', title: 'Routing', blurb: 'A static route plus a dynamic [id] segment that reads params. The file-based router in miniature.' },
1295
- { href: '/features/boundaries', title: 'Boundaries', blurb: 'The control-flow throws (forbidden / unauthorized / notFound) and the nearest boundary file that catches each.' },
1296
- { href: '/features/auth', title: 'Auth', blurb: 'Password login on createAuth, a signed session cookie, and a real protected route that redirects anonymous visitors to login.' },
1297
- { href: '/features/components', title: 'Components', blurb: 'The WebComponent factory, reactive props, instance signals, and slot projection in light DOM.' },
1298
- { 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.' },
1299
- { href: '/features/optimistic-ui', title: 'Optimistic UI', blurb: 'The imperative optimistic(signal, value, action) flip: instant update, automatic rollback on failure.' },
1300
- { 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.' },
1301
- { href: '/features/streaming', title: 'Streaming actions', blurb: 'A use-server action that returns an async generator, streamed to the call site token by token with for await.' },
1302
- { href: '/features/stream', title: 'Stream updates', blurb: 'The <webjs-stream> element: renderStream() applies surgical append / replace / remove DOM updates by target id, no region redraw.' },
1303
- { href: '/features/suspense', title: 'Suspense boundary', blurb: 'The <webjs-suspense> element: a first-paint fallback for a SLOW component, with the resolved content streamed in.' },
1304
- { href: '/features/view-transitions', title: 'View transitions', blurb: 'The opt-in view-transition meta cross-fades a soft navigation, with a data-webjs-permanent element persisted across the swap.' },
1305
- { href: '/features/directives', title: 'Directives', blurb: 'The lit-html directive set: repeat for keyed lists, watch(signal) for a fine-grained node swap.' },
1306
- { 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.' },
1307
- { href: '/features/forms', title: 'Forms', blurb: 'A no-JS progressive-enhancement form posting to the page action, with server-side validation errors.' },
1308
- { href: '/features/metadata', title: 'Metadata', blurb: 'Static metadata plus generateMetadata(ctx), which reads the request to compute the title and Open Graph tags.' },
1309
- { 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.' },
1310
- { href: '/features/env', title: 'Env vars', blurb: 'The server-only vs WEBJS_PUBLIC_ boundary, read during SSR so secrets never reach the browser.' },
1311
- { href: '/features/client-router', title: 'Client router', blurb: 'Automatic soft navigation: fragment-only fetches, hover prefetch, scroll restore, and graceful no-JS fallback.' },
1312
- { href: '/features/frames', title: 'Frames', blurb: 'A webjs-frame region that swaps a filtered sub-list in place from a link, shipping zero component JS, with a no-JS full-nav fallback.' },
1313
- { 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).' },
1314
- { href: '/features/websockets', title: 'WebSockets', blurb: 'A WS(ws, req) route endpoint plus the connectWS() client, echoing messages over a live socket.' },
1315
- { 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.' },
1316
- { href: '/features/rate-limit', title: 'Rate limiting', blurb: 'The rateLimit() middleware scoped to one endpoint, returning a 429 with Retry-After past the window.' },
1317
- { href: '/features/file-storage', title: 'File storage', blurb: 'A no-JS multipart upload streamed into the FileStore, then served back through a streaming route.' },
1318
- { href: '/features/sessions', title: 'Sessions', blurb: 'A signed-cookie session applied by a segment middleware, read and written per visitor with getSession() in a route.' },
1319
- ];
1320
- const EXAMPLES = [
1321
- { 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.' },
1322
- ];
1323
-
1324
1299
  export default function Home() {
1325
1300
  return html\`
1326
- <div class="fixed top-4 right-4 z-10"><theme-toggle></theme-toggle></div>
1327
-
1328
- <div class="max-w-5xl mx-auto px-6 py-16 flex flex-col items-center gap-16">
1329
- <!-- Masthead -->
1301
+ <div class="py-8 flex flex-col items-center gap-16">
1302
+ <!-- Hero -->
1330
1303
  <section class="flex flex-col items-center text-center gap-5">
1331
- <p class="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground m-0">Welcome to</p>
1332
- <h1 class="text-6xl sm:text-7xl font-bold uppercase tracking-tight leading-none m-0 break-words bg-gradient-to-b from-foreground to-muted-foreground bg-clip-text text-transparent" style="font-family: var(--font-display); word-spacing: 0.08em; letter-spacing: -0.02em;">
1333
- WebJs Gallery
1304
+ <h1 class="text-5xl sm:text-6xl font-bold tracking-tight leading-none m-0 break-words bg-gradient-to-b from-foreground to-muted-foreground bg-clip-text text-transparent" style="font-family: var(--font-display); letter-spacing: -0.02em;">
1305
+ Explore the gallery
1334
1306
  </h1>
1335
1307
  <p class="text-base sm:text-lg text-muted-foreground max-w-lg leading-relaxed m-0">
1336
- AI-first and web-components-first. Server-rendered, progressively enhanced, and buildless.
1308
+ Each demo isolates a single WebJs capability in real, runnable code. Read the ones you need, then build your app on the same patterns.
1337
1309
  </p>
1338
1310
  </section>
1339
1311
 
1340
1312
  <!-- Gallery: every feature demo + the example app -->
1341
1313
  <section class="w-full flex flex-col gap-6">
1342
- <div class="flex flex-col items-center gap-2 text-center">
1343
- <h2 class="text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground m-0">Explore the gallery</h2>
1344
- <p class="text-sm text-muted-foreground max-w-lg leading-relaxed m-0">
1345
- One WebJs concept per demo under <code class="text-[0.9em] text-foreground">app/features/</code>, with logic
1346
- in <code class="text-[0.9em] text-foreground">modules/</code>.
1347
- </p>
1348
- </div>
1349
1314
  <div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
1350
1315
  \${FEATURES.map(f => html\`
1351
- <a href="\${f.href}" class="group flex flex-col gap-1.5 rounded-xl border border-border bg-card p-4 no-underline transition-colors hover:border-border-strong hover:bg-accent">
1316
+ <a href="\${f.href}" class=\${cardClass('group flex flex-col gap-1.5 rounded-xl p-4 no-underline transition-colors hover:border-border-strong hover:bg-accent')}>
1352
1317
  <span class="flex items-center justify-between gap-2">
1353
1318
  <span class="text-sm font-medium text-foreground">\${f.title}</span>
1354
1319
  <span class="text-muted-foreground transition-transform group-hover:translate-x-0.5" aria-hidden="true">&rarr;</span>
@@ -1358,9 +1323,9 @@ export default function Home() {
1358
1323
  \`)}
1359
1324
  </div>
1360
1325
  \${EXAMPLES.map(e => html\`
1361
- <a href="\${e.href}" class="group flex flex-col gap-2 rounded-xl border border-border bg-card p-5 no-underline transition-colors hover:border-border-strong hover:bg-accent">
1326
+ <a href="\${e.href}" class=\${cardClass('group flex flex-col gap-2 rounded-xl p-5 no-underline transition-colors hover:border-border-strong hover:bg-accent')}>
1362
1327
  <span class="flex items-center gap-2.5">
1363
- <span class="text-[0.6rem] font-semibold uppercase tracking-wider text-muted-foreground rounded border border-border px-1.5 py-0.5">Example app</span>
1328
+ <span class=\${badgeClass({ variant: 'outline' })}>Example app</span>
1364
1329
  <span class="text-sm font-medium text-foreground">\${e.title}</span>
1365
1330
  <span class="ml-auto text-muted-foreground transition-transform group-hover:translate-x-0.5" aria-hidden="true">&rarr;</span>
1366
1331
  </span>
@@ -1372,8 +1337,8 @@ export default function Home() {
1372
1337
  <!-- Footer: docs + source -->
1373
1338
  <footer class="flex flex-col items-center gap-3">
1374
1339
  <nav class="flex items-center gap-6 text-sm text-muted-foreground" aria-label="WebJs links">
1375
- <a href="https://docs.webjs.dev" class="inline-flex items-center gap-2 hover:text-foreground transition-colors no-underline">\${iconBook()}<span>Docs</span></a>
1376
- <a href="https://github.com/webjsdev/webjs" class="inline-flex items-center gap-2 hover:text-foreground transition-colors no-underline">\${iconGithub()}<span>GitHub</span></a>
1340
+ <a href="https://docs.webjs.dev" target="_blank" rel="noopener" class="inline-flex items-center gap-2 hover:text-foreground transition-colors no-underline">\${iconBook()}<span>Docs</span></a>
1341
+ <a href="https://github.com/webjsdev/webjs" target="_blank" rel="noopener" class="inline-flex items-center gap-2 hover:text-foreground transition-colors no-underline">\${iconGithub()}<span>GitHub</span></a>
1377
1342
  </nav>
1378
1343
  <p class="text-[0.7rem] uppercase tracking-[0.15em] text-muted-foreground m-0 text-center">
1379
1344
  Built with WebJs &middot; MIT License
@@ -1397,6 +1362,8 @@ function iconGithub() {
1397
1362
  // --- Theme toggle component ---
1398
1363
 
1399
1364
  await writeFile(join(appDir, 'components', 'theme-toggle.ts'), `import { WebComponent, html, signal } from '@webjsdev/core';
1365
+ import { cn } from '#lib/utils/cn.ts';
1366
+ import { buttonClass } from '#components/ui/button.ts';
1400
1367
 
1401
1368
  type Theme = 'system' | 'light' | 'dark';
1402
1369
 
@@ -1432,9 +1399,10 @@ export class ThemeToggle extends WebComponent {
1432
1399
  const el = document.documentElement;
1433
1400
  if (next === 'system') delete el.dataset.theme;
1434
1401
  else el.dataset.theme = next;
1435
- // Keep the .dark class the @webjsdev/ui kit uses in sync so the ui-* components follow the theme.
1402
+ // Our own tokens follow color-scheme via data-theme (set above). Keep the
1403
+ // .dark class in sync only for @webjsdev/ui components, which key off it.
1436
1404
  const dark = next === 'dark'
1437
- || (next === 'system' && !window.matchMedia('(prefers-color-scheme: light)').matches);
1405
+ || (next === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
1438
1406
  el.classList.toggle('dark', dark);
1439
1407
  }
1440
1408
 
@@ -1444,7 +1412,7 @@ export class ThemeToggle extends WebComponent {
1444
1412
  const icon = t === 'light' ? ICONS.sun : t === 'dark' ? ICONS.moon : ICONS.system;
1445
1413
  return html\`
1446
1414
  <button
1447
- 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"
1415
+ class=\${cn(buttonClass({ variant: 'secondary', size: 'none' }), 'w-9 h-9 rounded-full text-muted-foreground duration-150 hover:text-foreground active:scale-[0.94]')}
1448
1416
  @click=\${() => this.cycle()}
1449
1417
  aria-label="Cycle theme (currently \${label})"
1450
1418
  title="Theme: \${label.toLowerCase()}"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/cli",
3
- "version": "0.10.45",
3
+ "version": "0.10.47",
4
4
  "type": "module",
5
5
  "description": "webjs CLI - dev, start, create, db",
6
6
  "bin": {
@@ -16,7 +16,9 @@ Read `AGENTS.md` first. Full hosted docs are at https://docs.webjs.dev.
16
16
  the demos relevant to your task under `app/features/<x>` for the runnable idiom
17
17
  (the skill teaches the same and SURVIVES the clear, so you never lose it);
18
18
  (2) run `npm run gallery:clear` to shed the whole gallery in one step (it keeps
19
- the agent skill, the layout, and the database wiring, and resets the home);
19
+ the agent skill and the database wiring, and resets the home AND the root
20
+ layout to a token-free blank slate, no gallery palette or navbar survives; a
21
+ layout you already customised is kept, only its theme-toggle wiring stripped);
20
22
  (3) regenerate the database and grow the app in place under `app/`,
21
23
  `components/`, and `modules/<feature>/`. Keep the gallery only while exploring,
22
24
  never ship it.
@@ -27,8 +29,10 @@ Read `AGENTS.md` first. Full hosted docs are at https://docs.webjs.dev.
27
29
  - **`app/` is routing-only.** Only routing files live in `app/` (page, layout,
28
30
  route, middleware, metadata routes). Browser-safe helpers go in `lib/utils/`,
29
31
  feature logic in `modules/`, server-only code behind `.server.ts`.
30
- - **Give a UI app its own design.** Set the design-token values in `app/layout.ts`
31
- to a palette that fits the app. Render the app and LOOK before calling UI work
32
+ - **Give a UI app its own design.** Define design tokens in `app/layout.ts` with
33
+ a palette that fits the app (after `gallery:clear` the layout is a token-free
34
+ blank slate; `.agents/skills/webjs/references/styling.md` is the guide).
35
+ Render the app and LOOK before calling UI work
32
36
  done: `webjs check` and `webjs typecheck` pass even when a layout collapses, so
33
37
  open every route you changed in a real browser and play through its states.
34
38
 
@@ -112,17 +112,19 @@ Find the right export fast. Load the linked reference for full examples.
112
112
  ### `@webjsdev/core` (browser + isomorphic)
113
113
 
114
114
  - `html` / `css` tagged templates. `WebComponent({ ... })` base-class factory; `prop(type?, opts?)` declares one reactive property. `register(tag, C)` / `Class.register('tag')`.
115
- - `signal` / `computed` reactive state; `render(v, el)` client render.
115
+ - `signal` / `computed` reactive state, `effect(fn)` client-only reaction (returns a disposer), `batch(fn)` coalesced writes; `render(v, el)` client render.
116
116
  - `notFound()` / `redirect(url[, status])` control-flow throws (page/layout/action only, NOT `route.ts`). `forbidden()` / `unauthorized()` render the nearest boundary.
117
117
  - `Suspense({fallback, children})` page-level streaming; `<webjs-suspense>` component-level streaming.
118
118
  - `optimistic()` optimistic UI; `navigate(url)` / `revalidate(url?)` client-router control; `connectWS` / `richFetch`.
119
119
  - Types: `Metadata`, `PageProps<R>`, `LayoutProps<R>`, `RouteHandlerContext<R>`, `WebjsConfig`.
120
120
  - `@webjsdev/core/server`: `renderToString` / `renderToStream` (Node side).
121
- - `@webjsdev/core/directives`: `repeat`, `unsafeHTML` (trusted only), `live`, `keyed`, `guard`, `cache`, `until`, `watch(signal)`, `ref` / `createRef`. `Task` lives at `@webjsdev/core/task`, context at `/context`.
121
+ - `@webjsdev/core/directives`: `repeat`, `unsafeHTML` (trusted only), `live`, `keyed`, `guard`, `cache`, `until`, `watch(signal)`, `ref` / `createRef`, `asyncAppend` / `asyncReplace`, `templateContent`. `Task` / `TaskStatus` live at `@webjsdev/core/task`, context (`createContext` / `ContextProvider` / `ContextConsumer`) at `/context`. See `references/components.md` for the directive table + Task + context.
122
122
 
123
123
  ### `@webjsdev/server` (server side)
124
124
 
125
125
  - `createRequestHandler`, `cors()`, `route(action, opts?)` REST adapter, `sitemap()` / `sitemapIndex()`, `actionContext()`, `actionSignal()`, `requestId()`, `cache()` / `revalidateTag`.
126
+ - Route-handler toolkit: `json(v)` rich responder, `readBody(req)`, `clientIp(req)`, no-arg `headers()` / `cookies()` / `cspNonce()` (client counterpart `richFetch` is in `@webjsdev/core`). See `references/routing-and-pages.md`.
127
+ - Auth + sessions: `createAuth` (+ `Credentials` / `Google` / `GitHub`), `auth()` / `auth(req)`, `session()` + `cookieSession` / `storeSession`, `getSession(req)` (`.get` / `.set` / `.flash` / `.destroy`). File storage: `getFileStore` / `diskStore` / `signedUrl`. See `references/auth-and-sessions.md` + `references/built-ins.md`.
126
128
  - Data layer is Drizzle in `db/*.server.ts`. Auth, sessions, caching, rate limit, file storage are built in and pluggable (`references/built-ins.md`).
127
129
 
128
130
  ### File conventions
@@ -2,10 +2,10 @@
2
2
 
3
3
  ## What This Covers
4
4
 
5
- - Sessions: cookie by default, Redis-backed when configured, the `SESSION_SECRET` requirement
6
- - Authentication: `createAuth` (NextAuth-style), Credentials plus OAuth providers, `auth()` in a page or action
7
- - Login and logout flows (`signIn` / `signOut` / `handlers`)
8
- - Protecting a route: gate at the top of a page or action, redirect when unauthenticated
5
+ - Sessions: the `session()` middleware + storage factories (`cookieSession` / `storeSession`), the `getSession(req)` method API (`.get` / `.set` / `.flash` / `.destroy`), the `SESSION_SECRET` requirement
6
+ - Authentication: `createAuth` (NextAuth-style), Credentials plus OAuth providers, `auth()` in a page or action, scrypt password hashing
7
+ - Login and logout flows: mounting `handlers` at `app/api/auth/[...path]`, the no-JS credentials form (`/api/auth/signin/credentials` + `redirectTo` + `?error`), `signIn` / `signOut`
8
+ - Protecting a route: a page-top `auth()` gate OR a per-segment `middleware.ts` calling `auth(req)`
9
9
  - `forbidden()` (403) vs `unauthorized()` (401) and their nearest-wins boundary files
10
10
  - Returning an `ActionResult` for an auth failure inside a `'use server'` action (do NOT throw there)
11
11
  - The Origin / `Sec-Fetch-Site` CSRF model (not a token cookie)
@@ -21,23 +21,30 @@ scaling, the full caching surface).
21
21
 
22
22
  ## Sessions
23
23
 
24
- Enable sessions in middleware, read and write them in a page or action.
24
+ Enable sessions with `session()` MIDDLEWARE, then read and write them with `getSession(req)` in any route or middleware the session wraps.
25
25
 
26
26
  ```ts
27
- // middleware.ts: enable on all routes
28
- import { session } from '@webjsdev/server';
29
- export default session(); // auto: REDIS_URL present -> server-side, else -> cookie
27
+ // middleware.ts: enable on all routes. Storage is pluggable.
28
+ import { session, cookieSession, storeSession } from '@webjsdev/server';
29
+ export default session({ secret: process.env.SESSION_SECRET, storage: cookieSession() });
30
+ // cookieSession() -> whole session in a signed cookie (stateless, the default)
31
+ // storeSession() -> session in the active store (memoryStore in dev, Redis in prod), id in the cookie
32
+ ```
30
33
 
31
- // in a page or action
34
+ `getSession(req)` returns a small key/value `Session` with a METHOD API, not property assignment. Mutating it makes the middleware re-sign and set the cookie on the way out:
35
+
36
+ ```ts
32
37
  import { getSession } from '@webjsdev/server';
33
- const s = getSession(req);
34
- s.userId = user.id; // auto-saved after the response
38
+ export async function GET(req: Request) {
39
+ const s = getSession(req);
40
+ s.set('userId', user.id); // write
41
+ const id = s.get('userId'); // read
42
+ s.flash('notice', 'Saved'); // one-read-only value (cleared after the next read)
43
+ s.destroy(); // clear the whole session (logout)
44
+ }
35
45
  ```
36
46
 
37
- Cookie sessions (the default) are signed and encrypted with no server
38
- state. Store sessions (with Redis) keep the session id in the cookie and
39
- the data in Redis. Both require `SESSION_SECRET`, read from the
40
- environment (never a literal in source) so boot fails if it is missing.
47
+ Cookie sessions (the default) are signed with no server state; store sessions keep only the id in the cookie and the data in the store. `cookieSession` / `storeSession` are aliases for `cookieSessionStorage` / `storeSessionStorage`. Both strategies require `SESSION_SECRET`, read from the environment (never a literal in source) so boot fails if it is missing.
41
48
 
42
49
  ## Authentication (`createAuth`)
43
50
 
@@ -55,7 +62,7 @@ export const { auth, signIn, signOut, handlers } = createAuth({
55
62
  Credentials({
56
63
  async authorize(credentials) {
57
64
  const user = await db.query.users.findFirst({ where: { email: credentials.email } });
58
- if (!user || !verifyPassword(credentials.password, user.passwordHash)) return null;
65
+ if (!user || !(await compare(credentials.password, user.passwordHash))) return null;
59
66
  return { id: user.id, name: user.name, email: user.email, role: user.role };
60
67
  },
61
68
  }),
@@ -63,9 +70,50 @@ export const { auth, signIn, signOut, handlers } = createAuth({
63
70
  GitHub(), // reads AUTH_GITHUB_ID, AUTH_GITHUB_SECRET
64
71
  ],
65
72
  secret: process.env.AUTH_SECRET, // required, 32+ random chars, from the env
73
+ pages: { error: '/login' }, // a failed sign-in 302s here with ?error=<code>
66
74
  });
67
75
  ```
68
76
 
77
+ **Password hashing is the app's job** (WebJs ships no `verifyPassword`). Use `scrypt` from `node:crypto` (built into Node AND Bun, no dependency) in a server-only utility, and call it from `authorize`:
78
+
79
+ ```ts
80
+ // modules/auth/password.server.ts (a server-only utility, never reaches the browser)
81
+ import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
82
+ import { promisify } from 'node:util';
83
+ const scryptAsync = promisify(scrypt);
84
+ export async function hash(pw: string) {
85
+ const salt = randomBytes(16).toString('hex');
86
+ return salt + ':' + ((await scryptAsync(pw, salt, 64)) as Buffer).toString('hex');
87
+ }
88
+ export async function compare(pw: string, stored: string) {
89
+ const [salt, key] = stored.split(':');
90
+ return timingSafeEqual((await scryptAsync(pw, salt, 64)) as Buffer, Buffer.from(key, 'hex'));
91
+ }
92
+ ```
93
+
94
+ **Mount `handlers` at an `app/api/auth/[...path]/route.ts` catch-all** (at the app root, NOT under a feature folder): `createAuth` hardcodes `/api/auth/signin/*` and `/api/auth/callback/*` for its form posts and OAuth callback URIs.
95
+
96
+ ```ts
97
+ // app/api/auth/[...path]/route.ts
98
+ import { handlers } from '#modules/auth/auth.server.ts';
99
+ export const GET = handlers.GET;
100
+ export const POST = handlers.POST;
101
+ ```
102
+
103
+ **The no-JS sign-in / sign-out flow is plain forms** (progressive-enhancement-safe). Sign in by POSTing to `/api/auth/signin/credentials` with a hidden `redirectTo`, and read `?error` (mapped from `pages.error`) for feedback; sign out by POSTing to `/api/auth/signout`:
104
+
105
+ ```html
106
+ <form method="POST" action="/api/auth/signin/credentials">
107
+ <input type="hidden" name="redirectTo" value="/dashboard">
108
+ <input name="email" type="email" required><input name="password" type="password" required>
109
+ <button>Sign in</button>
110
+ </form>
111
+ <!-- log out -->
112
+ <form method="POST" action="/api/auth/signout"><button>Log out</button></form>
113
+ ```
114
+
115
+ For a programmatic sign-in (the auto-login-after-signup pattern), `signIn('credentials', creds, { redirectTo })` returns a `302` `Response` that a page `action` can return directly.
116
+
69
117
  Sessions are JWT by default (stateless, scales horizontally). OAuth
70
118
  providers handle the full redirect flow. Read the session anywhere on the
71
119
  server with `auth()`.
@@ -119,6 +167,20 @@ Reading the session through `auth()` also auto-excludes the page from the
119
167
  server HTML response cache, so a per-user page is never cached and served
120
168
  to another visitor (see `built-ins.md`).
121
169
 
170
+ To gate a WHOLE subtree in one place, use a per-segment `middleware.ts` that reads `auth(req)` (the explicit-request form) and returns a `302` BEFORE the page renders. It runs for every request under its segment and needs only a cookie read (no DB query), so the gate is real the moment the app boots:
171
+
172
+ ```ts
173
+ // app/dashboard/middleware.ts (protects /dashboard/*)
174
+ import { auth } from '#modules/auth/auth.server.ts';
175
+ export default async function requireAuth(req: Request, next: () => Promise<Response>) {
176
+ const session = await auth(req);
177
+ if (!session?.user) return new Response(null, { status: 302, headers: { location: '/login' } });
178
+ return next();
179
+ }
180
+ ```
181
+
182
+ `auth(req)` takes the in-flight request explicitly (for a middleware / route); the ambient `auth()` (no argument) reads from context inside a page or action.
183
+
122
184
  ## `forbidden()` (403) vs `unauthorized()` (401)
123
185
 
124
186
  Two control-flow throws from `@webjsdev/core`, mirroring the `notFound()`