@webjsdev/cli 0.10.45 → 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 (90) hide show
  1. package/lib/create.js +109 -141
  2. package/package.json +1 -1
  3. package/templates/.agents/rules/workflow.md +7 -3
  4. package/templates/.agents/skills/webjs/SKILL.md +4 -2
  5. package/templates/.agents/skills/webjs/references/auth-and-sessions.md +78 -16
  6. package/templates/.agents/skills/webjs/references/built-ins.md +16 -2
  7. package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +28 -4
  8. package/templates/.agents/skills/webjs/references/components.md +82 -2
  9. package/templates/.agents/skills/webjs/references/data-and-actions.md +19 -2
  10. package/templates/.agents/skills/webjs/references/optimistic-ui.md +18 -0
  11. package/templates/.agents/skills/webjs/references/routing-and-pages.md +25 -2
  12. package/templates/.agents/skills/webjs/references/styling.md +82 -2
  13. package/templates/AGENTS.md +12 -5
  14. package/templates/gallery/app/apple-icon.ts +2 -5
  15. package/templates/gallery/app/examples/layout.ts +7 -2
  16. package/templates/gallery/app/examples/todo/page.ts +2 -1
  17. package/templates/gallery/app/features/async-render/page.ts +3 -2
  18. package/templates/gallery/app/features/auth/dashboard/layout.ts +2 -1
  19. package/templates/gallery/app/features/auth/dashboard/page.ts +4 -2
  20. package/templates/gallery/app/features/auth/dashboard/settings/page.ts +2 -1
  21. package/templates/gallery/app/features/auth/login/middleware.ts +15 -0
  22. package/templates/gallery/app/features/auth/login/page.ts +7 -4
  23. package/templates/gallery/app/features/auth/page.ts +6 -5
  24. package/templates/gallery/app/features/auth/signup/middleware.ts +11 -0
  25. package/templates/gallery/app/features/auth/signup/page.ts +7 -4
  26. package/templates/gallery/app/features/boundaries/error.ts +5 -4
  27. package/templates/gallery/app/features/boundaries/gated/forbidden.ts +5 -4
  28. package/templates/gallery/app/features/boundaries/not-found.ts +5 -4
  29. package/templates/gallery/app/features/boundaries/page.ts +11 -10
  30. package/templates/gallery/app/features/boundaries/private/unauthorized.ts +5 -4
  31. package/templates/gallery/app/features/broadcast/page.ts +4 -3
  32. package/templates/gallery/app/features/caching/page.ts +5 -4
  33. package/templates/gallery/app/features/client-router/page.ts +7 -5
  34. package/templates/gallery/app/features/client-router/second/page.ts +4 -3
  35. package/templates/gallery/app/features/components/page.ts +3 -2
  36. package/templates/gallery/app/features/directives/page.ts +3 -2
  37. package/templates/gallery/app/features/env/page.ts +4 -3
  38. package/templates/gallery/app/features/file-storage/page.ts +8 -5
  39. package/templates/gallery/app/features/forms/page.ts +10 -6
  40. package/templates/gallery/app/features/frames/page.ts +16 -8
  41. package/templates/gallery/app/features/layout.ts +60 -5
  42. package/templates/gallery/app/features/metadata/page.ts +7 -6
  43. package/templates/gallery/app/features/optimistic-ui/page.ts +3 -2
  44. package/templates/gallery/app/features/rate-limit/page.ts +6 -5
  45. package/templates/gallery/app/features/route-handler/page.ts +4 -3
  46. package/templates/gallery/app/features/routing/[id]/page.ts +7 -6
  47. package/templates/gallery/app/features/routing/page.ts +10 -9
  48. package/templates/gallery/app/features/server-actions/page.ts +5 -4
  49. package/templates/gallery/app/features/service-worker/page.ts +4 -3
  50. package/templates/gallery/app/features/sessions/page.ts +5 -4
  51. package/templates/gallery/app/features/stream/page.ts +4 -3
  52. package/templates/gallery/app/features/streaming/page.ts +4 -3
  53. package/templates/gallery/app/features/suspense/page.ts +4 -3
  54. package/templates/gallery/app/features/view-transitions/page.ts +6 -3
  55. package/templates/gallery/app/features/view-transitions/second/page.ts +4 -2
  56. package/templates/gallery/app/features/websockets/page.ts +4 -3
  57. package/templates/gallery/app/global-error.ts +2 -5
  58. package/templates/gallery/app/global-not-found.ts +4 -6
  59. package/templates/gallery/app/icon.ts +2 -5
  60. package/templates/gallery/app/manifest.ts +1 -4
  61. package/templates/gallery/app/opengraph-image.ts +3 -6
  62. package/templates/gallery/app/robots.ts +0 -3
  63. package/templates/gallery/app/sitemap.ts +0 -3
  64. package/templates/gallery/app/twitter-image.ts +3 -6
  65. package/templates/gallery/components/ui/badge.ts +41 -0
  66. package/templates/gallery/components/ui/button.ts +86 -0
  67. package/templates/gallery/components/ui/card.ts +36 -0
  68. package/templates/gallery/components/ui/input.ts +50 -0
  69. package/templates/gallery/lib/utils/ui.ts +31 -0
  70. package/templates/gallery/modules/broadcast/components/broadcast-feed.ts +4 -2
  71. package/templates/gallery/modules/caching/components/cache-buster.ts +2 -1
  72. package/templates/gallery/modules/client-router/components/router-controls.ts +4 -3
  73. package/templates/gallery/modules/components/components/counter-card.ts +4 -2
  74. package/templates/gallery/modules/components/components/reactive-meter.ts +9 -1
  75. package/templates/gallery/modules/components/components/task-loader.ts +3 -2
  76. package/templates/gallery/modules/components/components/theme-context.ts +5 -3
  77. package/templates/gallery/modules/directives/components/directive-demo.ts +17 -10
  78. package/templates/gallery/modules/gallery/components/gallery-nav.ts +54 -0
  79. package/templates/gallery/modules/gallery/nav.ts +79 -0
  80. package/templates/gallery/modules/optimistic-ui/components/like-button.ts +19 -1
  81. package/templates/gallery/modules/rate-limit/components/rate-probe.ts +2 -1
  82. package/templates/gallery/modules/route-handler/components/rich-data.ts +2 -1
  83. package/templates/gallery/modules/server-actions/components/greeter.ts +7 -4
  84. package/templates/gallery/modules/stream/components/stream-demo.ts +10 -5
  85. package/templates/gallery/modules/streaming/components/token-stream.ts +10 -3
  86. package/templates/gallery/modules/suspense/components/slow-fact.ts +2 -1
  87. package/templates/gallery/modules/todo/components/todo-app.ts +8 -4
  88. package/templates/gallery/modules/websockets/components/ws-echo.ts +5 -3
  89. package/templates/public/favicon.svg +10 -3
  90. package/templates/scripts/clear-gallery.mjs +126 -19
@@ -1,8 +1,11 @@
1
1
  import { html } from '@webjsdev/core';
2
+ import { cardClass } from '#components/ui/card.ts';
3
+ import { inputClass } from '#components/ui/input.ts';
4
+ import { buttonClass } from '#components/ui/button.ts';
2
5
 
3
6
  export const metadata = { title: 'Log in' };
4
7
 
5
- 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';
8
+ const inputCls = inputClass();
6
9
 
7
10
  // A failed sign-in 302s back here with ?error=... (createAuth is configured with
8
11
  // pages.error: '/features/auth/login' in modules/auth/auth.server.ts). Map the
@@ -21,7 +24,7 @@ export default function LoginPage({ searchParams }: { searchParams: { error?: st
21
24
  <h1 class="text-h2 font-bold mb-2">Sign in</h1>
22
25
  <p class="text-muted-foreground mb-5">Welcome back: log in to continue.</p>
23
26
  ${error ? html`<p role="alert" class="mb-4 text-sm text-destructive">${error}</p>` : ''}
24
- <form method="POST" action="/api/auth/signin/credentials" class="grid gap-4 p-5 rounded-2xl bg-card border border-border">
27
+ <form method="POST" action="/api/auth/signin/credentials" class="${cardClass()} grid gap-4 p-5">
25
28
  <!-- createAuth reads redirectTo from the posted form and 302s there after a successful signin. -->
26
29
  <input type="hidden" name="redirectTo" value="/features/auth/dashboard">
27
30
  <div class="grid gap-1.5">
@@ -32,9 +35,9 @@ export default function LoginPage({ searchParams }: { searchParams: { error?: st
32
35
  <label for="password" class="text-[13px] font-medium text-muted-foreground">Password</label>
33
36
  <input id="password" name="password" type="password" required class=${inputCls} />
34
37
  </div>
35
- <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]">Sign in</button>
38
+ <button type="submit" class="${buttonClass()} justify-self-start">Sign in</button>
36
39
  </form>
37
- <p class="text-sm text-muted-foreground mt-4">Don't have an account? <a href="/features/auth/signup" class="text-primary">Sign up</a></p>
40
+ <p class="text-sm text-muted-foreground mt-4">Don't have an account? <a href="/features/auth/signup" class="text-primary underline underline-offset-2">Sign up</a></p>
38
41
  </div>
39
42
  `;
40
43
  }
@@ -10,6 +10,7 @@
10
10
  // line is the real query during SSR and a safe RPC stub on the client.
11
11
  import { html } from '@webjsdev/core';
12
12
  import type { Metadata } from '@webjsdev/core';
13
+ import { pageHeading, lede } from '#lib/utils/ui.ts';
13
14
  import { currentUser } from '#modules/auth/queries/current-user.server.ts';
14
15
 
15
16
  export const metadata: Metadata = { title: 'Auth (login + protected route) | features' };
@@ -17,16 +18,16 @@ export const metadata: Metadata = { title: 'Auth (login + protected route) | fea
17
18
  export default async function AuthExample() {
18
19
  const user = await currentUser();
19
20
  return html`
20
- <h1 class="text-h2 font-bold mb-4">Auth</h1>
21
- <p class="text-muted-foreground mb-4">Password login on <code>createAuth</code>, a signed session cookie, and a protected <code>/features/auth/dashboard</code> that redirects anonymous visitors to login.</p>
21
+ ${pageHeading('Auth')}
22
+ ${lede(html`Password login on <code>createAuth</code>, a signed session cookie, and a protected <code>/features/auth/dashboard</code> that redirects anonymous visitors to login.`)}
22
23
 
23
24
  ${user
24
25
  ? html`
25
26
  <p class="mb-4">Signed in as <strong>${user.name || user.email}</strong>.</p>
26
- <p><a class="text-primary" href="/features/auth/dashboard">Open the protected dashboard</a></p>`
27
+ <p><a class="text-primary underline underline-offset-2" href="/features/auth/dashboard">Open the protected dashboard</a></p>`
27
28
  : html`
28
- <p class="mb-4">You are signed out. <a class="text-primary" href="/features/auth/dashboard">Visiting the dashboard</a> bounces you to login.</p>
29
- <p class="flex gap-4"><a class="text-primary" href="/features/auth/login">Log in</a><a class="text-primary" href="/features/auth/signup">Create an account</a></p>`}
29
+ <p class="mb-4">You are signed out. <a class="text-primary underline underline-offset-2" href="/features/auth/dashboard">Visiting the dashboard</a> bounces you to login.</p>
30
+ <p class="flex gap-4"><a class="text-primary underline underline-offset-2" href="/features/auth/login">Log in</a><a class="text-primary underline underline-offset-2" href="/features/auth/signup">Create an account</a></p>`}
30
31
 
31
32
  <p class="text-muted-foreground text-sm mt-6">The gate is <code class="font-mono">app/features/auth/dashboard/middleware.ts</code> calling <code class="font-mono">auth(req)</code>; the login form posts to the <code class="font-mono">app/api/auth/[...path]</code> handler; OAuth (GitHub / Google) activates once you set the matching env vars.</p>
32
33
  `;
@@ -0,0 +1,11 @@
1
+ import { auth } from '#modules/auth/auth.server.ts';
2
+
3
+ // Like the login middleware: an already-signed-in visitor has no reason to see
4
+ // the signup form, so send them to the dashboard before the page renders.
5
+ export default async function redirectIfSignedIn(req: Request, next: () => Promise<Response>) {
6
+ const session = await auth(req);
7
+ if (session?.user) {
8
+ return new Response(null, { status: 302, headers: { location: '/features/auth/dashboard' } });
9
+ }
10
+ return next();
11
+ }
@@ -1,9 +1,12 @@
1
1
  import { html } from '@webjsdev/core';
2
+ import { cardClass } from '#components/ui/card.ts';
3
+ import { inputClass } from '#components/ui/input.ts';
4
+ import { buttonClass } from '#components/ui/button.ts';
2
5
  import { signup } from '#modules/auth/actions/signup.server.ts';
3
6
 
4
7
  export const metadata = { title: 'Sign up' };
5
8
 
6
- 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';
9
+ const inputCls = inputClass();
7
10
 
8
11
  // Page server action: handles the POST from the form below. With JS disabled this
9
12
  // is a plain <form> round-trip; with JS the client router swaps the 422 re-render
@@ -34,7 +37,7 @@ export default function SignupPage({ actionData }: { actionData?: { fieldErrors?
34
37
  <div class="max-w-[420px] mx-auto">
35
38
  <h1 class="text-h2 font-bold mb-2">Create an account</h1>
36
39
  <p class="text-muted-foreground mb-5">Get started with your new workspace.</p>
37
- <form method="POST" class="grid gap-4 p-5 rounded-2xl bg-card border border-border">
40
+ <form method="POST" class="${cardClass()} grid gap-4 p-5">
38
41
  <div class="grid gap-1.5">
39
42
  <label for="name" class="text-[13px] font-medium text-muted-foreground">Name</label>
40
43
  <input id="name" name="name" type="text" value=${values.name || ''} required class=${inputCls} placeholder="Ada Lovelace" />
@@ -50,9 +53,9 @@ export default function SignupPage({ actionData }: { actionData?: { fieldErrors?
50
53
  <input id="password" name="password" type="password" minlength="8" required class=${inputCls} />
51
54
  ${errors.password ? html`<p class="m-0 text-[12.5px] text-destructive">${errors.password}</p>` : ''}
52
55
  </div>
53
- <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]">Create account</button>
56
+ <button type="submit" class="${buttonClass()} justify-self-start">Create account</button>
54
57
  </form>
55
- <p class="text-sm text-muted-foreground mt-4">Already have an account? <a href="/features/auth/login" class="text-primary">Log in</a></p>
58
+ <p class="text-sm text-muted-foreground mt-4">Already have an account? <a href="/features/auth/login" class="text-primary underline underline-offset-2">Log in</a></p>
56
59
  </div>
57
60
  `;
58
61
  }
@@ -3,14 +3,15 @@
3
3
  // rendered scoped to this boundary, so outer layouts stay alive. The default
4
4
  // export receives { error, ...ctx }; in production only error.message is sent.
5
5
  import { html } from '@webjsdev/core';
6
+ import { pageHeading, lede } from '#lib/utils/ui.ts';
6
7
 
7
8
  export default function BoundariesError({ error }: { error: Error }) {
8
9
  return html`
9
- <h1 class="text-h2 font-bold mb-4">Something went wrong</h1>
10
- <p class="text-muted-foreground mb-4">
10
+ ${pageHeading('Something went wrong')}
11
+ ${lede(html`
11
12
  This segment's <code class="font-mono">error.ts</code> boundary caught a
12
13
  render error: <code class="font-mono">${error?.message ?? 'unknown'}</code>.
13
- </p>
14
- <p><a class="text-primary" href="/features/boundaries">Back to boundaries</a></p>
14
+ `)}
15
+ <p><a class="text-primary underline underline-offset-2" href="/features/boundaries">Back to boundaries</a></p>
15
16
  `;
16
17
  }
@@ -4,15 +4,16 @@
4
4
  // forbidden.ts higher up the tree. Keep the message actionable for an
5
5
  // authenticated user who lacks permission.
6
6
  import { html } from '@webjsdev/core';
7
+ import { pageHeading, lede } from '#lib/utils/ui.ts';
7
8
 
8
9
  export default function Forbidden() {
9
10
  return html`
10
- <h1 class="text-h2 font-bold mb-4">403 Forbidden</h1>
11
- <p class="text-muted-foreground mb-4">
11
+ ${pageHeading('403 Forbidden')}
12
+ ${lede(html`
12
13
  You are signed in but do not have permission to view this page. This is the
13
14
  nearest <code class="font-mono">forbidden.ts</code> boundary, rendered
14
15
  because the page threw <code class="font-mono">forbidden()</code>.
15
- </p>
16
- <p><a class="text-primary" href="/features/boundaries">Back to boundaries</a></p>
16
+ `)}
17
+ <p><a class="text-primary underline underline-offset-2" href="/features/boundaries">Back to boundaries</a></p>
17
18
  `;
18
19
  }
@@ -3,15 +3,16 @@
3
3
  // /features/boundaries/ (try /features/boundaries/does-not-exist). Nearest wins,
4
4
  // so this beats the root not-found for anything in this segment.
5
5
  import { html } from '@webjsdev/core';
6
+ import { pageHeading, lede } from '#lib/utils/ui.ts';
6
7
 
7
8
  export default function BoundariesNotFound() {
8
9
  return html`
9
- <h1 class="text-h2 font-bold mb-4">404: Not here</h1>
10
- <p class="text-muted-foreground mb-4">
10
+ ${pageHeading('404: Not here')}
11
+ ${lede(html`
11
12
  This segment's <code class="font-mono">not-found.ts</code> boundary rendered,
12
13
  because a page threw <code class="font-mono">notFound()</code> or the URL
13
14
  matched nothing under this segment.
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
  }
@@ -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
  }
@@ -20,6 +20,10 @@
20
20
  // requirement. The frame element itself upgrades because the root layout ships a
21
21
  // component (the theme toggle), so @webjsdev/core and the router load app-wide.
22
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';
23
27
  import type { Metadata } from '@webjsdev/core';
24
28
  import { filterTasks, normalizeStatus, type Status } from '#modules/frames/utils/tasks.ts';
25
29
 
@@ -30,10 +34,14 @@ export const metadata: Metadata = { title: 'Frames (webjs-frame partial swap) |
30
34
  // link OUTSIDE the frame would drive it from anywhere via data-webjs-frame="tasks".
31
35
  function filterTab(current: Status, status: Status, label: string) {
32
36
  const active = current === status;
33
- const base = 'px-3 py-1.5 rounded-lg font-semibold text-sm no-underline transition-colors';
34
- const cls = active
35
- ? base + ' bg-primary text-primary-foreground'
36
- : 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
+ );
37
45
  return html`<a href="/features/frames?status=${status}" class=${cls}>${label}</a>`;
38
46
  }
39
47
 
@@ -41,16 +49,16 @@ export default function FramesExample({ searchParams }: { searchParams: Record<s
41
49
  const status = normalizeStatus(searchParams?.status);
42
50
  const tasks = filterTasks(status);
43
51
  return html`
44
- <h1 class="text-h2 font-bold mb-4">Frames</h1>
45
- <p class="text-muted-foreground mb-4">
52
+ ${pageHeading('Frames')}
53
+ ${lede(html`
46
54
  Filter the list. With JS on, only the framed region swaps (the response is
47
55
  just the frame's subtree, not the whole page) and the heading above never
48
56
  re-renders. With JS off, the same links do full-page navigations. It is one
49
57
  region refreshing independently of a navigation, which neither a page nor a
50
58
  layout can express: a layout wraps routes with shared chrome and only
51
59
  re-renders when you navigate, while a frame updates one region in place.
52
- </p>
53
- <webjs-frame id="tasks" class="block p-4 rounded-2xl bg-card border border-border">
60
+ `)}
61
+ <webjs-frame id="tasks" class="${cardClass()} block p-4">
54
62
  <div class="flex gap-2 mb-4">
55
63
  ${filterTab(status, 'all', 'All')}
56
64
  ${filterTab(status, 'active', 'Active')}