@webjsdev/cli 0.10.44 → 0.10.46

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 (111) hide show
  1. package/README.md +2 -3
  2. package/bin/webjs.js +18 -18
  3. package/lib/api-gallery.js +1 -1
  4. package/lib/create.js +154 -243
  5. package/package.json +1 -1
  6. package/templates/.agents/rules/workflow.md +7 -3
  7. package/templates/.agents/skills/webjs/SKILL.md +4 -2
  8. package/templates/.agents/skills/webjs/references/auth-and-sessions.md +78 -16
  9. package/templates/.agents/skills/webjs/references/built-ins.md +16 -2
  10. package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +38 -6
  11. package/templates/.agents/skills/webjs/references/components.md +101 -6
  12. package/templates/.agents/skills/webjs/references/data-and-actions.md +19 -2
  13. package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +5 -1
  14. package/templates/.agents/skills/webjs/references/optimistic-ui.md +18 -0
  15. package/templates/.agents/skills/webjs/references/routing-and-pages.md +25 -2
  16. package/templates/.agents/skills/webjs/references/runtime.md +1 -1
  17. package/templates/.agents/skills/webjs/references/service-worker.md +1 -1
  18. package/templates/.agents/skills/webjs/references/styling.md +82 -2
  19. package/templates/AGENTS.md +12 -5
  20. package/templates/gallery/app/api/auth/[...path]/route.ts +7 -0
  21. package/templates/gallery/app/apple-icon.ts +2 -5
  22. package/templates/gallery/app/examples/layout.ts +16 -0
  23. package/templates/gallery/app/examples/todo/page.ts +2 -1
  24. package/templates/gallery/app/features/async-render/page.ts +3 -2
  25. package/templates/gallery/app/features/auth/dashboard/layout.ts +21 -0
  26. package/templates/gallery/app/features/auth/dashboard/middleware.ts +14 -0
  27. package/templates/gallery/app/features/auth/dashboard/page.ts +20 -0
  28. package/templates/gallery/app/features/auth/dashboard/settings/page.ts +22 -0
  29. package/templates/gallery/app/features/auth/login/middleware.ts +15 -0
  30. package/templates/gallery/app/features/auth/login/page.ts +43 -0
  31. package/templates/gallery/app/features/auth/page.ts +34 -0
  32. package/templates/gallery/app/features/auth/signup/middleware.ts +11 -0
  33. package/templates/gallery/app/features/auth/signup/page.ts +61 -0
  34. package/templates/gallery/app/features/boundaries/error.ts +5 -4
  35. package/templates/gallery/app/features/boundaries/gated/forbidden.ts +5 -4
  36. package/templates/gallery/app/features/boundaries/not-found.ts +5 -4
  37. package/templates/gallery/app/features/boundaries/page.ts +11 -10
  38. package/templates/gallery/app/features/boundaries/private/unauthorized.ts +5 -4
  39. package/templates/gallery/app/features/broadcast/page.ts +4 -3
  40. package/templates/gallery/app/features/caching/page.ts +5 -4
  41. package/templates/gallery/app/features/client-router/page.ts +7 -5
  42. package/templates/gallery/app/features/client-router/second/page.ts +4 -3
  43. package/templates/gallery/app/features/components/page.ts +3 -2
  44. package/templates/gallery/app/features/directives/page.ts +3 -2
  45. package/templates/gallery/app/features/env/page.ts +4 -3
  46. package/templates/gallery/app/features/file-storage/page.ts +8 -5
  47. package/templates/gallery/app/features/forms/page.ts +10 -6
  48. package/templates/gallery/app/features/frames/page.ts +26 -10
  49. package/templates/gallery/app/features/layout.ts +67 -0
  50. package/templates/gallery/app/features/metadata/page.ts +7 -6
  51. package/templates/gallery/app/features/optimistic-ui/page.ts +3 -2
  52. package/templates/gallery/app/features/rate-limit/page.ts +6 -5
  53. package/templates/gallery/app/features/route-handler/page.ts +4 -3
  54. package/templates/gallery/app/features/routing/[id]/page.ts +7 -6
  55. package/templates/gallery/app/features/routing/page.ts +10 -9
  56. package/templates/gallery/app/features/server-actions/page.ts +7 -4
  57. package/templates/gallery/app/features/service-worker/page.ts +4 -3
  58. package/templates/gallery/app/features/sessions/page.ts +5 -4
  59. package/templates/gallery/app/features/stream/page.ts +46 -0
  60. package/templates/gallery/app/features/streaming/page.ts +32 -0
  61. package/templates/gallery/app/features/suspense/page.ts +35 -0
  62. package/templates/gallery/app/features/view-transitions/page.ts +44 -0
  63. package/templates/gallery/app/features/view-transitions/second/page.ts +30 -0
  64. package/templates/gallery/app/features/websockets/page.ts +4 -3
  65. package/templates/gallery/app/global-error.ts +2 -5
  66. package/templates/gallery/app/global-not-found.ts +4 -6
  67. package/templates/gallery/app/icon.ts +2 -5
  68. package/templates/gallery/app/manifest.ts +1 -4
  69. package/templates/gallery/app/opengraph-image.ts +3 -6
  70. package/templates/gallery/app/robots.ts +0 -3
  71. package/templates/gallery/app/sitemap.ts +0 -3
  72. package/templates/gallery/app/twitter-image.ts +3 -6
  73. package/templates/gallery/components/ui/badge.ts +41 -0
  74. package/templates/gallery/components/ui/button.ts +86 -0
  75. package/templates/gallery/components/ui/card.ts +36 -0
  76. package/templates/gallery/components/ui/input.ts +50 -0
  77. package/templates/gallery/lib/utils/ui.ts +31 -0
  78. package/templates/gallery/modules/auth/actions/signup.server.ts +19 -0
  79. package/templates/gallery/modules/auth/auth.server.ts +53 -0
  80. package/templates/gallery/modules/auth/password.server.ts +20 -0
  81. package/templates/gallery/modules/auth/queries/current-user.server.ts +12 -0
  82. package/templates/gallery/modules/auth/types.ts +9 -0
  83. package/templates/gallery/modules/broadcast/components/broadcast-feed.ts +4 -2
  84. package/templates/gallery/modules/caching/components/cache-buster.ts +2 -1
  85. package/templates/gallery/modules/client-router/components/router-controls.ts +4 -3
  86. package/templates/gallery/modules/components/components/counter-card.ts +4 -2
  87. package/templates/gallery/modules/components/components/reactive-meter.ts +9 -1
  88. package/templates/gallery/modules/components/components/task-loader.ts +3 -2
  89. package/templates/gallery/modules/components/components/theme-context.ts +5 -3
  90. package/templates/gallery/modules/directives/components/directive-demo.ts +17 -10
  91. package/templates/gallery/modules/gallery/components/gallery-nav.ts +54 -0
  92. package/templates/gallery/modules/gallery/nav.ts +79 -0
  93. package/templates/gallery/modules/optimistic-ui/components/like-button.ts +19 -1
  94. package/templates/gallery/modules/rate-limit/components/rate-probe.ts +2 -1
  95. package/templates/gallery/modules/route-handler/components/rich-data.ts +2 -1
  96. package/templates/gallery/modules/server-actions/actions/greet.server.ts +2 -2
  97. package/templates/gallery/modules/server-actions/actions/greet.test.ts +39 -36
  98. package/templates/gallery/modules/server-actions/components/greeter.ts +11 -11
  99. package/templates/gallery/modules/server-actions/middleware/require-auth.server.ts +14 -9
  100. package/templates/gallery/modules/stream/components/stream-demo.ts +81 -0
  101. package/templates/gallery/modules/streaming/actions/stream-tokens.server.ts +17 -0
  102. package/templates/gallery/modules/streaming/components/token-stream.ts +53 -0
  103. package/templates/gallery/modules/suspense/components/slow-fact.ts +20 -0
  104. package/templates/gallery/modules/todo/components/todo-app.ts +8 -4
  105. package/templates/gallery/modules/websockets/components/ws-echo.ts +5 -3
  106. package/templates/gallery/test/auth/auth.test.ts +81 -0
  107. package/templates/public/favicon.svg +10 -3
  108. package/templates/scripts/clear-api-gallery.mjs +55 -0
  109. package/templates/scripts/clear-gallery.mjs +143 -24
  110. package/lib/lean-copy.js +0 -43
  111. package/lib/saas-template.js +0 -568
@@ -16,47 +16,48 @@
16
16
  // /features/boundaries/private throws unauthorized() -> private/unauthorized.ts
17
17
  import { html } from '@webjsdev/core';
18
18
  import type { Metadata } from '@webjsdev/core';
19
+ import { pageHeading, lede } from '#lib/utils/ui.ts';
19
20
 
20
21
  export const metadata: Metadata = { title: 'Boundaries (throws + boundary files) | features' };
21
22
 
22
23
  export default function BoundariesExample() {
23
24
  return html`
24
- <h1 class="text-h2 font-bold mb-4">Boundaries</h1>
25
- <p class="text-muted-foreground mb-4">
25
+ ${pageHeading('Boundaries')}
26
+ ${lede(html`
26
27
  Throw a control-flow function from a page (or a page
27
28
  <code class="font-mono">action</code>) to short-circuit the render. The
28
29
  framework catches it and renders the nearest matching boundary file in the
29
30
  route's chain, innermost wins.
30
- </p>
31
+ `)}
31
32
  <ul class="list-disc pl-5 mb-4">
32
33
  <li>
33
- <a class="text-primary" href="/features/boundaries/gated">/features/boundaries/gated</a>
34
+ <a class="text-primary underline underline-offset-2" href="/features/boundaries/gated">/features/boundaries/gated</a>
34
35
  throws <code class="font-mono">forbidden()</code>, caught by
35
36
  <code class="font-mono">gated/forbidden.ts</code> (403).
36
37
  </li>
37
38
  <li>
38
- <a class="text-primary" href="/features/boundaries/private">/features/boundaries/private</a>
39
+ <a class="text-primary underline underline-offset-2" href="/features/boundaries/private">/features/boundaries/private</a>
39
40
  throws <code class="font-mono">unauthorized()</code>, caught by
40
41
  <code class="font-mono">private/unauthorized.ts</code> (401).
41
42
  </li>
42
43
  <li>
43
- <a class="text-primary" href="/features/boundaries/crash">/features/boundaries/crash</a>
44
+ <a class="text-primary underline underline-offset-2" href="/features/boundaries/crash">/features/boundaries/crash</a>
44
45
  throws a render error, caught by this segment's
45
46
  <code class="font-mono">error.ts</code> (500).
46
47
  </li>
47
48
  <li>
48
- <a class="text-primary" href="/features/boundaries/does-not-exist">/features/boundaries/does-not-exist</a>
49
+ <a class="text-primary underline underline-offset-2" href="/features/boundaries/does-not-exist">/features/boundaries/does-not-exist</a>
49
50
  matches nothing, caught by the nearest
50
51
  <code class="font-mono">not-found.ts</code> (404).
51
52
  </li>
52
53
  </ul>
53
- <p class="text-muted-foreground text-sm mb-2">
54
+ <p class="text-muted-foreground text-sm">
54
55
  <code class="font-mono">forbidden()</code> is for an authenticated user who
55
56
  lacks permission (403); <code class="font-mono">unauthorized()</code> is for
56
57
  a request that is not authenticated at all (401). Both import from
57
58
  <code class="font-mono">@webjsdev/core</code> and are thrown, never returned.
58
59
  </p>
59
- <p class="text-muted-foreground text-sm mb-2">
60
+ <p class="text-muted-foreground text-sm">
60
61
  Same throw model as <code class="font-mono">notFound()</code> (renders the
61
62
  nearest <code class="font-mono">not-found.ts</code>) and
62
63
  <code class="font-mono">redirect(url)</code> (sends an HTTP 3xx). Inside a
@@ -71,6 +72,6 @@ export default function BoundariesExample() {
71
72
  and <code class="font-mono">app/global-not-found.ts</code> (a 404 for a URL
72
73
  that matches nothing anywhere).
73
74
  </p>
74
- <p class="mt-3"><a class="text-primary" href="/">Back to the gallery</a></p>
75
+ <p class="mt-3"><a class="text-primary underline underline-offset-2" href="/">Back to the gallery</a></p>
75
76
  `;
76
77
  }
@@ -3,15 +3,16 @@
3
3
  // in place of the page that threw. Nearest wins: this one (inside private/) beats
4
4
  // any unauthorized.ts higher up the tree. A real one usually links to sign-in.
5
5
  import { html } from '@webjsdev/core';
6
+ import { pageHeading, lede } from '#lib/utils/ui.ts';
6
7
 
7
8
  export default function Unauthorized() {
8
9
  return html`
9
- <h1 class="text-h2 font-bold mb-4">401 Unauthorized</h1>
10
- <p class="text-muted-foreground mb-4">
10
+ ${pageHeading('401 Unauthorized')}
11
+ ${lede(html`
11
12
  You need to sign in to view this page. This is the nearest
12
13
  <code class="font-mono">unauthorized.ts</code> boundary, rendered because the
13
14
  page threw <code class="font-mono">unauthorized()</code>.
14
- </p>
15
- <p><a class="text-primary" href="/features/boundaries">Back to boundaries</a></p>
15
+ `)}
16
+ <p><a class="text-primary underline underline-offset-2" href="/features/boundaries">Back to boundaries</a></p>
16
17
  `;
17
18
  }
@@ -5,19 +5,20 @@
5
5
  // Open this page in two browser tabs and send: both see every message.
6
6
  import { html } from '@webjsdev/core';
7
7
  import type { Metadata } from '@webjsdev/core';
8
+ import { pageHeading, lede } from '#lib/utils/ui.ts';
8
9
  import '#modules/broadcast/components/broadcast-feed.ts';
9
10
 
10
11
  export const metadata: Metadata = { title: 'Broadcast (fan-out to all clients) | features' };
11
12
 
12
13
  export default function BroadcastExample() {
13
14
  return html`
14
- <h1 class="text-h2 font-bold mb-4">Broadcast</h1>
15
- <p class="text-muted-foreground mb-4">
15
+ ${pageHeading('Broadcast')}
16
+ ${lede(html`
16
17
  Every message is fanned out to all connected clients via
17
18
  <code class="font-mono">broadcast()</code>. Open this page in a second tab
18
19
  and watch messages appear in both. Single-instance by default; wire Redis
19
20
  to scale across processes.
20
- </p>
21
+ `)}
21
22
  <broadcast-feed></broadcast-feed>
22
23
  `;
23
24
  }
@@ -7,6 +7,7 @@
7
7
  // use HTTP Cache-Control + ETag (conditional GET).
8
8
  import { html } from '@webjsdev/core';
9
9
  import type { Metadata } from '@webjsdev/core';
10
+ import { pageHeading, lede } from '#lib/utils/ui.ts';
10
11
  import '#modules/caching/components/cache-buster.ts';
11
12
 
12
13
  export const metadata: Metadata = { title: 'Caching (revalidate) | features' };
@@ -19,11 +20,11 @@ export default function CachingExample() {
19
20
  // seconds, so this value is frozen until the window elapses.
20
21
  const renderedAt = new Date().toLocaleTimeString('en-US', { hour12: false });
21
22
  return html`
22
- <h1 class="text-h2 font-bold mb-4">Caching</h1>
23
- <p class="text-muted-foreground mb-4">
23
+ ${pageHeading('Caching')}
24
+ ${lede(html`
24
25
  This page sets <code>export const revalidate = 10</code>, so its
25
26
  server-rendered HTML is cached per URL for ten seconds.
26
- </p>
27
+ `)}
27
28
  <p class="mb-4">
28
29
  Rendered at
29
30
  <code class="font-mono text-primary">${renderedAt}</code>.
@@ -35,7 +36,7 @@ export default function CachingExample() {
35
36
  <code>revalidateTag</code>, or a GET action's
36
37
  <code>export const cache</code>.
37
38
  </p>
38
- <p class="text-muted-foreground text-sm mt-6 mb-2">
39
+ <p class="text-muted-foreground text-sm">
39
40
  A mutation evicts the cache on demand. Click below (it calls
40
41
  <code class="font-mono">revalidatePath('/features/caching')</code>), then refresh:
41
42
  the timestamp updates immediately, even inside the 10s window, because the
@@ -7,24 +7,26 @@
7
7
  // JS off, every link is a normal full-page navigation.
8
8
  import { html } from '@webjsdev/core';
9
9
  import type { Metadata } from '@webjsdev/core';
10
+ import { buttonClass } from '#components/ui/button.ts';
11
+ import { pageHeading, lede } from '#lib/utils/ui.ts';
10
12
  import '#modules/client-router/components/router-controls.ts';
11
13
 
12
14
  export const metadata: Metadata = { title: 'Client router (soft nav) | features' };
13
15
 
14
16
  export default function ClientRouterExample() {
15
17
  return html`
16
- <h1 class="text-h2 font-bold mb-4">Client router</h1>
17
- <p class="text-muted-foreground mb-4">
18
+ ${pageHeading('Client router')}
19
+ ${lede(html`
18
20
  Navigate to the second page and back. With JS on it is a soft swap (no full
19
21
  reload, scroll restored); open the network tab to see only a fragment
20
22
  fetched, prefetched on hover. With JS off the same links do full-page
21
23
  navigations. Nothing was imported to get this.
22
- </p>
24
+ `)}
23
25
  <div class="flex gap-3 items-center">
24
- <a href="/features/client-router/second" 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]">Go to page two</a>
26
+ <a href="/features/client-router/second" class="${buttonClass()} no-underline">Go to page two</a>
25
27
  <a href="/" class="text-muted-foreground no-underline font-medium text-sm hover:text-foreground transition-colors">Home</a>
26
28
  </div>
27
- <p class="text-muted-foreground text-sm mt-6 mb-2">Or drive it from JS with <code class="font-mono">navigate()</code> / <code class="font-mono">revalidate()</code>:</p>
29
+ <p class="text-muted-foreground text-sm mt-6">Or drive it from JS with <code class="font-mono">navigate()</code> / <code class="font-mono">revalidate()</code>:</p>
28
30
  <router-controls></router-controls>
29
31
  <p class="text-muted-foreground text-sm mt-6">
30
32
  Opt out app-wide with <code class="font-mono">{ "webjs": { "clientRouter": false } }</code>,
@@ -3,17 +3,18 @@
3
3
  // its scroll position from the client-router snapshot cache.
4
4
  import { html } from '@webjsdev/core';
5
5
  import type { Metadata } from '@webjsdev/core';
6
+ import { pageHeading, lede } from '#lib/utils/ui.ts';
6
7
 
7
8
  export const metadata: Metadata = { title: 'Client router: page two | features' };
8
9
 
9
10
  export default function ClientRouterSecond() {
10
11
  return html`
11
- <h1 class="text-h2 font-bold mb-4">Page two</h1>
12
- <p class="text-muted-foreground mb-4">
12
+ ${pageHeading('Page two')}
13
+ ${lede(html`
13
14
  You arrived here without a full reload. Press the browser Back button (or
14
15
  the link below): the previous page and its scroll position are restored
15
16
  from the snapshot cache.
16
- </p>
17
+ `)}
17
18
  <a href="/features/client-router" class="text-primary no-underline font-medium">&larr; Back to page one</a>
18
19
  `;
19
20
  }
@@ -1,5 +1,6 @@
1
1
  import { html } from '@webjsdev/core';
2
2
  import type { Metadata } from '@webjsdev/core';
3
+ import { pageHeading, lede } from '#lib/utils/ui.ts';
3
4
  import '#modules/components/components/counter-card.ts';
4
5
  import '#modules/components/components/reactive-meter.ts';
5
6
  import '#modules/components/components/theme-context.ts';
@@ -9,8 +10,8 @@ export const metadata: Metadata = { title: 'Components (signals + slots) | featu
9
10
 
10
11
  export default function ComponentsExample() {
11
12
  return html`
12
- <h1 class="text-h2 font-bold mb-4">Components</h1>
13
- <p class="text-muted-foreground mb-4">The WebComponent factory, a reactive prop, an instance signal, and a slot.</p>
13
+ ${pageHeading('Components')}
14
+ ${lede(html`The WebComponent factory, a reactive prop, an instance signal, and a slot.`)}
14
15
  <counter-card label="Taps"><strong>A slotted title</strong></counter-card>
15
16
  <p class="text-muted-foreground mt-6 mb-2">Shadow DOM (scoped <code class="font-mono">css</code>) plus the rest of the signals API (<code class="font-mono">computed</code>, <code class="font-mono">effect</code>, <code class="font-mono">batch</code>):</p>
16
17
  <reactive-meter></reactive-meter>
@@ -1,13 +1,14 @@
1
1
  import { html } from '@webjsdev/core';
2
2
  import type { Metadata } from '@webjsdev/core';
3
+ import { pageHeading, lede } from '#lib/utils/ui.ts';
3
4
  import '#modules/directives/components/directive-demo.ts';
4
5
 
5
6
  export const metadata: Metadata = { title: 'Directives (repeat + watch) | features' };
6
7
 
7
8
  export default function DirectivesExample() {
8
9
  return html`
9
- <h1 class="text-h2 font-bold mb-4">Directives</h1>
10
- <p class="text-muted-foreground mb-4">The lit-html directive set: <code>repeat</code> keys a reordering list so nodes are reused, and <code>watch(signal)</code> swaps one node without a full re-render.</p>
10
+ ${pageHeading('Directives')}
11
+ ${lede(html`The lit-html directive set: <code>repeat</code> keys a reordering list so nodes are reused, and <code>watch(signal)</code> swaps one node without a full re-render.`)}
11
12
  <directive-demo></directive-demo>
12
13
  `;
13
14
  }
@@ -6,6 +6,7 @@
6
6
  // an app-root env.ts (a schema or a validator fn) that fails fast.
7
7
  import { html } from '@webjsdev/core';
8
8
  import type { Metadata } from '@webjsdev/core';
9
+ import { pageHeading, lede } from '#lib/utils/ui.ts';
9
10
 
10
11
  export const metadata: Metadata = { title: 'Env vars (public vs server) | features' };
11
12
 
@@ -16,11 +17,11 @@ export default function EnvExample() {
16
17
  // add WEBJS_PUBLIC_APP_NAME=... to .env, which demonstrates the default.
17
18
  const publicName = process.env.WEBJS_PUBLIC_APP_NAME || '(unset, add WEBJS_PUBLIC_APP_NAME to .env)';
18
19
  return html`
19
- <h1 class="text-h2 font-bold mb-4">Environment variables</h1>
20
- <p class="text-muted-foreground mb-4">
20
+ ${pageHeading('Environment variables')}
21
+ ${lede(html`
21
22
  Read on the server during SSR. Only <code>WEBJS_PUBLIC_</code>-prefixed
22
23
  names are exposed to the browser; the rest stay server-side.
23
- </p>
24
+ `)}
24
25
  <ul class="list-disc pl-5 mb-4 space-y-1">
25
26
  <li><code class="font-mono text-sm">NODE_ENV</code> = <span class="text-primary">${nodeEnv}</span> <span class="text-muted-foreground text-sm">(defined both sides)</span></li>
26
27
  <li><code class="font-mono text-sm">WEBJS_PUBLIC_APP_NAME</code> = <span class="text-primary">${publicName}</span></li>
@@ -5,6 +5,9 @@
5
5
  // streams the file back through file/[key]/route.ts. Works with JS off; the
6
6
  // client router applies the same flow in place with JS on.
7
7
  import { html } from '@webjsdev/core';
8
+ import { buttonClass } from '#components/ui/button.ts';
9
+ import { cardClass } from '#components/ui/card.ts';
10
+ import { pageHeading, lede } from '#lib/utils/ui.ts';
8
11
  import type { Metadata } from '@webjsdev/core';
9
12
  import { storeUpload } from '#modules/file-storage/actions/store-upload.server.ts';
10
13
 
@@ -33,25 +36,25 @@ export default function FileStorageExample({
33
36
  const name = (searchParams.name || '').trim();
34
37
  const size = (searchParams.size || '').trim();
35
38
  return html`
36
- <h1 class="text-h2 font-bold mb-4">File storage</h1>
37
- <p class="text-muted-foreground mb-4">
39
+ ${pageHeading('File storage')}
40
+ ${lede(html`
38
41
  Upload a file: the bytes stream into the FileStore (a local
39
42
  <code class="font-mono">.webjs/uploads</code> directory by default,
40
43
  gitignored). Swap the backend for S3/R2 with one
41
44
  <code class="font-mono">setFileStore()</code> call, no call-site change.
42
- </p>
45
+ `)}
43
46
  <form method="post" enctype="multipart/form-data" class="flex flex-wrap gap-3 items-center mb-4">
44
47
  <input type="file" name="file" required aria-label="Choose a file to upload"
45
48
  class="text-sm text-muted-foreground file:mr-3 file:px-3.5 file:py-2 file:rounded-xl file:border-0 file:bg-card file:border file:border-border file:text-foreground file:text-sm file:cursor-pointer" />
46
49
  <button type="submit"
47
- class="px-4 py-2 rounded-xl bg-primary text-primary-foreground font-semibold text-sm border-0 cursor-pointer transition-all hover:bg-primary/90 active:scale-[0.97]">Upload</button>
50
+ class=${buttonClass()}>Upload</button>
48
51
  </form>
49
52
  ${actionData?.error
50
53
  ? html`<p class="text-destructive text-sm mb-4">${actionData.error}</p>`
51
54
  : ''}
52
55
  ${key
53
56
  ? html`
54
- <div class="px-4 py-3 rounded-xl bg-card border border-border text-sm">
57
+ <div class="${cardClass('rounded-xl')} px-4 py-3 text-sm">
55
58
  Stored <span class="text-foreground font-medium">${name}</span>
56
59
  <span class="text-muted-foreground">(${size} bytes)</span>
57
60
  <a class="text-primary no-underline ml-2" href="/features/file-storage/file/${key}">download</a>
@@ -6,6 +6,10 @@
6
6
  // On failure the framework re-renders at 422 with the result; on success it
7
7
  // does a 303 Post-Redirect-Get, so we redirect to ?sent=1 to show a confirmation.
8
8
  import { html } from '@webjsdev/core';
9
+ import { cardClass } from '#components/ui/card.ts';
10
+ import { inputClass } from '#components/ui/input.ts';
11
+ import { buttonClass } from '#components/ui/button.ts';
12
+ import { pageHeading } from '#lib/utils/ui.ts';
9
13
  import type { Metadata } from '@webjsdev/core';
10
14
 
11
15
  export const metadata: Metadata = { title: 'Forms (no-JS PE) | features' };
@@ -25,18 +29,18 @@ const field = (label: string, name: string, input: unknown, error?: string) => h
25
29
  </div>
26
30
  `;
27
31
 
28
- const inputCls = 'w-full bg-background border border-border rounded-xl px-3 py-2 text-[15px] text-foreground outline-none transition-colors focus:border-primary placeholder:text-muted-foreground';
32
+ const inputCls = inputClass();
29
33
 
30
34
  export default function FormsFeature({ searchParams, actionData }: { searchParams: Record<string, string | undefined>; actionData?: Result }) {
31
35
  if (searchParams.sent) {
32
36
  return html`
33
- <h1 class="text-h2 font-bold mb-4">Forms</h1>
34
- <div class="max-w-[460px] grid gap-3 p-6 rounded-2xl bg-card border border-border text-center">
37
+ ${pageHeading('Forms')}
38
+ <div class="${cardClass()} max-w-[460px] grid gap-3 p-6 text-center">
35
39
  <span class="mx-auto grid place-items-center w-12 h-12 rounded-2xl bg-primary/15 text-primary">
36
40
  <svg viewBox="0 0 24 24" class="w-6 h-6 stroke-current fill-none" style="stroke-width:2.4;stroke-linecap:round;stroke-linejoin:round"><path d="m5 13 4 4L19 7"/></svg>
37
41
  </span>
38
42
  <p class="m-0 text-lg font-semibold text-foreground">Message sent</p>
39
- <p class="m-0 text-sm text-muted-foreground">Thanks, we got it. <a class="text-primary" href="/features/forms">Send another</a>.</p>
43
+ <p class="m-0 text-sm text-muted-foreground">Thanks, we got it. <a class="text-primary underline underline-offset-2" href="/features/forms">Send another</a>.</p>
40
44
  </div>
41
45
  `;
42
46
  }
@@ -45,11 +49,11 @@ export default function FormsFeature({ searchParams, actionData }: { searchParam
45
49
  return html`
46
50
  <h1 class="text-h2 font-bold mb-2">Forms</h1>
47
51
  <p class="text-muted-foreground mb-5 max-w-[460px]">A real <code>&lt;form&gt;</code> posting to this page's <code>action</code>. It works with JS off; validation errors come back on <code>actionData</code>.</p>
48
- <form method="post" action="" class="max-w-[460px] grid gap-4 p-5 rounded-2xl bg-card border border-border">
52
+ <form method="post" action="" class="${cardClass()} max-w-[460px] grid gap-4 p-5">
49
53
  ${field('Name', 'name', html`<input id="name" name="name" value=${v.name ?? ''} class=${inputCls} placeholder="Ada Lovelace" />`, errs.name)}
50
54
  ${field('Email', 'email', html`<input id="email" name="email" type="email" value=${v.email ?? ''} class=${inputCls} placeholder="ada@example.com" />`, errs.email)}
51
55
  ${field('Message', 'message', html`<textarea id="message" name="message" rows="3" class=${inputCls} placeholder="Say hello...">${v.message ?? ''}</textarea>`, errs.message)}
52
- <button type="submit" class="justify-self-start px-4 py-2 rounded-xl bg-primary text-primary-foreground font-semibold text-sm border-0 cursor-pointer transition-all hover:bg-primary/90 active:scale-[0.97]">Send message</button>
56
+ <button type="submit" class="${buttonClass()} justify-self-start">Send message</button>
53
57
  </form>
54
58
  `;
55
59
  }
@@ -2,7 +2,13 @@
2
2
  // link targeting its id, shipping zero component JS. It is WebJs's take on Turbo
3
3
  // Frames. Unlike the client router (which swaps the whole page's children when
4
4
  // you navigate to a DIFFERENT url), a frame refreshes just ONE sub-region in
5
- // place. The filter links below live INSIDE the frame, so a click walks
5
+ // place. It is also NOT a layout: a layout (layout.ts) is server-rendered chrome
6
+ // that WRAPS a route subtree via ${children} and re-renders only as part of a
7
+ // navigation, so it answers "what structure wraps these routes". A frame answers
8
+ // "which region updates itself in place", with no navigation at all. Use a layout
9
+ // for shared chrome across routes; use a frame when one region (a filtered list,
10
+ // a paginated table, a tab panel) must refresh on its own without navigating.
11
+ // The filter links below live INSIDE the frame, so a click walks
6
12
  // closest('webjs-frame'), refetches THIS same page with the new ?status, and the
7
13
  // server returns ONLY the <webjs-frame id="tasks"> subtree (open the network tab
8
14
  // to see it). The router swaps that subtree in; everything outside the frame,
@@ -14,6 +20,10 @@
14
20
  // requirement. The frame element itself upgrades because the root layout ships a
15
21
  // component (the theme toggle), so @webjsdev/core and the router load app-wide.
16
22
  import { html } from '@webjsdev/core';
23
+ import { cardClass } from '#components/ui/card.ts';
24
+ import { buttonClass } from '#components/ui/button.ts';
25
+ import { cn } from '#lib/utils/cn.ts';
26
+ import { pageHeading, lede } from '#lib/utils/ui.ts';
17
27
  import type { Metadata } from '@webjsdev/core';
18
28
  import { filterTasks, normalizeStatus, type Status } from '#modules/frames/utils/tasks.ts';
19
29
 
@@ -24,10 +34,14 @@ export const metadata: Metadata = { title: 'Frames (webjs-frame partial swap) |
24
34
  // link OUTSIDE the frame would drive it from anywhere via data-webjs-frame="tasks".
25
35
  function filterTab(current: Status, status: Status, label: string) {
26
36
  const active = current === status;
27
- const base = 'px-3 py-1.5 rounded-lg font-semibold text-sm no-underline transition-colors';
28
- const cls = active
29
- ? base + ' bg-primary text-primary-foreground'
30
- : base + ' bg-card border border-border text-foreground font-medium hover:border-border-strong';
37
+ // A segmented control: keep ONE variant (constant weight, border, padding) so
38
+ // toggling the active tab never reflows the row, overriding only the active
39
+ // colors with cn().
40
+ const cls = cn(
41
+ buttonClass({ variant: 'secondary', size: 'sm' }),
42
+ 'no-underline',
43
+ active && 'bg-primary text-primary-foreground border-primary hover:border-primary',
44
+ );
31
45
  return html`<a href="/features/frames?status=${status}" class=${cls}>${label}</a>`;
32
46
  }
33
47
 
@@ -35,14 +49,16 @@ export default function FramesExample({ searchParams }: { searchParams: Record<s
35
49
  const status = normalizeStatus(searchParams?.status);
36
50
  const tasks = filterTasks(status);
37
51
  return html`
38
- <h1 class="text-h2 font-bold mb-4">Frames</h1>
39
- <p class="text-muted-foreground mb-4">
52
+ ${pageHeading('Frames')}
53
+ ${lede(html`
40
54
  Filter the list. With JS on, only the framed region swaps (the response is
41
55
  just the frame's subtree, not the whole page) and the heading above never
42
56
  re-renders. With JS off, the same links do full-page navigations. It is one
43
- region refreshing independently of a navigation, which a page cannot express.
44
- </p>
45
- <webjs-frame id="tasks" class="block p-4 rounded-2xl bg-card border border-border">
57
+ region refreshing independently of a navigation, which neither a page nor a
58
+ layout can express: a layout wraps routes with shared chrome and only
59
+ re-renders when you navigate, while a frame updates one region in place.
60
+ `)}
61
+ <webjs-frame id="tasks" class="${cardClass()} block p-4">
46
62
  <div class="flex gap-2 mb-4">
47
63
  ${filterTab(status, 'all', 'All')}
48
64
  ${filterTab(status, 'active', 'Active')}
@@ -0,0 +1,67 @@
1
+ import { html } from '@webjsdev/core';
2
+ import { backLink } from '#lib/utils/ui.ts';
3
+ import '#modules/gallery/components/gallery-nav.ts';
4
+
5
+ // A subtle, hover-revealed scrollbar for the sidebar (macOS overlay feel): the
6
+ // track + thumb are transparent while idle (no bar), and fade in on hover /
7
+ // keyboard focus, then fade out again. A layout may interpolate CSS into a
8
+ // <style> (it never hydrates, unlike a component), so this is legitimate here.
9
+ const SIDENAV_CSS = `
10
+ .gallery-sidenav { scrollbar-width: thin; scrollbar-color: transparent transparent; transition: scrollbar-color .2s ease; }
11
+ .gallery-sidenav:hover, .gallery-sidenav:focus-within { scrollbar-color: color-mix(in oklch, var(--foreground) 22%, transparent) transparent; }
12
+ .gallery-sidenav::-webkit-scrollbar { width: 8px; }
13
+ .gallery-sidenav::-webkit-scrollbar-track { background: transparent; }
14
+ .gallery-sidenav::-webkit-scrollbar-thumb { background-color: transparent; border-radius: 999px; border: 2px solid transparent; background-clip: padding-box; transition: background-color .2s ease; }
15
+ .gallery-sidenav:hover::-webkit-scrollbar-thumb, .gallery-sidenav:focus-within::-webkit-scrollbar-thumb { background-color: color-mix(in oklch, var(--foreground) 22%, transparent); }
16
+ .gallery-sidenav::-webkit-scrollbar-thumb:hover { background-color: color-mix(in oklch, var(--foreground) 40%, transparent); }
17
+ `;
18
+
19
+ // Section rhythm: ONE uniform gap between every top-level block of a demo
20
+ // (heading, lede, paragraph, component, list, anything), so spacing stays
21
+ // consistent with NO per-element margins. This is the single place that controls
22
+ // demo spacing. A flex column with `gap` does it WITHOUT touching each child's
23
+ // own display: a `grid`/`flex` child (like the forms card) keeps its layout, a
24
+ // shadow-DOM host (inline by default) is blockified as a flex item so it honours
25
+ // the gap, a display:contents <webjs-suspense> is replaced by its children (which
26
+ // become the flex items and get the gap), and a display:none streaming
27
+ // <script>/<template> is not an item at all (no phantom gap).
28
+ const STACK_CSS = `
29
+ .demo-stack {
30
+ --section-gap: 1.5rem;
31
+ display: flex;
32
+ flex-direction: column;
33
+ gap: var(--section-gap);
34
+ }
35
+ /* Zero any BLOCK-axis margins so ONLY the flex gap spaces sections. Block
36
+ axis only: this style is unlayered so it beats Tailwind's layered
37
+ utilities, and a full margin: 0 would also kill mx-auto (an auth card's
38
+ horizontal centering). */
39
+ .demo-stack > * { margin-block: 0; }
40
+ `;
41
+
42
+ // Shared layout for every gallery feature demo under /features/*. A docs-style
43
+ // two-column shell: a grouped sidebar (the <gallery-nav> component, which tracks
44
+ // the active demo across soft navigation) plus the demo content. On mobile the
45
+ // sidebar is hidden, so a slim back link keeps a demo from being a dead end.
46
+ // Nested layouts (like the auth dashboard's sub-nav) render inside ${children}.
47
+ // A non-root layout, so it never writes the document shell (the framework does).
48
+ export default function FeaturesLayout({ children, url }: { children: unknown; url: URL | string }) {
49
+ const path = typeof url === 'string' ? new URL(url, 'http://x').pathname : url.pathname;
50
+ return html`
51
+ <style>${SIDENAV_CSS}${STACK_CSS}</style>
52
+ <div class="lg:hidden mb-6">${backLink('/', html`&larr; Gallery`)}</div>
53
+ <div class="grid lg:grid-cols-[190px_1fr] gap-8 lg:gap-12">
54
+ <!-- The sidebar caps at the viewport minus the root chrome (the h-14 navbar
55
+ = 3.5rem plus the root <main>'s py-8 = 4rem, so 7.5rem) and scrolls
56
+ internally, so a short demo never forces a page scrollbar. -->
57
+ <aside class="hidden lg:flex lg:flex-col sticky top-6 self-start max-h-[calc(100dvh-7.5rem)] text-sm">
58
+ <!-- Pinned header: stays put while the demo list below scrolls. -->
59
+ <a href="/" class="shrink-0 block px-3 py-1.5 mb-4 rounded-lg no-underline text-muted-foreground hover:text-foreground transition-colors">&larr; Gallery</a>
60
+ <div class="gallery-sidenav min-h-0 overflow-y-auto overflow-x-hidden -mr-2 pr-2">
61
+ <gallery-nav current=${path}></gallery-nav>
62
+ </div>
63
+ </aside>
64
+ <div class="min-w-0 demo-stack">${children}</div>
65
+ </div>
66
+ `;
67
+ }
@@ -6,6 +6,7 @@
6
6
  // (sitemap.ts, robots.ts, opengraph-image.ts, ...) live at the app root.
7
7
  import { html } from '@webjsdev/core';
8
8
  import type { Metadata } from '@webjsdev/core';
9
+ import { pageHeading, lede } from '#lib/utils/ui.ts';
9
10
 
10
11
  // generateMetadata wins over a static `metadata` export when both exist. Here
11
12
  // it derives the title from a ?topic= query param to show the dynamic form;
@@ -31,20 +32,20 @@ export default function MetadataExample({
31
32
  }) {
32
33
  const topic = (searchParams.topic || '').trim();
33
34
  return html`
34
- <h1 class="text-h2 font-bold mb-4">Metadata</h1>
35
- <p class="text-muted-foreground mb-4">
35
+ ${pageHeading('Metadata')}
36
+ ${lede(html`
36
37
  <code>generateMetadata(ctx)</code> runs on the server and can read the
37
38
  request, so the <code>&lt;title&gt;</code> is computed per URL. View source
38
39
  to see the tag this page produced.
39
- </p>
40
+ `)}
40
41
  <p class="mb-4">
41
42
  Current title source:
42
43
  <code class="font-mono text-sm">${topic ? '?topic=' + topic : '(default, no ?topic=)'}</code>
43
44
  </p>
44
45
  <ul class="list-disc pl-5 mb-4">
45
- <li><a class="text-primary" href="/features/metadata?topic=webjs">?topic=webjs</a></li>
46
- <li><a class="text-primary" href="/features/metadata?topic=Routing">?topic=Routing</a></li>
47
- <li><a class="text-primary" href="/features/metadata">clear the param</a></li>
46
+ <li><a class="text-primary underline underline-offset-2" href="/features/metadata?topic=webjs">?topic=webjs</a></li>
47
+ <li><a class="text-primary underline underline-offset-2" href="/features/metadata?topic=Routing">?topic=Routing</a></li>
48
+ <li><a class="text-primary underline underline-offset-2" href="/features/metadata">clear the param</a></li>
48
49
  </ul>
49
50
  <p class="text-muted-foreground text-sm">
50
51
  Site-wide metadata (sitemap, robots, Open Graph images) lives in metadata
@@ -1,13 +1,14 @@
1
1
  import { html } from '@webjsdev/core';
2
2
  import type { Metadata } from '@webjsdev/core';
3
+ import { pageHeading, lede } from '#lib/utils/ui.ts';
3
4
  import '#modules/optimistic-ui/components/like-button.ts';
4
5
 
5
6
  export const metadata: Metadata = { title: 'Optimistic UI (imperative flip) | features' };
6
7
 
7
8
  export default function OptimisticUiFeature() {
8
9
  return html`
9
- <h1 class="text-h2 font-bold mb-4">Optimistic UI</h1>
10
- <p class="text-muted-foreground mb-4">The imperative <code>optimistic(signal, value, action)</code> form: the UI flips instantly and rolls back if the action fails. For the declarative list form (add / remove with rollback) in a full app, see <a class="text-primary" href="/examples/todo">/examples/todo</a>.</p>
10
+ ${pageHeading('Optimistic UI')}
11
+ ${lede(html`The imperative <code>optimistic(signal, value, action)</code> form: the UI flips instantly and rolls back if the action fails. For the declarative list form (add / remove with rollback) in a full app, see <a class="text-primary underline underline-offset-2" href="/examples/todo">/examples/todo</a>.`)}
11
12
  <like-button></like-button>
12
13
  `;
13
14
  }
@@ -5,23 +5,24 @@
5
5
  // default; point the store at Redis to share the window across instances.
6
6
  import { html } from '@webjsdev/core';
7
7
  import type { Metadata } from '@webjsdev/core';
8
+ import { pageHeading, lede } from '#lib/utils/ui.ts';
8
9
  import '#modules/rate-limit/components/rate-probe.ts';
9
10
 
10
11
  export const metadata: Metadata = { title: 'Rate limiting (rateLimit middleware) | features' };
11
12
 
12
13
  export default function RateLimitExample() {
13
14
  return html`
14
- <h1 class="text-h2 font-bold mb-4">Rate limiting</h1>
15
- <p class="text-muted-foreground mb-4">
15
+ ${pageHeading('Rate limiting')}
16
+ ${lede(html`
16
17
  The <code class="font-mono">/ping</code> endpoint is limited to five
17
18
  requests per ten seconds by a scoped <code class="font-mono">middleware.ts</code>.
18
19
  Ping past the limit to get a <code class="font-mono">429</code> with a
19
20
  <code class="font-mono">Retry-After</code> header.
20
- </p>
21
+ `)}
21
22
  <rate-probe></rate-probe>
22
- <p class="text-muted-foreground text-sm mt-4">
23
+ <p class="text-muted-foreground text-sm">
23
24
  With JavaScript off, hit
24
- <a class="text-primary" href="/features/rate-limit/ping" data-no-router>/features/rate-limit/ping</a>
25
+ <a class="text-primary underline underline-offset-2" href="/features/rate-limit/ping" data-no-router>/features/rate-limit/ping</a>
25
26
  directly (refresh past five times in ten seconds).
26
27
  </p>
27
28
  `;
@@ -1,14 +1,15 @@
1
1
  import { html } from '@webjsdev/core';
2
2
  import type { Metadata } from '@webjsdev/core';
3
+ import { pageHeading, lede } from '#lib/utils/ui.ts';
3
4
  import '#modules/route-handler/components/rich-data.ts';
4
5
 
5
6
  export const metadata: Metadata = { title: 'Route handlers (route.ts) | features' };
6
7
 
7
8
  export default function RouteHandlerExample() {
8
9
  return html`
9
- <h1 class="text-h2 font-bold mb-4">Route handlers</h1>
10
- <p class="text-muted-foreground mb-4">A <code>route.ts</code> is a server-only HTTP endpoint (named <code>GET</code>/<code>POST</code>/... exports), the WebJs equivalent of a Next route handler. It never ships to the client.</p>
11
- <p>GET <a class="text-primary" href="/features/route-handler/data" data-no-router>/features/route-handler/data</a> returns rich JSON via <code class="font-mono">json()</code>. It carries <code class="font-mono">data-no-router</code> so the client router does not try to soft-navigate to it: a <code>route.ts</code> is not a page, so the browser loads its JSON directly.</p>
10
+ ${pageHeading('Route handlers')}
11
+ ${lede(html`A <code>route.ts</code> is a server-only HTTP endpoint (named <code>GET</code>/<code>POST</code>/... exports), the WebJs equivalent of a Next route handler. It never ships to the client.`)}
12
+ <p>GET <a class="text-primary underline underline-offset-2" href="/features/route-handler/data" data-no-router>/features/route-handler/data</a> returns rich JSON via <code class="font-mono">json()</code>. It carries <code class="font-mono">data-no-router</code> so the client router does not try to soft-navigate to it: a <code>route.ts</code> is not a page, so the browser loads its JSON directly.</p>
12
13
  <p class="text-muted-foreground mt-6 mb-2">A client component reading it with <code class="font-mono">richFetch</code>, so <code class="font-mono">at</code> comes back as a real <code class="font-mono">Date</code>:</p>
13
14
  <rich-data></rich-data>
14
15
  `;