Skip to content

Commit 5f7bd1f

Browse files
authored
feat: add boundaries gallery demo + harden scaffold-sync skill (#848 follow-up) (#854)
* chore: harden scaffold-sync skill for control-flow throw demos The #848 file-routing parity work documented forbidden()/unauthorized() and the global-error/global-not-found boundaries in the scaffold rule files but shipped no gallery demo for them, only the await-params demo. The gallery is the primary teaching surface, so an undemoed thrower is invisible to a scaffolding agent. Add a change-type row so a new control-flow throw or routing boundary file requires a runnable gallery demo, not just an app-tree bullet, and note the root-only carve-out. * feat: add boundaries gallery demo (forbidden/unauthorized + boundary files) Close the scaffold teaching gap from #848. The full-stack and saas scaffolds now ship an app/features/boundaries demo: an index page teaching the four control-flow throws (notFound/redirect/forbidden/ unauthorized) and their boundary files, plus two live sub-routes, gated/ (throws forbidden(), caught by the nearest forbidden.ts at 403) and private/ (throws unauthorized(), caught by the nearest unauthorized.ts at 401). Root-only global-error/global-not-found are taught in prose since they cannot mount under a feature folder. Wired into the home features array (full-stack + saas), cross-linked from the routing demo, added to the scaffold rule-file feature lists, and asserted in scaffold-gallery.test.js. Verified by generating a full-stack app, booting it, and confirming /features/boundaries/gated renders 403 and /private renders 401.
1 parent 28e5be9 commit 5f7bd1f

11 files changed

Lines changed: 145 additions & 6 deletions

File tree

.claude/skills/webjs-scaffold-sync/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ it applies, then update or consciously skip each.
9393
|---|---|
9494
| New / changed **gallery or showcase demo** | the template file(s) or generator strings for the demo + the home-page `features`/index array + the scaffold AGENTS.md gallery list + `test/scaffolds/*` FEATURES/assertions + **generate + boot the affected template** |
9595
| New / removed **template** | the `create.js` template branch (+ a `*-template.js` if large) + the "only N templates exist" list in EVERY per-agent rule file + the framework `AGENTS.md`/getting-started/README template matrix + the CLI `--template` validation + `--help` + `test/scaffolds/*` |
96+
| New **control-flow throw or routing boundary file** (`notFound` / `redirect` / `forbidden` / `unauthorized` and their `not-found` / `forbidden` / `unauthorized` / `error` / `loading` / `global-error` / `global-not-found` boundary files) | a runnable **gallery demo** that exercises it (a route that throws it plus the nearest boundary file), NOT just an app-tree bullet in the rule files + the home-page `features` array + `test/scaffolds/*` FEATURES/boundary-file asserts + **generate + boot + hit the route**. A doc bullet in `AGENTS.md` / `CONVENTIONS.md` is necessary but NOT sufficient: the gallery is the primary teaching surface, so an undemoed thrower is invisible to a scaffolding agent (the #848 gap). Carve-out: a **root-only** boundary (`global-error` / `global-not-found`) cannot mount under `app/features/` without clashing with the generated app root, so teach those in the demo's PROSE rather than as a live route. |
9697
| New **convention/rule** for generated apps | ALL per-agent rule files in lockstep (surface 3) + repo `CONVENTIONS.md` if the repo demonstrates it + `agent-docs` only if it also changes a framework API |
9798
| Changed **generated file** (layout, theme, home, schema, middleware) | the generator (`create.js`/`*-template.js`) + any scaffold test asserting it + any doc/preview describing it + **regenerate + boot** |
9899
| New **scaffold-shipped config/hook** (`.hooks/`, `webjs.*` in the generated `package.json`, a check rule) | `templates/**` + `webjs doctor`/`check` that reads it + the per-agent rule files if agents must know it |

packages/cli/lib/create.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1363,6 +1363,7 @@ export const metadata = {
13631363
// do not use (delete the route AND its modules/<name>), then reshape this page.
13641364
const features = [
13651365
{ href: '/features/routing', title: 'Routing', blurb: 'A static route plus a dynamic [id] segment that reads params. The file-based router in miniature.' },
1366+
{ href: '/features/boundaries', title: 'Boundaries', blurb: 'The control-flow throws (forbidden / unauthorized / notFound) and the nearest boundary file that catches each.' },
13661367
{ href: '/features/components', title: 'Components', blurb: 'The WebComponent factory, reactive props, instance signals, and slot projection in light DOM.' },
13671368
{ href: '/features/server-actions', title: 'Server actions', blurb: 'A use-server RPC action next to a server-only .server.ts utility, and why the boundary matters.' },
13681369
{ href: '/features/optimistic-ui', title: 'Optimistic UI', blurb: 'The imperative optimistic(signal, value, action) flip: instant update, automatic rollback on failure.' },
@@ -1460,6 +1461,7 @@ export const metadata = {
14601461
// AND its modules/<name>). Keep this list in sync with the full-stack home.
14611462
const features = [
14621463
{ href: '/features/routing', title: 'Routing', blurb: 'A static route plus a dynamic [id] segment that reads params. The file-based router in miniature.' },
1464+
{ href: '/features/boundaries', title: 'Boundaries', blurb: 'The control-flow throws (forbidden / unauthorized / notFound) and the nearest boundary file that catches each.' },
14631465
{ href: '/features/components', title: 'Components', blurb: 'The WebComponent factory, reactive props, instance signals, and slot projection in light DOM.' },
14641466
{ href: '/features/server-actions', title: 'Server actions', blurb: 'A use-server RPC action next to a server-only .server.ts utility, and why the boundary matters.' },
14651467
{ href: '/features/optimistic-ui', title: 'Optimistic UI', blurb: 'The imperative optimistic(signal, value, action) flip: instant update, automatic rollback on failure.' },

packages/cli/templates/AGENTS.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,7 @@ app/ ROUTING ONLY: thin route adapters (import from modules/
342342
styles/, lib/utils/, and modules/. globals.css is at
343343
styles/, NOT app/.
344344
page.ts → / (the scaffold home links to the gallery)
345-
features/<name>/ single-feature demos (routing, components,
345+
features/<name>/ single-feature demos (routing, boundaries, components,
346346
server-actions, optimistic-ui, async-render,
347347
directives, route-handler, forms, metadata, caching,
348348
env, client-router, service-worker); prune what you skip
@@ -405,9 +405,10 @@ instrumentation-client.ts optional client boot hook, runs first before app modu
405405
The scaffold ships a gallery organized by KIND, so features and whole apps are
406406
not mixed:
407407
- `app/features/<name>/` are single-feature demos, one webjs concept each
408-
(routing, components, server-actions, optimistic-ui, async-render, directives,
409-
route-handler, forms, metadata, caching, env, client-router, service-worker,
410-
plus the infra demos websockets, file-storage, rate-limit, broadcast).
408+
(routing, boundaries, components, server-actions, optimistic-ui, async-render,
409+
directives, route-handler, forms, metadata, caching, env, client-router,
410+
service-worker, plus the infra demos websockets, file-storage, rate-limit,
411+
broadcast).
411412
- `app/examples/<name>/` are whole example apps that compose several features
412413
(todo: optimistic UI + progressive enhancement + a11y + db + modules).
413414

packages/cli/templates/CONVENTIONS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ api / saas templates) is a **starting point**.
317317

318318
The full-stack and saas scaffolds ship a **gallery** organized by kind so features
319319
and whole apps are not mixed. `app/features/<name>/` are single-feature
320-
demos, one webjs concept each (routing, components, server-actions,
320+
demos, one webjs concept each (routing, boundaries, components, server-actions,
321321
optimistic-ui, async-render, directives, route-handler). `app/examples/<name>/`
322322
are whole example apps that compose several features (todo: optimistic UI
323323
+ progressive enhancement + a11y + db + modules). Both keep their logic in
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// The nearest 403 boundary for a thrown forbidden(). A forbidden.ts default-
2+
// exports a function returning a TemplateResult, rendered at status 403 in place
3+
// of the page that threw. Nearest wins: this one (inside gated/) beats any
4+
// forbidden.ts higher up the tree. Keep the message actionable for an
5+
// authenticated user who lacks permission.
6+
import { html } from '@webjsdev/core';
7+
8+
export default function Forbidden() {
9+
return html`
10+
<h1 class="text-h2 font-bold mb-4">403 Forbidden</h1>
11+
<p class="text-muted-foreground mb-4">
12+
You are signed in but do not have permission to view this page. This is the
13+
nearest <code class="font-mono">forbidden.ts</code> boundary, rendered
14+
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>
17+
`;
18+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// A page that throws forbidden(). In a real app you would call this only when
2+
// an authenticated user lacks permission (checked against the session). Here it
3+
// throws unconditionally so the demo always shows the 403 boundary. The nearest
4+
// forbidden.ts (this folder's sibling) renders in place of this page at status
5+
// 403; without a nearest one the framework renders a default 403 page.
6+
import { forbidden } from '@webjsdev/core';
7+
8+
export default function Gated() {
9+
forbidden();
10+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// webjs-scaffold-placeholder. Feature gallery route. Keep and adapt it, or prune it (delete this app/features/boundaries route), then delete this marker line. webjs check fails while the marker remains.
2+
// Boundaries: the four control-flow throws and their boundary files. A page (or
3+
// a page `action`) can THROW to short-circuit rendering, and the framework
4+
// renders the NEAREST matching boundary file in the route's ancestor chain
5+
// (innermost wins), exactly like an error boundary:
6+
// notFound() -> the nearest not-found.ts (404)
7+
// forbidden() -> the nearest forbidden.ts (403, authenticated but not allowed)
8+
// unauthorized() -> the nearest unauthorized.ts (401, not authenticated)
9+
// redirect(url) -> an HTTP 3xx (no boundary file; it sends a Location)
10+
// Two boundaries are ROOT-ONLY, so they live at the app root, not here under a
11+
// feature folder: global-error.ts (the app-wide catch-all, owns its own <html>)
12+
// and global-not-found.ts (a 404 for a URL that matches nothing anywhere).
13+
//
14+
// This demo ships two live sub-routes. Visit each and the page throws, so you
15+
// see the nearest boundary render in place of the page:
16+
// /features/boundaries/gated throws forbidden() -> gated/forbidden.ts
17+
// /features/boundaries/private throws unauthorized() -> private/unauthorized.ts
18+
import { html } from '@webjsdev/core';
19+
import type { Metadata } from '@webjsdev/core';
20+
21+
export const metadata: Metadata = { title: 'Boundaries (throws + boundary files) | features' };
22+
23+
export default function BoundariesExample() {
24+
return html`
25+
<h1 class="text-h2 font-bold mb-4">Boundaries</h1>
26+
<p class="text-muted-foreground mb-4">
27+
Throw a control-flow function from a page (or a page
28+
<code class="font-mono">action</code>) to short-circuit the render. The
29+
framework catches it and renders the nearest matching boundary file in the
30+
route's chain, innermost wins.
31+
</p>
32+
<ul class="list-disc pl-5 mb-4">
33+
<li>
34+
<a class="text-primary" href="/features/boundaries/gated">/features/boundaries/gated</a>
35+
throws <code class="font-mono">forbidden()</code>, caught by
36+
<code class="font-mono">gated/forbidden.ts</code> (403).
37+
</li>
38+
<li>
39+
<a class="text-primary" href="/features/boundaries/private">/features/boundaries/private</a>
40+
throws <code class="font-mono">unauthorized()</code>, caught by
41+
<code class="font-mono">private/unauthorized.ts</code> (401).
42+
</li>
43+
</ul>
44+
<p class="text-muted-foreground text-sm mb-2">
45+
<code class="font-mono">forbidden()</code> is for an authenticated user who
46+
lacks permission (403); <code class="font-mono">unauthorized()</code> is for
47+
a request that is not authenticated at all (401). Both import from
48+
<code class="font-mono">@webjsdev/core</code> and are thrown, never returned.
49+
</p>
50+
<p class="text-muted-foreground text-sm mb-2">
51+
Same throw model as <code class="font-mono">notFound()</code> (renders the
52+
nearest <code class="font-mono">not-found.ts</code>) and
53+
<code class="font-mono">redirect(url)</code> (sends an HTTP 3xx). Inside a
54+
<code class="font-mono">'use server'</code> RPC action, return an
55+
<code class="font-mono">ActionResult</code> for an auth failure instead of
56+
throwing, since a raw throw there is a generic 500.
57+
</p>
58+
<p class="text-muted-foreground text-sm">
59+
Two boundaries are root-only and live at the app root:
60+
<code class="font-mono">app/global-error.ts</code> (the app-wide catch-all,
61+
which renders its own <code class="font-mono">&lt;html&gt;</code> document)
62+
and <code class="font-mono">app/global-not-found.ts</code> (a 404 for a URL
63+
that matches nothing anywhere).
64+
</p>
65+
<p class="mt-3"><a class="text-primary" href="/">Back to the gallery</a></p>
66+
`;
67+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// A page that throws unauthorized(). In a real app you would call this when the
2+
// request is not authenticated (no valid session), typically to prompt a sign-in.
3+
// Here it throws unconditionally so the demo always shows the 401 boundary. The
4+
// nearest unauthorized.ts (this folder's sibling) renders in place of this page
5+
// at status 401; without a nearest one the framework renders a default 401 page.
6+
import { unauthorized } from '@webjsdev/core';
7+
8+
export default function Private() {
9+
unauthorized();
10+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// The nearest 401 boundary for a thrown unauthorized(). An unauthorized.ts
2+
// default-exports a function returning a TemplateResult, rendered at status 401
3+
// in place of the page that threw. Nearest wins: this one (inside private/) beats
4+
// any unauthorized.ts higher up the tree. A real one usually links to sign-in.
5+
import { html } from '@webjsdev/core';
6+
7+
export default function Unauthorized() {
8+
return html`
9+
<h1 class="text-h2 font-bold mb-4">401 Unauthorized</h1>
10+
<p class="text-muted-foreground mb-4">
11+
You need to sign in to view this page. This is the nearest
12+
<code class="font-mono">unauthorized.ts</code> boundary, rendered because the
13+
page threw <code class="font-mono">unauthorized()</code>.
14+
</p>
15+
<p><a class="text-primary" href="/features/boundaries">Back to boundaries</a></p>
16+
`;
17+
}

packages/cli/templates/gallery/app/features/routing/page.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,13 @@ export default function RoutingExample() {
4343
<code class="font-mono">redirect()</code> on the server, call
4444
<code class="font-mono">navigate()</code> on the client.
4545
</p>
46+
<p class="text-muted-foreground text-sm mt-3">
47+
A page can also THROW to short-circuit rendering:
48+
<code class="font-mono">notFound()</code> (404),
49+
<code class="font-mono">forbidden()</code> (403), and
50+
<code class="font-mono">unauthorized()</code> (401), each rendering the
51+
nearest matching boundary file. See the
52+
<a class="text-primary" href="/features/boundaries">Boundaries</a> demo.
53+
</p>
4654
`;
4755
}

0 commit comments

Comments
 (0)