@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
@@ -4,8 +4,8 @@
4
4
 
5
5
  - Pages, layouts, and where the HTML shell comes from
6
6
  - Dynamic (`[param]`), catch-all (`[...rest]`), and optional catch-all (`[[...rest]]`) segments, route groups, private folders
7
- - `route.ts` HTTP handlers and `middleware.ts`
8
- - `metadata` and `generateMetadata` (folded in here)
7
+ - `route.ts` HTTP handlers and `middleware.ts`, the route-handler toolkit (`json` / `readBody` / `clientIp` / the no-arg accessors), and calling one from the client with `richFetch`
8
+ - `metadata` and `generateMetadata` (folded in here), including image metadata routes that return a `Response`
9
9
  - Control-flow throws: `notFound()`, `redirect()`, `forbidden()`, `unauthorized()`
10
10
  - The no-JS page `action` write path
11
11
  - Boundaries: `error.ts`, `loading.ts`, `not-found.ts`, `forbidden.ts`, `unauthorized.ts`, and the two root-only ones
@@ -78,6 +78,18 @@ export async function GET() {
78
78
 
79
79
  **NEVER throw `redirect()` / `notFound()` / `forbidden()` inside a `route.ts` handler** (an uncaught throw is a generic 500). Return a real response instead: `return Response.redirect(url, 303)` for a redirect, `return new Response('Not Found', { status: 404 })` for a 404. A `route.ts` is also NOT covered by the action CSRF check, so authenticate every mutating endpoint, validate, and rate-limit. Export `WS(ws, req, { params })` from the same file for a WebSocket endpoint.
80
80
 
81
+ **The route-handler toolkit** (from `@webjsdev/server`). Two accessors take the request explicitly: `readBody(req)` decodes a rich request body (the inverse of `json()`, round-tripping `Date` / `Map` / `Set` / `BigInt` / `Blob`), and `clientIp(req)` reads the caller IP. To respond with rich types, `json(value)` serializes with the same wire (so a `Date` survives), versus a plain object return that auto-JSONs. The context accessors take NO argument because they read the in-flight request from context: `headers()`, `cookies()`, `requestId()`, `cspNonce()`.
82
+
83
+ ```ts
84
+ import { json, readBody, clientIp } from '@webjsdev/server';
85
+ export async function POST(req: Request) {
86
+ const body = await readBody(req); // rich types decoded
87
+ return json({ at: new Date(), ip: clientIp(req) }); // Date survives the wire
88
+ }
89
+ ```
90
+
91
+ **Calling your own `route.ts` from the client:** use `richFetch<T>(url, opts?)` from `@webjsdev/core`, a drop-in `fetch` that sends `Accept: application/vnd.webjs+json`, encodes a plain-object `body` with the rich wire, and decodes the `json()` response (so `data.at` is a real `Date`). This is the ONE legitimate hand-fetch (importing a `'use server'` action is the way to call the server otherwise, never a hand-written `fetch` to an action).
92
+
81
93
  ## Middleware (`middleware.ts`)
82
94
 
83
95
  Optional root-level plus per-segment. The default export is `async (req, next) => Response`. Return a Response to short-circuit, or call `next()` and post-process. Per-segment middleware applies to its subtree, outermost to innermost.
@@ -156,3 +168,14 @@ How the result is read (server side): a success PRG-redirects with `303` (to a s
156
168
  - Root-only (in `app/` exactly): `global-error.ts` is the app-wide catch-all after nested `error` boundaries are exhausted and renders its OWN `<!doctype><html><body>` (returned verbatim, so keep it static HTML with no components or hydration). `global-not-found.ts` renders for an unmatched-anywhere URL when no `not-found` matches.
157
169
 
158
170
  Metadata routes (`sitemap.ts`, `robots.ts`, `manifest.ts`, `icon.ts`, `apple-icon.ts`, `opengraph-image.ts`, `twitter-image.ts`) live at app root or static segments and default-export a possibly-async function; `sitemap()` / `sitemapIndex()` from `@webjsdev/server` serialize spec-valid XML.
171
+
172
+ The IMAGE metadata routes (`icon`, `apple-icon`, `opengraph-image`, `twitter-image`) default-export a function returning a `Response` with an explicit `content-type`, so an inline SVG needs no asset file (buildless). Then point `metadata` at the route via `openGraph.images` / `twitter.images` / `icons` (or drop a static file in `public/` instead).
173
+
174
+ ```ts
175
+ // app/opengraph-image.ts (OG is 1200x630; apple-icon 180x180)
176
+ export default function OgImage() {
177
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630">...</svg>`;
178
+ return new Response(svg, { headers: { 'content-type': 'image/svg+xml' } });
179
+ }
180
+ // app/page.ts -> export const metadata = { openGraph: { images: ['/opengraph-image'] } };
181
+ ```
@@ -46,7 +46,7 @@ The 103 Early Hints gap costs only a small first-load latency edge where an edge
46
46
  webjs create my-app --runtime bun
47
47
  ```
48
48
 
49
- `--runtime` is orthogonal to `--template`, so it re-flavors any of full-stack, saas, or api. A Bun scaffold emits a `bun.lock`, a pure `oven/bun:1` Dockerfile plus a bun-install CI, and bun-command agent docs. The test, db, and check tooling still runs on Node.
49
+ `--runtime` is orthogonal to `--template`, so it re-flavors either full-stack or api. A Bun scaffold emits a `bun.lock`, a pure `oven/bun:1` Dockerfile plus a bun-install CI, and bun-command agent docs. The test, db, and check tooling still runs on Node.
50
50
 
51
51
  ## Running on Bun
52
52
 
@@ -11,7 +11,7 @@ Read this when you want an offline experience or an asset cache in a WebJs app,
11
11
 
12
12
  ## What ships and why it is safe
13
13
 
14
- WebJs's UI scaffolds (full-stack and saas, not the api template) ship a hand-authored service worker at `public/sw.js` and an offline fallback at `public/offline.html`. Both ship **dormant**: they do nothing until the app registers the worker, and the worker only ever registers from JavaScript. So with JS off no worker exists, and pages, links, and forms behave exactly as before. It is opt-in and adds an offline experience plus an asset cache without changing the no-JS baseline.
14
+ WebJs's UI scaffold (full-stack, not the api template) ships a hand-authored service worker at `public/sw.js` and an offline fallback at `public/offline.html`. Both ship **dormant**: they do nothing until the app registers the worker, and the worker only ever registers from JavaScript. So with JS off no worker exists, and pages, links, and forms behave exactly as before. It is opt-in and adds an offline experience plus an asset cache without changing the no-JS baseline.
15
15
 
16
16
  This is a thin, hand-readable worker built directly on the native Service Worker and Cache Storage APIs. There is no Workbox, no precache framework, and no bundler step, matching WebJs's no-build, close-to-web-standards posture. The file is yours to edit, not a framework internal.
17
17
 
@@ -8,6 +8,7 @@
8
8
  - Design tokens: `:root` / `@theme` in the root layout
9
9
  - Light-DOM host `display: block` behaviour (and shadow hosts via `:host`)
10
10
  - When to use `static styles` (shadow DOM)
11
+ - Accessible native controls (label association, `aria-pressed`, `aria-label`)
11
12
  - `position: fixed`, not `sticky`, for a pinned header (the iOS WebKit flicker)
12
13
  - Even-grid / no-reflow layout tips
13
14
 
@@ -67,9 +68,77 @@ export default function Post({ params }) {
67
68
 
68
69
  Avoid `@apply`: it hides which utilities a class uses and creates a second source of truth. A JS helper keeps the bundle visible at the definition site, composes with conditional classes and active states, and runs at SSR time.
69
70
 
70
- ## Design tokens
71
+ ### A design system for repeated PRIMITIVES: class helpers built on `@webjsdev/ui`
71
72
 
72
- The default stack is a static compiled Tailwind stylesheet (`css:build` compiles `public/input.css` to the linked `public/tailwind.css`, so it works with JS off) plus `@theme` design tokens (palette, fonts, fluid type, motion durations) declared once in the root layout. Consume them as utility classes (`text-foreground`, `bg-card`, `font-serif`, `duration-fast`). If you wire your own theme switch, drive BOTH signals on `<html>` (the `data-theme` attribute for the app palette blocks AND the `.dark` class for the `@webjsdev/ui` kit), or half the UI renders stale tokens. Verify dark mode in a real browser, light mode passing proves nothing about dark.
73
+ An `html`-fragment helper is right for a repeated CHUNK of markup (the rubric above). For a repeated UI PRIMITIVE (button, input, card, badge) that needs variants and sizes, use a class helper instead: a function that returns a Tailwind class STRING you spread onto a native element. That is exactly what `@webjsdev/ui` ships (`buttonClass({ variant, size })`, `cardClass()`, `inputClass()`, `badgeClass({ variant })`), and it is what the scaffold gallery uses in `components/ui/`. To style a ONE-OFF that a variant does not cover (a circular icon button, a pill), compose the helper and override the bespoke bits with `cn()`: `cn(buttonClass({ variant: 'secondary', size: 'none' }), 'w-9 h-9 rounded-full')`. `cn` resolves Tailwind conflicts so a later class wins, including a shorthand over the axis it subsumes (`p-0` beats an earlier `px-4 py-2`), so an override just works. For an icon button prefer `size: 'none'` (it states "I supply my own box" by dropping the helper's padding + radius) over layering a `p-0` on top of the default size.
74
+
75
+ ```ts
76
+ // components/ui/button.ts (webjs ui add button, themed to your app)
77
+ import { cn } from '#lib/utils/cn.ts';
78
+ const BASE = 'inline-flex cursor-pointer items-center justify-center ...';
79
+ const VARIANTS = { default: 'bg-primary text-primary-foreground ...', secondary: '...' } as const;
80
+ const SIZES = { default: 'px-4 py-2 rounded-xl', sm: '...' } as const;
81
+ export function buttonClass(o: { variant?: keyof typeof VARIANTS; size?: keyof typeof SIZES } = {}) {
82
+ return cn(BASE, VARIANTS[o.variant ?? 'default'], SIZES[o.size ?? 'default']);
83
+ }
84
+ ```
85
+
86
+ ```ts
87
+ // a page or component
88
+ import { buttonClass } from '#components/ui/button.ts';
89
+ html`<button class=${buttonClass({ variant: 'secondary', size: 'sm' })} @click=${...}>Reset</button>`;
90
+ ```
91
+
92
+ Why a class helper (not a `<ui-button>` wrapper): it adds NO indirection, so the element stays native (`@click`, `?disabled`, form submission, focus, a11y all just work) and the markup stays readable, while every button shares one source of truth (so no button can forget `cursor-pointer` or drift). Put the affordance every variant needs (like `cursor-pointer`) on the shared BASE.
93
+
94
+ **Own and theme your copy.** `webjs ui add <name>` copies the primitive INTO your `components/ui/`, so you own it. Theme it to YOUR app: change the class values so the helper produces YOUR look, rather than bending your app to the kit's defaults. Keep only the parts you use (the gallery's `cardClass` is surface-only, since its panels vary their own padding and layout). Reserve `lib/utils/ui.ts` `html`-fragment helpers for repeated markup chunks; reserve `components/ui/*` class helpers for themed primitives with variants.
95
+
96
+ ## Accessible native controls
97
+
98
+ A cleared, growing app hand-authors its own controls, so accessibility is your job (the `@webjsdev/ui` primitives carry their own, but a raw `<button>` / `<input>` does not). Three habits keep hand-authored interactive markup accessible on BOTH the JS and no-JS paths:
99
+
100
+ - **Associate a label with its control.** `<label for="email">` paired with `<input id="email">` (or wrap the control in the `<label>`), so a click on the label focuses the field and a screen reader announces it.
101
+ - **State a toggle's pressed state.** A button that toggles carries `aria-pressed=${on}` so assistive tech announces on/off, not just "button".
102
+ - **Name an icon-only button.** A button whose only content is an icon has no accessible name, so give it `aria-label="Delete task"`.
103
+
104
+ Native `<button>` / `<a>` / `<input>` already have correct focus + keyboard behaviour, which is the main reason to prefer them (and the `buttonClass()` / `inputClass()` class helpers) over a custom `<div role>` element.
105
+
106
+ ## Design tokens and theming
107
+
108
+ The default stack is a static compiled Tailwind stylesheet (`css:build` compiles `public/input.css` to the linked `public/tailwind.css`, so it works with JS off) plus `@theme` design tokens declared once in the root layout. You consume them as utility classes (`bg-background`, `text-foreground`, `bg-card`, `border-border`, `font-serif`).
109
+
110
+ **Two halves.** (1) `public/input.css` MAPS token names into Tailwind with `@theme inline` (`--color-background: var(--background)`), so `bg-background` resolves to `var(--background)`. That is infrastructure; leave it. (2) The root layout (`app/layout.ts`) DEFINES the values as plain CSS custom properties in a `<style>` block. That is your palette; make it your own. A freshly cleared app (after `npm run gallery:clear`) ships only the OS system-colour base (`Canvas` / `CanvasText`) with NO tokens, so building this palette is your first styling step.
111
+
112
+ **Light and dark, defined once (DRY).** Write each colour token ONE time with the native CSS `light-dark(LIGHT, DARK)` function and let `color-scheme` pick the side. The default `color-scheme: light dark` follows the OS; a `[data-theme]` attribute forces one. No duplicated light/dark blocks:
113
+
114
+ ```html
115
+ <style>
116
+ :root {
117
+ --font-sans: ui-sans-serif, system-ui, sans-serif;
118
+ color-scheme: light dark; /* follow the OS by default */
119
+ --background: light-dark(#ffffff, #1e2226);
120
+ --foreground: light-dark(#191c20, #dee2e6);
121
+ --card: light-dark(#f7f8fa, #313539);
122
+ --muted-foreground: light-dark(#565c64, #94989c);
123
+ --border: light-dark(#e2e5e9, #3d434b);
124
+ --primary: light-dark(#1e2226, #dee2e6);
125
+ /* a derived token tracks BOTH themes for free via var(--primary) */
126
+ --primary-tint: color-mix(in srgb, var(--primary) 22%, transparent);
127
+ }
128
+ :root[data-theme='light'] { color-scheme: light; } /* the toggle forces a scheme */
129
+ :root[data-theme='dark'] { color-scheme: dark; }
130
+ </style>
131
+ ```
132
+
133
+ `light-dark()` is a native CSS function (CSS Color 5, Baseline 2024), not a library, so nothing to import. A single-theme app drops the `[data-theme]` rules and gives each token one colour.
134
+
135
+ **A manual theme toggle** writes `data-theme` on `<html>` (`light` / `dark`, or removes it for "follow the OS"). If you use `@webjsdev/ui` components, ALSO keep the `.dark` class in sync (the ui kit keys its own tokens off `.dark`), and apply the saved choice in a tiny inline `<script>` in the layout head so there is no first-paint flash. Verify dark mode in a real browser. Light mode passing proves nothing about dark.
136
+
137
+ **Edge cases.** `light-dark()` is COLOUR-only. A colour needed in just one theme sets the unused side to a no-op (`light-dark(#fff, transparent)`). A derived token that references a `light-dark()` one (like `--primary-tint` above) tracks both themes automatically. A NON-colour token that must differ per theme (a shadow's geometry, a gradient, a size, an image) cannot use `light-dark()`; give it a `:root[data-theme='dark']` override plus an `@media (prefers-color-scheme: dark) { :root:not([data-theme]) { ... } }` rule for the OS default.
138
+
139
+ **The ui class helpers build on these tokens.** `buttonClass()` / `cardClass()` / `inputClass()` / `badgeClass()` emit Tailwind utilities that reference the same tokens (`bg-primary`, `border-border`), so theming the tokens re-skins every helper at once.
140
+
141
+ **Focus rings.** The design system applies ONE themed, keyboard-only focus ring globally in the theme CSS: `@layer base { * { @apply border-border outline-ring/50 } }` themes the outline COLOUR to `--ring/50`, and a `:focus-visible { outline: 2px solid color-mix(in oklab, var(--color-ring) 50%, transparent); outline-offset: 2px }` forces a SOLID outline. That second rule matters: `outline-ring/50` alone leaves `outline-style: auto`, so the browser draws its OWN focus ring (which can look thick and white and ignore the colour). So every focusable element (button, link, input) shares one `--ring`-coloured ring with no per-element styling (a native `<button>` renders it a touch wider than a link, a Chromium form-control quirk, but the colour is the same). Do NOT re-add a focus style on a light-DOM element (`buttonClass` deliberately carries none), and NEVER remove it (`outline: none` with no replacement fails WCAG 2.4.7). `:focus-visible` already limits the ring to keyboard / programmatic focus, not a mouse click. A SHADOW-DOM component is the ONE exception: a document rule cannot cross the shadow boundary, so it styles its own focus in `static styles`, matching the global ring EXACTLY (`--ring` at 50%, the same as `outline-ring/50`): `button:focus-visible { outline: 2px solid color-mix(in oklab, var(--color-ring) 50%, transparent); outline-offset: 2px }`. Without it, its controls fall back to the raw browser outline (thick, light on a dark theme, and shown on window-refocus).
73
142
 
74
143
  ## Light-DOM host display, and shadow hosts
75
144
 
@@ -83,6 +152,17 @@ static styles = css`:host { display: block }`; // a shadow host with no :host
83
152
 
84
153
  **Size the HOST, not just an inner wrapper.** The host custom element is the box the parent lays out. A host that is a flex/grid item in a centering parent (`flex justify-center`, `grid place-items-center`) is sized to its content unless it carries width itself. Put the sizing classes on the host (`w-full max-w-[400px]`), not only on an inner `<div>`. Symptom: a board or card renders tiny even though its inner grid says `w-full max-w-[400px]`. Fix: move the sizing onto the host.
85
154
 
155
+ ## Section rhythm: one gap, defined once
156
+
157
+ Per-element margins can never give consistent vertical spacing: each element controls only one side, so a block's gap-above (the previous element's margin) drifts from its gap-below (its own). For a content column (a docs page, a demo page, an article), make the COLUMN own the spacing with a flex stack, and zero the children's own margins so only the one gap applies:
158
+
159
+ ```css
160
+ .stack { --section-gap: 1.5rem; display: flex; flex-direction: column; gap: var(--section-gap); }
161
+ .stack > * { margin: 0; }
162
+ ```
163
+
164
+ Every top-level child (heading, paragraph, component, list) is then equally spaced from ONE variable, and a spacing change is a one-line edit. Flex `gap` beats forcing `display: block` + margins on children: a `grid`/`flex` child keeps its own layout (a blanket `display: block` clobbers it), an inline shadow-DOM host is blockified as a flex item so it honours the gap, a `display: contents` element (a streaming `<webjs-suspense>`) is replaced by its children which become the flex items, and a `display: none` node (a streaming `<script>`/`<template>`) is not an item at all, so it gets no phantom gap. A group that must stay tight (a caption directly above its code block) wraps in one child `<div>` and keeps its own inner spacing. The gallery's `/features` layout (`demo-stack`) is the worked example.
165
+
86
166
  ## Even grids, no reflow
87
167
 
88
168
  The reflow bug (a cell grows when it gets content while the others shrink) comes from `auto`-sized grid rows. Size the tracks explicitly so every cell is an equal fraction regardless of content:
@@ -30,14 +30,21 @@ The order matters:
30
30
  `.agents/skills/webjs/` teaches the same patterns and SURVIVES the clear, so
31
31
  clearing is not a knowledge-loss event, the gallery is just a runnable bonus.
32
32
  2. **Clear it.** Run `npm run gallery:clear` to shed the whole gallery in one
33
- step (removes `app/features/`, `app/examples/`, the demo `modules/`, the demo
34
- `todos` table, and resets `app/page.ts` to a minimal home), while KEEPING the
35
- agent skill, the layout, and the database wiring.
33
+ step: it removes `app/features/`, `app/examples/`, the demo `modules/`, the
34
+ gallery's example design system (`components/ui/`, the theme-toggle), the
35
+ example tests, and the demo `todos` table, and resets `app/page.ts` to a
36
+ minimal home AND `app/layout.ts` to a token-free blank slate (OS system
37
+ colours, no navbar, no palette). A layout you already customised (the gallery
38
+ brand removed) is KEPT, with only the theme-toggle wiring stripped. It KEEPS
39
+ the agent skill, the database wiring, and `lib/utils/cn.ts` (the
40
+ `webjs ui add` prerequisite).
36
41
  3. **Build.** Regenerate the database (`npm run db:generate` then `npm run
37
42
  db:migrate`), then grow the app in place: routes under `app/`, components
38
43
  under `components/`, features under `modules/<feature>/`, server-only code
39
- behind `.server.ts`, and the app's own palette via the tokens in
40
- `app/layout.ts`.
44
+ behind `.server.ts`. Build the app's OWN design system from the blank slate:
45
+ define design tokens in `app/layout.ts` and pull primitives with
46
+ `npx webjsdev ui add <name>`, following
47
+ `.agents/skills/webjs/references/styling.md`.
41
48
 
42
49
  If you are only exploring, keep the gallery and browse it.
43
50
 
@@ -0,0 +1,7 @@
1
+ // The createAuth HTTP endpoints (signin, signout, OAuth callbacks). This route
2
+ // stays at the app root, NOT under app/features/auth/, because createAuth
3
+ // hardcodes /api/auth/signin/* and /api/auth/callback/* for its form posts and
4
+ // OAuth redirect URIs. The rest of the auth card lives under app/features/auth/.
5
+ import { handlers } from '#modules/auth/auth.server.ts';
6
+ export const GET = handlers.GET;
7
+ export const POST = handlers.POST;
@@ -1,14 +1,11 @@
1
- // (delete this file), then delete this marker line. webjs check fails while the
2
- // marker remains.
3
- //
4
1
  // app/apple-icon.ts serves /apple-icon (the Apple touch icon iOS uses when a
5
2
  // visitor adds the site to their home screen). Apple expects a 180x180 square
6
3
  // with no rounded corners (iOS rounds them). Same shape as icon.ts: return a
7
4
  // Response with the exact content type. Swap the inline SVG for your real mark.
8
5
  export default function AppleIcon() {
9
6
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="180" height="180" viewBox="0 0 180 180">
10
- <rect width="180" height="180" fill="#1c1613"/>
11
- <text x="90" y="120" font-family="system-ui, sans-serif" font-size="104" font-weight="700" fill="#ff8a3d" text-anchor="middle">w</text>
7
+ <rect width="180" height="180" fill="#1e2226"/>
8
+ <text x="90" y="120" font-family="system-ui, sans-serif" font-size="104" font-weight="700" fill="#94989c" text-anchor="middle">w</text>
12
9
  </svg>`;
13
10
  return new Response(svg, {
14
11
  headers: { 'content-type': 'image/svg+xml', 'cache-control': 'public, max-age=3600' },
@@ -0,0 +1,16 @@
1
+ import { html } from '@webjsdev/core';
2
+ import { backLink } from '#lib/utils/ui.ts';
3
+
4
+ // Shared layout for every gallery example app under /examples/*. It adds the same
5
+ // slim "back to the gallery" link the feature demos get, so an example is never a
6
+ // dead end. A non-root layout, so it never writes the document shell.
7
+ export default function ExamplesLayout({ children }: { children: unknown }) {
8
+ // An example app has no sidebar, so center it in a focused reading column
9
+ // (the root centers the whole page; this narrows the example within it).
10
+ return html`
11
+ <div class="max-w-xl mx-auto">
12
+ <div class="mb-6">${backLink('/', html`&larr; Gallery`)}</div>
13
+ ${children}
14
+ </div>
15
+ `;
16
+ }
@@ -4,6 +4,7 @@
4
4
  // lives in modules/todo/. This is the idiomatic app-thin + modules-logic split.
5
5
  import { html } from '@webjsdev/core';
6
6
  import type { Metadata } from '@webjsdev/core'; // Metadata is a @webjsdev/core type
7
+ import { pageHeading } from '#lib/utils/ui.ts';
7
8
  import { listTodos } from '#modules/todo/queries/list-todos.server.ts';
8
9
  import { createTodo } from '#modules/todo/actions/create-todo.server.ts';
9
10
  import { toggleTodo } from '#modules/todo/actions/toggle-todo.server.ts';
@@ -16,7 +17,7 @@ export default async function TodoExample() {
16
17
  // SSR-fetched and seeded, so <todo-app> paints the real list on first byte.
17
18
  const todos = await listTodos();
18
19
  return html`
19
- <h1 class="text-h2 font-bold mb-4">Optimistic todo</h1>
20
+ ${pageHeading('Optimistic todo')}
20
21
  <todo-app .todos=${todos}></todo-app>
21
22
  `;
22
23
  }
@@ -1,5 +1,6 @@
1
1
  import { html, Suspense } from '@webjsdev/core';
2
2
  import type { Metadata } from '@webjsdev/core';
3
+ import { pageHeading, lede } from '#lib/utils/ui.ts';
3
4
  import '#modules/async-render/components/server-clock.ts';
4
5
 
5
6
  export const metadata: Metadata = { title: 'Async render (server data in first paint) | features' };
@@ -14,8 +15,8 @@ async function slowRegion() {
14
15
 
15
16
  export default function AsyncRenderExample() {
16
17
  return html`
17
- <h1 class="text-h2 font-bold mb-4">Async render</h1>
18
- <p class="text-muted-foreground mb-4">A component's <code>async render()</code> awaits server data. SSR blocks, so the resolved value is in the first paint (no fallback, readable with JS off).</p>
18
+ ${pageHeading('Async render')}
19
+ ${lede(html`A component's <code>async render()</code> awaits server data. SSR blocks, so the resolved value is in the first paint (no fallback, readable with JS off).`)}
19
20
  <server-clock></server-clock>
20
21
  <p class="text-muted-foreground mt-6 mb-2">For a SLOW region where blocking the first byte hurts, wrap it in <code class="font-mono">Suspense</code> to stream it instead:</p>
21
22
  ${Suspense({ fallback: html`<p class="text-muted-foreground">loading slow region…</p>`, children: slowRegion() })}
@@ -0,0 +1,21 @@
1
+ import { html } from '@webjsdev/core';
2
+ import { buttonClass } from '#components/ui/button.ts';
3
+
4
+ // Nested layout for the protected dashboard subtree. Logout is a plain
5
+ // <form method="POST"> posting to the createAuth signout route: it clears the
6
+ // session cookie and 302s home, and works with JS off (progressive-enhancement
7
+ // default). signOut is server-only (modules/auth/auth.server.ts), so we POST to
8
+ // its route rather than import it into a browser-shipping page. After signout the
9
+ // dashboard middleware bounces any later visit to login.
10
+ export default function DashboardLayout({ children }: { children: unknown }) {
11
+ return html`
12
+ <nav class="flex items-center gap-4 mb-6 pb-4 border-b border-border">
13
+ <a href="/features/auth/dashboard" class="text-sm font-medium text-foreground hover:underline">Dashboard</a>
14
+ <a href="/features/auth/dashboard/settings" class="text-sm font-medium text-foreground hover:underline">Settings</a>
15
+ <form method="POST" action="/api/auth/signout" class="ml-auto">
16
+ <button type="submit" class=${buttonClass({ variant: 'secondary', size: 'sm' })}>Log out</button>
17
+ </form>
18
+ </nav>
19
+ ${children}
20
+ `;
21
+ }
@@ -0,0 +1,14 @@
1
+ import { auth } from '#modules/auth/auth.server.ts';
2
+
3
+ // The protected-route gate. A per-segment middleware.ts runs for every request
4
+ // under /features/auth/dashboard/*. It reads the signed session off the request
5
+ // with auth(req); with no valid session it 302s to login BEFORE the page renders,
6
+ // so an anonymous visitor never sees the protected content. This needs no DB
7
+ // query (only a cookie read), so the gate is real the moment the app boots.
8
+ export default async function requireAuth(req: Request, next: () => Promise<Response>) {
9
+ const session = await auth(req);
10
+ if (!session?.user) {
11
+ return new Response(null, { status: 302, headers: { location: '/features/auth/login' } });
12
+ }
13
+ return next();
14
+ }
@@ -0,0 +1,20 @@
1
+ import { html } from '@webjsdev/core';
2
+ import { cardClass } from '#components/ui/card.ts';
3
+ import { badgeClass } from '#components/ui/badge.ts';
4
+ import { currentUser } from '#modules/auth/queries/current-user.server.ts';
5
+
6
+ export const metadata = { title: 'Dashboard' };
7
+
8
+ export default async function Dashboard() {
9
+ const user = await currentUser();
10
+ return html`
11
+ <div class="flex items-center justify-between mb-6">
12
+ <h1 class="text-2xl font-semibold">Dashboard</h1>
13
+ <span class=${badgeClass()}>Signed in</span>
14
+ </div>
15
+ <div class="${cardClass()} p-6">
16
+ <h2 class="text-lg font-semibold text-foreground m-0 mb-1">Welcome, ${user?.name || user?.email}!</h2>
17
+ <p class="text-sm text-muted-foreground m-0">This route is gated by middleware.ts. Promote it into your product, or drop the whole auth card with gallery:clear.</p>
18
+ </div>
19
+ `;
20
+ }
@@ -0,0 +1,22 @@
1
+ import { html } from '@webjsdev/core';
2
+ import { cardClass } from '#components/ui/card.ts';
3
+ import { currentUser } from '#modules/auth/queries/current-user.server.ts';
4
+
5
+ export const metadata = { title: 'Settings' };
6
+
7
+ export default async function Settings() {
8
+ const user = await currentUser();
9
+ return html`
10
+ <h1 class="text-2xl font-semibold mb-6">Settings</h1>
11
+ <div class="${cardClass()} p-6">
12
+ <h2 class="text-lg font-semibold text-foreground m-0 mb-1">Account</h2>
13
+ <p class="text-sm text-muted-foreground m-0 mb-4">Your basic profile information.</p>
14
+ <dl class="grid grid-cols-[max-content_1fr] gap-x-6 gap-y-2 text-sm">
15
+ <dt class="text-muted-foreground">Email</dt>
16
+ <dd>${user?.email}</dd>
17
+ <dt class="text-muted-foreground">Name</dt>
18
+ <dd>${user?.name || 'Not set'}</dd>
19
+ </dl>
20
+ </div>
21
+ `;
22
+ }
@@ -0,0 +1,15 @@
1
+ import { auth } from '#modules/auth/auth.server.ts';
2
+
3
+ // The mirror of the dashboard's protect-middleware: a visitor who is ALREADY
4
+ // signed in has no reason to see the login form, so send them to the dashboard.
5
+ // A per-segment middleware.ts reads the signed session off the request with
6
+ // auth(req) (a cookie read, no DB), so the redirect happens before the page
7
+ // renders. Keeping the check in middleware (not the page) keeps auth.server.ts
8
+ // off the client.
9
+ export default async function redirectIfSignedIn(req: Request, next: () => Promise<Response>) {
10
+ const session = await auth(req);
11
+ if (session?.user) {
12
+ return new Response(null, { status: 302, headers: { location: '/features/auth/dashboard' } });
13
+ }
14
+ return next();
15
+ }
@@ -0,0 +1,43 @@
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';
5
+
6
+ export const metadata = { title: 'Log in' };
7
+
8
+ const inputCls = inputClass();
9
+
10
+ // A failed sign-in 302s back here with ?error=... (createAuth is configured with
11
+ // pages.error: '/features/auth/login' in modules/auth/auth.server.ts). Map the
12
+ // code to a plain message so a bad password gets visible feedback instead of a
13
+ // silent bounce.
14
+ function errorMessage(code: string | undefined): string | null {
15
+ if (!code) return null;
16
+ if (code === 'CredentialsSignin') return 'Invalid email or password.';
17
+ return 'Could not sign you in. Please try again.';
18
+ }
19
+
20
+ export default function LoginPage({ searchParams }: { searchParams: { error?: string } }) {
21
+ const error = errorMessage(searchParams.error);
22
+ return html`
23
+ <div class="max-w-[420px] mx-auto">
24
+ <h1 class="text-h2 font-bold mb-2">Sign in</h1>
25
+ <p class="text-muted-foreground mb-5">Welcome back: log in to continue.</p>
26
+ ${error ? html`<p role="alert" class="mb-4 text-sm text-destructive">${error}</p>` : ''}
27
+ <form method="POST" action="/api/auth/signin/credentials" class="${cardClass()} grid gap-4 p-5">
28
+ <!-- createAuth reads redirectTo from the posted form and 302s there after a successful signin. -->
29
+ <input type="hidden" name="redirectTo" value="/features/auth/dashboard">
30
+ <div class="grid gap-1.5">
31
+ <label for="email" class="text-[13px] font-medium text-muted-foreground">Email</label>
32
+ <input id="email" name="email" type="email" required class=${inputCls} placeholder="ada@example.com" />
33
+ </div>
34
+ <div class="grid gap-1.5">
35
+ <label for="password" class="text-[13px] font-medium text-muted-foreground">Password</label>
36
+ <input id="password" name="password" type="password" required class=${inputCls} />
37
+ </div>
38
+ <button type="submit" class="${buttonClass()} justify-self-start">Sign in</button>
39
+ </form>
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>
41
+ </div>
42
+ `;
43
+ }
@@ -0,0 +1,34 @@
1
+ // Auth: password login/signup on top of createAuth, a signed session cookie, and
2
+ // a genuinely protected route. This card wires a REAL auth baseline (a users
3
+ // table with a passwordHash, createAuth in modules/auth/auth.server.ts, and the
4
+ // /features/auth/dashboard subtree gated by a middleware.ts), so a fresh app can
5
+ // promote it into a product. gallery:clear removes the whole surface (this card,
6
+ // modules/auth, app/api/auth, the passwordHash column) back to the minimal base.
7
+ //
8
+ // This index page is public and reads the current session with currentUser() so
9
+ // it can show who is signed in. The read is a 'use server' action, so the same
10
+ // line is the real query during SSR and a safe RPC stub on the client.
11
+ import { html } from '@webjsdev/core';
12
+ import type { Metadata } from '@webjsdev/core';
13
+ import { pageHeading, lede } from '#lib/utils/ui.ts';
14
+ import { currentUser } from '#modules/auth/queries/current-user.server.ts';
15
+
16
+ export const metadata: Metadata = { title: 'Auth (login + protected route) | features' };
17
+
18
+ export default async function AuthExample() {
19
+ const user = await currentUser();
20
+ return html`
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.`)}
23
+
24
+ ${user
25
+ ? html`
26
+ <p class="mb-4">Signed in as <strong>${user.name || user.email}</strong>.</p>
27
+ <p><a class="text-primary underline underline-offset-2" href="/features/auth/dashboard">Open the protected dashboard</a></p>`
28
+ : html`
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>`}
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>
33
+ `;
34
+ }
@@ -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
+ }
@@ -0,0 +1,61 @@
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';
5
+ import { signup } from '#modules/auth/actions/signup.server.ts';
6
+
7
+ export const metadata = { title: 'Sign up' };
8
+
9
+ const inputCls = inputClass();
10
+
11
+ // Page server action: handles the POST from the form below. With JS disabled this
12
+ // is a plain <form> round-trip; with JS the client router swaps the 422 re-render
13
+ // (errors) or follows the 302 (success) in place. A validation failure returns
14
+ // fieldErrors + values so the page re-renders with messages and the user's typed
15
+ // input preserved.
16
+ export async function action({ formData }: { formData: FormData }) {
17
+ const name = String(formData.get('name') || '').trim();
18
+ const email = String(formData.get('email') || '').trim();
19
+ const password = String(formData.get('password') || '');
20
+ const values = { name, email };
21
+ const fieldErrors: Record<string, string> = {};
22
+ if (!name) fieldErrors.name = 'Name is required';
23
+ if (!email.includes('@')) fieldErrors.email = 'Enter a valid email';
24
+ if (password.length < 8) fieldErrors.password = 'At least 8 characters';
25
+ if (Object.keys(fieldErrors).length) return { success: false, fieldErrors, values, status: 422 };
26
+ const result = await signup({ name, email, password });
27
+ // On success signup returns signIn's 302 Response (auto-login -> dashboard); a
28
+ // page action may return a Response, so pass it straight through.
29
+ if (result instanceof Response) return result;
30
+ return { success: false, fieldErrors: { email: result.error }, values, status: result.status };
31
+ }
32
+
33
+ export default function SignupPage({ actionData }: { actionData?: { fieldErrors?: Record<string, string>; values?: Record<string, string> } }) {
34
+ const errors = actionData?.fieldErrors || {};
35
+ const values = actionData?.values || {};
36
+ return html`
37
+ <div class="max-w-[420px] mx-auto">
38
+ <h1 class="text-h2 font-bold mb-2">Create an account</h1>
39
+ <p class="text-muted-foreground mb-5">Get started with your new workspace.</p>
40
+ <form method="POST" class="${cardClass()} grid gap-4 p-5">
41
+ <div class="grid gap-1.5">
42
+ <label for="name" class="text-[13px] font-medium text-muted-foreground">Name</label>
43
+ <input id="name" name="name" type="text" value=${values.name || ''} required class=${inputCls} placeholder="Ada Lovelace" />
44
+ ${errors.name ? html`<p class="m-0 text-[12.5px] text-destructive">${errors.name}</p>` : ''}
45
+ </div>
46
+ <div class="grid gap-1.5">
47
+ <label for="email" class="text-[13px] font-medium text-muted-foreground">Email</label>
48
+ <input id="email" name="email" type="email" value=${values.email || ''} required class=${inputCls} placeholder="ada@example.com" />
49
+ ${errors.email ? html`<p class="m-0 text-[12.5px] text-destructive">${errors.email}</p>` : ''}
50
+ </div>
51
+ <div class="grid gap-1.5">
52
+ <label for="password" class="text-[13px] font-medium text-muted-foreground">Password</label>
53
+ <input id="password" name="password" type="password" minlength="8" required class=${inputCls} />
54
+ ${errors.password ? html`<p class="m-0 text-[12.5px] text-destructive">${errors.password}</p>` : ''}
55
+ </div>
56
+ <button type="submit" class="${buttonClass()} justify-self-start">Create account</button>
57
+ </form>
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>
59
+ </div>
60
+ `;
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
  }