From 3f1c15aaacaf471c3b40f70a044fca7a4d2f53ae Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 9 Jul 2026 13:37:53 +0530 Subject: [PATCH 1/9] docs: fix stale tier-1-only convention note left by #861 The coverage-gate extension gated routing convention files and server exports, but this framework-dev paragraph still claimed conventions stay tier-1-only. Correct it to the shipped three-surface scope. --- agent-docs/framework-dev.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent-docs/framework-dev.md b/agent-docs/framework-dev.md index 0db3ee2e..ab018b86 100644 --- a/agent-docs/framework-dev.md +++ b/agent-docs/framework-dev.md @@ -46,7 +46,7 @@ The scaffold is webjs's primary teaching surface for AI agents, so a new framewo - **Tier 2 (CI gate):** `test/scaffolds/gallery-coverage.test.js` reconciles the LIVE framework surface against `test/scaffolds/gallery-coverage.json` and FAILS when something new is neither demoed nor exempted. It gates three surfaces: `@webjsdev/core` exports (a `{ demo }` gallery-file pointer), `@webjsdev/server` exports (`{ demoed: true }`, verified by a generated app importing it), and routing convention files (the stems DERIVED from `packages/server/src/router.js`, each demonstrated by a file in a generated app). It runs under `npm test`, so a local `--no-verify` cannot skip it: a new export or convention turns CI red until classified. The `reconcile()` / `reconcileSet()` cores are pure and their failure modes (new name, stale key, missing/over-claimed demo, empty reason) are proven with synthetic inputs alongside the real-surface assertions. The deferred backlog is tracked in #859. -**When you add or rename a `@webjsdev/core` export, update the manifest** the same way you write a test: add a demo pointer, or an honest exemption. The gate covers the export surface (where the #848-class throwers live); a new routing convention FILE type (a `*.ts` boundary) is not an export, so it stays tier-1-only for now. +**When you add or rename a `@webjsdev/core` or `@webjsdev/server` export, or add a routing convention file the router parses, update the manifest** the same way you write a test: add a demo pointer / `{ demoed: true }`, or an honest exemption. All three surfaces are gated (the convention stems are derived from `packages/server/src/router.js`, so a new `stem === '...'` branch auto-appears and must be classified). --- From 8d6cd17032877e37039044d2a39fe1511cc77d0b Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 9 Jul 2026 13:41:24 +0530 Subject: [PATCH 2/9] feat: ship sitemap/robots/manifest metadata-route demos in the scaffold Close 4 coverage-gate deferred gaps: the scaffold now ships example app/sitemap.ts (uses sitemap() from @webjsdev/server), app/robots.ts, and app/manifest.ts, flipping the sitemap/robots/manifest routing conventions and the server sitemap export from exempt to demonstrated. Gate: conventions 6->9 demonstrated, server 21->22 demoed. --- .../cli/templates/gallery/app/manifest.ts | 21 +++++++++++++++++ packages/cli/templates/gallery/app/robots.ts | 16 +++++++++++++ packages/cli/templates/gallery/app/sitemap.ts | 23 +++++++++++++++++++ test/scaffolds/gallery-coverage.json | 8 +++---- 4 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 packages/cli/templates/gallery/app/manifest.ts create mode 100644 packages/cli/templates/gallery/app/robots.ts create mode 100644 packages/cli/templates/gallery/app/sitemap.ts diff --git a/packages/cli/templates/gallery/app/manifest.ts b/packages/cli/templates/gallery/app/manifest.ts new file mode 100644 index 00000000..e7b42790 --- /dev/null +++ b/packages/cli/templates/gallery/app/manifest.ts @@ -0,0 +1,21 @@ +// webjs-scaffold-placeholder. Metadata route. Keep and adapt it, or prune it +// (delete this file), then delete this marker line. webjs check fails while the +// marker remains. +// +// app/manifest.ts serves /manifest.json (the web app manifest). The default +// export returns an object, serialized to JSON. Adapt the name, colors, and +// icons to your app; pair it with the opt-in service worker for an installable +// PWA. See agent-docs/service-worker.md. +export default function Manifest() { + return { + name: '{{APP_NAME}}', + short_name: '{{APP_NAME}}', + start_url: '/', + display: 'standalone', + background_color: '#ffffff', + theme_color: '#1c1613', + icons: [ + { src: '/favicon.svg', sizes: 'any', type: 'image/svg+xml' }, + ], + }; +} diff --git a/packages/cli/templates/gallery/app/robots.ts b/packages/cli/templates/gallery/app/robots.ts new file mode 100644 index 00000000..d9e8c0b0 --- /dev/null +++ b/packages/cli/templates/gallery/app/robots.ts @@ -0,0 +1,16 @@ +// webjs-scaffold-placeholder. Metadata route. Keep and adapt it, or prune it +// (delete this file), then delete this marker line. webjs check fails while the +// marker remains. +// +// app/robots.ts serves /robots.txt. The default export returns a string (served +// as text/plain) or an object. This example allows all crawlers and points them +// at the sitemap. Tighten the rules (Disallow paths) for a real app. +const SITE_URL = (process.env.SITE_URL || 'http://localhost:8080').replace(/\/$/, ''); + +export default function Robots() { + return [ + 'User-agent: *', + 'Allow: /', + `Sitemap: ${SITE_URL}/sitemap.xml`, + ].join('\n'); +} diff --git a/packages/cli/templates/gallery/app/sitemap.ts b/packages/cli/templates/gallery/app/sitemap.ts new file mode 100644 index 00000000..72278457 --- /dev/null +++ b/packages/cli/templates/gallery/app/sitemap.ts @@ -0,0 +1,23 @@ +// webjs-scaffold-placeholder. Metadata route. Keep and adapt it to your real +// routes, or prune it (delete this file), then delete this marker line. webjs +// check fails while the marker remains. +// +// app/sitemap.ts serves /sitemap.xml. The default export is a (possibly async) +// server function; `sitemap(entries)` from @webjsdev/server serializes a +// spec-valid XML sitemap. In a real app, build the entries from your content +// (a query over posts/products), so new content is discoverable without editing +// this file. Here it lists the gallery's static routes as an example. +import { sitemap } from '@webjsdev/server'; + +const SITE_URL = (process.env.SITE_URL || 'http://localhost:8080').replace(/\/$/, ''); + +export default function Sitemap() { + const routes = ['/', '/features/routing', '/features/boundaries']; + return sitemap( + routes.map((path) => ({ + url: `${SITE_URL}${path}`, + changeFrequency: 'weekly' as const, + priority: path === '/' ? 1.0 : 0.7, + })), + ); +} diff --git a/test/scaffolds/gallery-coverage.json b/test/scaffolds/gallery-coverage.json index bbc44d58..9496e265 100644 --- a/test/scaffolds/gallery-coverage.json +++ b/test/scaffolds/gallery-coverage.json @@ -630,7 +630,7 @@ "exempt": "deferred: signed URL builder (#865)" }, "sitemap": { - "exempt": "deferred: sitemap serializer (#865)" + "demoed": true }, "sitemapIndex": { "exempt": "deferred: sitemap index serializer (#865)" @@ -728,13 +728,13 @@ "demoed": true }, "sitemap": { - "exempt": "deferred: no example sitemap file shipped by the scaffold yet (#865)" + "demoed": true }, "robots": { - "exempt": "deferred: no example robots file shipped by the scaffold yet (#865)" + "demoed": true }, "manifest": { - "exempt": "deferred: no example manifest file shipped by the scaffold yet (#865)" + "demoed": true }, "icon": { "exempt": "deferred: no example icon file shipped by the scaffold yet (#865)" From 5f7a27213b9504c6c0bac20a3797b464ab7209fd Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 9 Jul 2026 13:46:38 +0530 Subject: [PATCH 3/9] feat: demo the common lit directives in the scaffold (live/ref/until/keyed/unsafeHTML) Extend the directives gallery demo with a second card covering live (a controlled input), ref + createRef (a DOM-node handle), until (a pending fallback), unsafeHTML (trusted raw HTML), and keyed (fresh subtree on key change). Flips 6 core exports from deferred to demoed (core 12->18). The niche completeness directives (guard, templateContent, asyncAppend/ asyncReplace) stay documented-deferred rather than ship contrived demos. --- .../directives/components/directive-demo.ts | 79 ++++++++++++++----- test/scaffolds/gallery-coverage.json | 12 +-- 2 files changed, 66 insertions(+), 25 deletions(-) diff --git a/packages/cli/templates/gallery/modules/directives/components/directive-demo.ts b/packages/cli/templates/gallery/modules/directives/components/directive-demo.ts index 4851dd2d..4ec3f25e 100644 --- a/packages/cli/templates/gallery/modules/directives/components/directive-demo.ts +++ b/packages/cli/templates/gallery/modules/directives/components/directive-demo.ts @@ -2,9 +2,13 @@ // `repeat` keys a list so DOM nodes are REUSED across reorders instead of being // recreated (use it for keyed lists that reorder; plain `.map()` is fine for // static lists). `watch(signal)` does a fine-grained DOM swap of ONE node when -// the signal changes, without re-running the whole template. +// the signal changes, without re-running the whole template. The second card +// shows more of the set: `live` (a controlled input), `ref` + `createRef` (a +// handle to a DOM node), `until` (a pending fallback for a promise), +// `unsafeHTML` (trusted raw HTML, NEVER user input), and `keyed` (force a fresh +// subtree when a key changes). import { WebComponent, signal, html } from '@webjsdev/core'; -import { repeat, watch } from '@webjsdev/core/directives'; +import { repeat, watch, live, until, keyed, unsafeHTML, ref, createRef } from '@webjsdev/core/directives'; interface Item { id: number; label: string } @@ -17,6 +21,11 @@ export class DirectiveDemo extends WebComponent { { id: 3, label: 'Charlie' }, ]); private ticks = signal(0); + // A controlled value (for `live`) and a key (for `keyed`). + private text = signal('type here'); + private variant = signal(0); + // A handle to the input node, attached by `ref` in the browser. + private inputRef = createRef(); private reverse() { this.items.set(this.items.get().slice().reverse()); @@ -25,27 +34,59 @@ export class DirectiveDemo extends WebComponent { const id = nextId++; this.items.set([...this.items.get(), { id, label: 'Item ' + id }]); } + private focusInput() { + this.inputRef.value?.focus(); + } + // A promise that resolves after a tick, so `until` shows its fallback first. + private later(): Promise { + return new Promise((resolve) => setTimeout(() => resolve('resolved!'), 400)); + } render() { + const variant = this.variant.get(); return html` -
-
- - +
+
+
+ + +
+ +
    + ${repeat(this.items.get(), (it: Item) => it.id, (it: Item) => html` +
  • + #${it.id}${it.label} +
  • + `)} +
+ + +
+ +
+ +
+ this.text.set((e.target as HTMLInputElement).value)}> + +
+ +

async: ${until(this.later(), 'loading...')}

+ + + ${keyed(variant, html`
${unsafeHTML('fresh subtree')} #${variant}
`)}
- -
    - ${repeat(this.items.get(), (it: Item) => it.id, (it: Item) => html` -
  • - #${it.id}${it.label} -
  • - `)} -
- -
`; } diff --git a/test/scaffolds/gallery-coverage.json b/test/scaffolds/gallery-coverage.json index 9496e265..b6bdbd1e 100644 --- a/test/scaffolds/gallery-coverage.json +++ b/test/scaffolds/gallery-coverage.json @@ -87,7 +87,7 @@ "exempt": "internal: frame codec used by the client router" }, "createRef": { - "exempt": "deferred: element ref directive; agent-docs/components.md (#859)" + "demo": "modules/directives/components/directive-demo.ts" }, "cspNonce": { "exempt": "deferred: CSP nonce accessor; agent-docs/configuration.md (#859)" @@ -192,10 +192,10 @@ "exempt": "internal: directive type guard used by the renderer" }, "keyed": { - "exempt": "deferred: keyed directive; agent-docs/components.md (#859)" + "demo": "modules/directives/components/directive-demo.ts" }, "live": { - "exempt": "deferred: live directive; agent-docs/components.md (#859)" + "demo": "modules/directives/components/directive-demo.ts" }, "lookup": { "exempt": "internal: component-registry plumbing" @@ -231,7 +231,7 @@ "exempt": "deferred: server redirect throw; mentioned in the boundaries demo prose (#859)" }, "ref": { - "exempt": "deferred: element ref directive; agent-docs/components.md (#859)" + "demo": "modules/directives/components/directive-demo.ts" }, "register": { "demo": "modules/components/components/counter-card.ts" @@ -294,10 +294,10 @@ "demo": "app/features/boundaries/private/page.ts" }, "unsafeHTML": { - "exempt": "deferred: trusted raw-HTML directive; agent-docs/components.md (#859)" + "demo": "modules/directives/components/directive-demo.ts" }, "until": { - "exempt": "deferred: until directive; agent-docs/components.md (#859)" + "demo": "modules/directives/components/directive-demo.ts" }, "watch": { "demo": "modules/directives/components/directive-demo.ts" From d702f5075d3d59822cb4db0dd56932f596435d94 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 9 Jul 2026 13:48:22 +0530 Subject: [PATCH 4/9] feat: demo error/loading/not-found boundaries in the scaffold Round out the boundaries feature with the three remaining nested boundary conventions: error.ts (with a crash/ sub-route that throws so it is reachable), loading.ts (the Suspense fallback), and not-found.ts (nearest 404, served for an unmatched subtree URL). Index links the new routes. Flips error/loading/not-found conventions to demonstrated (conventions 9->12). Verified: /crash renders 500, an unmatched URL 404. --- .../app/features/boundaries/crash/page.ts | 7 +++++++ .../gallery/app/features/boundaries/error.ts | 16 ++++++++++++++++ .../gallery/app/features/boundaries/loading.ts | 15 +++++++++++++++ .../app/features/boundaries/not-found.ts | 17 +++++++++++++++++ .../gallery/app/features/boundaries/page.ts | 10 ++++++++++ test/scaffolds/gallery-coverage.json | 6 +++--- 6 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 packages/cli/templates/gallery/app/features/boundaries/crash/page.ts create mode 100644 packages/cli/templates/gallery/app/features/boundaries/error.ts create mode 100644 packages/cli/templates/gallery/app/features/boundaries/loading.ts create mode 100644 packages/cli/templates/gallery/app/features/boundaries/not-found.ts diff --git a/packages/cli/templates/gallery/app/features/boundaries/crash/page.ts b/packages/cli/templates/gallery/app/features/boundaries/crash/page.ts new file mode 100644 index 00000000..506117d3 --- /dev/null +++ b/packages/cli/templates/gallery/app/features/boundaries/crash/page.ts @@ -0,0 +1,7 @@ +// A page that throws a real render error, so the nearest error.ts boundary +// (../error.ts) catches it. In a real app an unexpected throw (a failed query, +// a bug) lands here; expected failures should return an ActionResult or throw +// notFound() / forbidden() instead. +export default function Crash() { + throw new Error('demo: this page threw during render'); +} diff --git a/packages/cli/templates/gallery/app/features/boundaries/error.ts b/packages/cli/templates/gallery/app/features/boundaries/error.ts new file mode 100644 index 00000000..4804de68 --- /dev/null +++ b/packages/cli/templates/gallery/app/features/boundaries/error.ts @@ -0,0 +1,16 @@ +// error.ts is the error boundary for this segment's subtree. A render-time +// exception in a sibling or deeper page (see crash/page.ts) is caught here and +// rendered scoped to this boundary, so outer layouts stay alive. The default +// export receives { error, ...ctx }; in production only error.message is sent. +import { html } from '@webjsdev/core'; + +export default function BoundariesError({ error }: { error: Error }) { + return html` +

Something went wrong

+

+ This segment's error.ts boundary caught a + render error: ${error?.message ?? 'unknown'}. +

+

Back to boundaries

+ `; +} diff --git a/packages/cli/templates/gallery/app/features/boundaries/loading.ts b/packages/cli/templates/gallery/app/features/boundaries/loading.ts new file mode 100644 index 00000000..5de85bce --- /dev/null +++ b/packages/cli/templates/gallery/app/features/boundaries/loading.ts @@ -0,0 +1,15 @@ +// loading.ts auto-wraps the sibling page in a Suspense boundary: its default +// export is the fallback shown while an async page (or its streamed regions) +// resolves. The boundaries index is fast, so you rarely see this, but any async +// page in this subtree gets this skeleton for free during navigation. +import { html } from '@webjsdev/core'; + +export default function BoundariesLoading() { + return html` +
+
+
+
+
+ `; +} diff --git a/packages/cli/templates/gallery/app/features/boundaries/not-found.ts b/packages/cli/templates/gallery/app/features/boundaries/not-found.ts new file mode 100644 index 00000000..2f6b5b58 --- /dev/null +++ b/packages/cli/templates/gallery/app/features/boundaries/not-found.ts @@ -0,0 +1,17 @@ +// not-found.ts is the nearest 404 boundary for this subtree. It renders both +// for a thrown notFound() in a page below here AND for an unmatched URL under +// /features/boundaries/ (try /features/boundaries/does-not-exist). Nearest wins, +// so this beats the root not-found for anything in this segment. +import { html } from '@webjsdev/core'; + +export default function BoundariesNotFound() { + return html` +

404: Not here

+

+ This segment's not-found.ts boundary rendered, + because a page threw notFound() or the URL + matched nothing under this segment. +

+

Back to boundaries

+ `; +} diff --git a/packages/cli/templates/gallery/app/features/boundaries/page.ts b/packages/cli/templates/gallery/app/features/boundaries/page.ts index d5fc93a7..77e89ea8 100644 --- a/packages/cli/templates/gallery/app/features/boundaries/page.ts +++ b/packages/cli/templates/gallery/app/features/boundaries/page.ts @@ -40,6 +40,16 @@ export default function BoundariesExample() { throws unauthorized(), caught by private/unauthorized.ts (401). +
  • + /features/boundaries/crash + throws a render error, caught by this segment's + error.ts (500). +
  • +
  • + /features/boundaries/does-not-exist + matches nothing, caught by the nearest + not-found.ts (404). +
  • forbidden() is for an authenticated user who diff --git a/test/scaffolds/gallery-coverage.json b/test/scaffolds/gallery-coverage.json index b6bdbd1e..0783dad3 100644 --- a/test/scaffolds/gallery-coverage.json +++ b/test/scaffolds/gallery-coverage.json @@ -701,16 +701,16 @@ "demoed": true }, "error": { - "exempt": "deferred: no example error file shipped by the scaffold yet (#865)" + "demoed": true }, "loading": { - "exempt": "deferred: no example loading file shipped by the scaffold yet (#865)" + "demoed": true }, "middleware": { "demoed": true }, "not-found": { - "exempt": "deferred: no example not-found file shipped by the scaffold yet (#865)" + "demoed": true }, "forbidden": { "demoed": true From 09c157bd8878252d97ff42c6e031b44deb0a913c Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 9 Jul 2026 13:52:02 +0530 Subject: [PATCH 5/9] feat: demo shadow-DOM css + computed/effect/batch in the scaffold Add a shadow-DOM component to the components feature: static shadow=true with static styles=css`` (scoped CSS, the one place the lit reflex is right), plus computed (derived signal), effect (a browser-side reaction), and batch (coalesced writes). Flips css/computed/ effect/batch from deferred to demoed (core 18->22). Shadow DSD verified in SSR output. --- .../gallery/app/features/components/page.ts | 3 + .../components/components/reactive-meter.ts | 64 +++++++++++++++++++ test/scaffolds/gallery-coverage.json | 28 ++++---- 3 files changed, 81 insertions(+), 14 deletions(-) create mode 100644 packages/cli/templates/gallery/modules/components/components/reactive-meter.ts diff --git a/packages/cli/templates/gallery/app/features/components/page.ts b/packages/cli/templates/gallery/app/features/components/page.ts index 4968c89f..e3fe7822 100644 --- a/packages/cli/templates/gallery/app/features/components/page.ts +++ b/packages/cli/templates/gallery/app/features/components/page.ts @@ -2,6 +2,7 @@ import { html } from '@webjsdev/core'; import type { Metadata } from '@webjsdev/core'; import '#modules/components/components/counter-card.ts'; +import '#modules/components/components/reactive-meter.ts'; export const metadata: Metadata = { title: 'Components (signals + slots) | features' }; @@ -10,5 +11,7 @@ export default function ComponentsExample() {

    Components

    The WebComponent factory, a reactive prop, an instance signal, and a slot.

    A slotted title +

    Shadow DOM (scoped css) plus the rest of the signals API (computed, effect, batch):

    + `; } diff --git a/packages/cli/templates/gallery/modules/components/components/reactive-meter.ts b/packages/cli/templates/gallery/modules/components/components/reactive-meter.ts new file mode 100644 index 00000000..c8cb3e9f --- /dev/null +++ b/packages/cli/templates/gallery/modules/components/components/reactive-meter.ts @@ -0,0 +1,64 @@ +// A shadow-DOM component: `static shadow = true` scopes `static styles = css\`\`` +// to this element (bare selectors do not leak out), the one place the lit reflex +// to scope CSS is right in webjs. It also shows the rest of the signals API: +// `computed` (a derived signal), `effect` (a browser-side reaction that runs on +// change), and `batch` (coalesce several writes into ONE re-render). +import { WebComponent, html, css, signal, computed, effect, batch } from '@webjsdev/core'; + +export class ReactiveMeter extends WebComponent { + static shadow = true; + static styles = css` + .row { display: flex; align-items: center; gap: 8px; } + button { + padding: 6px 12px; border-radius: 10px; border: 1px solid #8883; + background: transparent; color: inherit; cursor: pointer; font: inherit; + } + .val { font-variant-numeric: tabular-nums; font-weight: 600; } + .muted { opacity: 0.6; font-size: 13px; } + `; + + private count = signal(0); + // computed: recomputed lazily when count changes, cached otherwise. + private doubled = computed(() => this.count.get() * 2); + private lastLogged = signal(''); + private disposeEffect?: () => void; + + connectedCallback() { + super.connectedCallback(); + // effect: runs now and again whenever a signal it reads changes. Browser-only + // (SSR never calls connectedCallback), so it is the place for reactive + // side effects. + this.disposeEffect = effect(() => { + this.lastLogged.set('count is ' + this.count.get()); + }); + } + disconnectedCallback() { + super.disconnectedCallback?.(); + this.disposeEffect?.(); + } + + private bump(n: number) { + this.count.set(this.count.get() + n); + } + // batch: both writes commit together, so the component re-renders ONCE. + private reset() { + batch(() => { + this.count.set(0); + this.lastLogged.set('reset'); + }); + } + + render() { + return html` +
    + + ${this.count.get()} + + doubled: ${this.doubled.get()} + +
    +

    ${this.lastLogged.get()}

    + `; + } +} +ReactiveMeter.register('reactive-meter'); diff --git a/test/scaffolds/gallery-coverage.json b/test/scaffolds/gallery-coverage.json index 0783dad3..1389e0d9 100644 --- a/test/scaffolds/gallery-coverage.json +++ b/test/scaffolds/gallery-coverage.json @@ -66,13 +66,13 @@ "exempt": "deferred: asyncReplace directive; agent-docs/components.md (#859)" }, "batch": { - "exempt": "deferred: signal batching; agent-docs/components.md (#859)" + "demo": "modules/components/components/reactive-meter.ts" }, "cache": { "exempt": "deferred: lit cache directive (distinct from the server cache() helper); agent-docs/advanced.md (#859)" }, "computed": { - "exempt": "deferred: derived signal; agent-docs/components.md (#859)" + "demo": "modules/components/components/reactive-meter.ts" }, "connectWS": { "demo": "modules/broadcast/components/broadcast-feed.ts" @@ -93,7 +93,7 @@ "exempt": "deferred: CSP nonce accessor; agent-docs/configuration.md (#859)" }, "css": { - "exempt": "deferred: shadow-DOM scoped styles; documented in agent-docs/styling.md (#859)" + "demo": "modules/components/components/reactive-meter.ts" }, "deserialize": { "exempt": "internal: wire serializer; app code round-trips via server actions" @@ -102,7 +102,7 @@ "exempt": "deferred: client-router opt-out; agent-docs/configuration.md (#859)" }, "effect": { - "exempt": "deferred: signal effect; agent-docs/components.md (#859)" + "demo": "modules/components/components/reactive-meter.ts" }, "enableClientRouter": { "exempt": "deferred: client-router opt-in; agent-docs/configuration.md (#859)" @@ -354,7 +354,7 @@ "exempt": "internal: framework/CLI plumbing" }, "attachWebSocket": { - "exempt": "deferred: WS upgrade helper (#865)" + "exempt": "internal: WS upgrade helper; apps export a WS() handler from route.ts" }, "auditPinned": { "exempt": "internal: framework/CLI plumbing" @@ -393,13 +393,13 @@ "exempt": "deferred: cookie session strategy (#865)" }, "cookieSessionStorage": { - "exempt": "deferred: cookie session storage (#865)" + "exempt": "internal: cookie session storage primitive" }, "cookies": { "exempt": "deferred: request cookie accessor (#865)" }, "cookiesToHeader": { - "exempt": "deferred: cookie serializer (#865)" + "exempt": "internal: low-level cookie serializer; apps use cookies()" }, "cors": { "demoed": true @@ -471,10 +471,10 @@ "exempt": "deferred: session read (#865)" }, "getSetCookies": { - "exempt": "deferred: Set-Cookie reader (#865)" + "exempt": "internal: low-level Set-Cookie reader" }, "getStore": { - "exempt": "deferred: active store accessor (#865)" + "exempt": "internal: active-store accessor; apps use setStore()" }, "handleApi": { "exempt": "internal: framework/CLI plumbing" @@ -588,10 +588,10 @@ "demoed": true }, "runInstrumentation": { - "exempt": "deferred: instrumentation runner (#865)" + "exempt": "internal: the framework runs it; apps write instrumentation.ts register()" }, "runWithActionSignal": { - "exempt": "deferred: action-signal scope helper (#865)" + "exempt": "internal: action-signal scope helper; apps use actionSignal()" }, "satisfiesSemverRange": { "exempt": "internal: framework/CLI plumbing" @@ -648,10 +648,10 @@ "exempt": "deferred: programmatic server boot (#865)" }, "storeSession": { - "exempt": "deferred: session persistence (#865)" + "exempt": "internal: session persistence primitive; apps use createAuth()/session" }, "storeSessionStorage": { - "exempt": "deferred: session storage (#865)" + "exempt": "internal: session storage primitive" }, "stream": { "demoed": true @@ -684,7 +684,7 @@ "exempt": "deferred: signed URL verifier (#865)" }, "withCookies": { - "exempt": "deferred: cookie response helper (#865)" + "exempt": "internal: low-level response cookie helper; apps use cookies()" }, "withRequest": { "exempt": "internal: framework/CLI plumbing" From 91492c8ad04aad0216d8748f6a0a210858e77b25 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 9 Jul 2026 13:53:00 +0530 Subject: [PATCH 6/9] feat: demo navigate()/revalidate() in the client-router gallery Add a component to the client-router feature that drives programmatic soft navigation (navigate()) and browser snapshot eviction (revalidate()) from JS handlers. Flips navigate/revalidate to demoed (core 22->24). --- .../app/features/client-router/page.ts | 3 +++ .../components/router-controls.ts | 24 +++++++++++++++++++ test/scaffolds/gallery-coverage.json | 4 ++-- 3 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 packages/cli/templates/gallery/modules/client-router/components/router-controls.ts diff --git a/packages/cli/templates/gallery/app/features/client-router/page.ts b/packages/cli/templates/gallery/app/features/client-router/page.ts index 40087c04..a741b208 100644 --- a/packages/cli/templates/gallery/app/features/client-router/page.ts +++ b/packages/cli/templates/gallery/app/features/client-router/page.ts @@ -8,6 +8,7 @@ // JS off, every link is a normal full-page navigation. import { html } from '@webjsdev/core'; import type { Metadata } from '@webjsdev/core'; +import '#modules/client-router/components/router-controls.ts'; export const metadata: Metadata = { title: 'Client router (soft nav) | features' }; @@ -24,6 +25,8 @@ export default function ClientRouterExample() { Go to page two Home
    +

    Or drive it from JS with navigate() / revalidate():

    +

    Opt out app-wide with { "webjs": { "clientRouter": false } }, or per-link with data-no-router (use it for diff --git a/packages/cli/templates/gallery/modules/client-router/components/router-controls.ts b/packages/cli/templates/gallery/modules/client-router/components/router-controls.ts new file mode 100644 index 00000000..1be9883c --- /dev/null +++ b/packages/cli/templates/gallery/modules/client-router/components/router-controls.ts @@ -0,0 +1,24 @@ +// Programmatic client navigation. `navigate(url)` does the same soft, in-place +// swap an click does, but from an event handler (after a save, a wizard +// step, etc.). `revalidate(url?)` evicts the browser snapshot cache so the next +// visit refetches fresh HTML instead of the cached page. Both are client-only +// (they run in the browser), so a component is the right home; a page/layout +// never hydrates. With JS off this component is inert, so keep real navigation +// on plain and use navigate() only for JS-driven flows. +import { WebComponent, html, navigate, revalidate } from '@webjsdev/core'; + +export class RouterControls extends WebComponent { + render() { + return html` +

    + `; + } +} +RouterControls.register('router-controls'); diff --git a/test/scaffolds/gallery-coverage.json b/test/scaffolds/gallery-coverage.json index 1389e0d9..7b00e41e 100644 --- a/test/scaffolds/gallery-coverage.json +++ b/test/scaffolds/gallery-coverage.json @@ -207,7 +207,7 @@ "exempt": "internal: stale-while-revalidate plumbing" }, "navigate": { - "exempt": "deferred: programmatic client navigation; mentioned in the routing demo prose (#859)" + "demo": "modules/client-router/components/router-controls.ts" }, "notFound": { "demo": "app/features/routing/[id]/page.ts" @@ -255,7 +255,7 @@ "demo": "modules/directives/components/directive-demo.ts" }, "revalidate": { - "exempt": "deferred: client snapshot eviction; agent-docs/built-ins.md (#859)" + "demo": "modules/client-router/components/router-controls.ts" }, "richFetch": { "exempt": "deferred: content-negotiated rich fetch; agent-docs/advanced.md (#859)" From e3f7d287ef22d02349c47cbdd2088cef70d6795f Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 9 Jul 2026 14:04:36 +0530 Subject: [PATCH 7/9] fix: manifest literal placeholder + until() re-render flicker in scaffold demos Two review findings: (1) gallery files are copied verbatim with no {{APP_NAME}} substitution, so manifest.ts served a literal '{{APP_NAME}}' in /manifest.json; hardcode a neutral 'webjs app' the user adapts. (2) the directives demo called later() inside render(), so until()'s new promise identity each render flashed back to the fallback on any re-render; create the promise once in a field. Also rephrase the inline comments to satisfy the prose-punctuation invariant. --- packages/cli/templates/gallery/app/manifest.ts | 7 ++++--- .../directives/components/directive-demo.ts | 17 ++++++++++------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/cli/templates/gallery/app/manifest.ts b/packages/cli/templates/gallery/app/manifest.ts index e7b42790..10d3c027 100644 --- a/packages/cli/templates/gallery/app/manifest.ts +++ b/packages/cli/templates/gallery/app/manifest.ts @@ -5,11 +5,12 @@ // app/manifest.ts serves /manifest.json (the web app manifest). The default // export returns an object, serialized to JSON. Adapt the name, colors, and // icons to your app; pair it with the opt-in service worker for an installable -// PWA. See agent-docs/service-worker.md. +// PWA. See agent-docs/service-worker.md. (Gallery files are copied verbatim, so +// set the real app name here by hand rather than expecting substitution.) export default function Manifest() { return { - name: '{{APP_NAME}}', - short_name: '{{APP_NAME}}', + name: 'webjs app', + short_name: 'webjs app', start_url: '/', display: 'standalone', background_color: '#ffffff', diff --git a/packages/cli/templates/gallery/modules/directives/components/directive-demo.ts b/packages/cli/templates/gallery/modules/directives/components/directive-demo.ts index 4ec3f25e..96ce0569 100644 --- a/packages/cli/templates/gallery/modules/directives/components/directive-demo.ts +++ b/packages/cli/templates/gallery/modules/directives/components/directive-demo.ts @@ -26,6 +26,9 @@ export class DirectiveDemo extends WebComponent { private variant = signal(0); // A handle to the input node, attached by `ref` in the browser. private inputRef = createRef(); + // Created ONCE (not per render), so `until` keeps the resolved value across + // re-renders instead of flashing back to the fallback each time. + private asyncValue: Promise = this.later(); private reverse() { this.items.set(this.items.get().slice().reverse()); @@ -67,9 +70,9 @@ export class DirectiveDemo extends WebComponent {
    - +
    this.focusInput()} class="px-3.5 py-1.5 rounded-xl bg-card border border-border text-foreground text-sm cursor-pointer transition-colors hover:border-border-strong">Focus
    - -

    async: ${until(this.later(), 'loading...')}

    - + +

    async: ${until(this.asyncValue, 'loading...')}

    + ${keyed(variant, html`
    ${unsafeHTML('fresh subtree')} #${variant}
    `)} From b127796062e3e8126527b993821e2d9762d34675 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 9 Jul 2026 14:11:21 +0530 Subject: [PATCH 8/9] fix: reclassify session/store exports back to deferred (over-reached internal) Review round 2: cookieSessionStorage, storeSessionStorage (+ its alias storeSession), and getStore are app-facing and documented (docs/sessions, docs/cache, agent-docs/built-ins), so internal was wrong. Move them back to deferred so they stay in the demo backlog. cookieSessionStorage now matches its alias cookieSession. Six genuinely-internal reclassifications (attachWebSocket, runInstrumentation, low-level cookie serializers, runWithActionSignal) stand. --- test/scaffolds/gallery-coverage.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/scaffolds/gallery-coverage.json b/test/scaffolds/gallery-coverage.json index 7b00e41e..38512864 100644 --- a/test/scaffolds/gallery-coverage.json +++ b/test/scaffolds/gallery-coverage.json @@ -393,7 +393,7 @@ "exempt": "deferred: cookie session strategy (#865)" }, "cookieSessionStorage": { - "exempt": "internal: cookie session storage primitive" + "exempt": "deferred: cookie session storage strategy; documented in docs/app/docs/sessions (#859)" }, "cookies": { "exempt": "deferred: request cookie accessor (#865)" @@ -474,7 +474,7 @@ "exempt": "internal: low-level Set-Cookie reader" }, "getStore": { - "exempt": "internal: active-store accessor; apps use setStore()" + "exempt": "deferred: active-store read API; docs/app/docs/cache + agent-docs/built-ins (#859)" }, "handleApi": { "exempt": "internal: framework/CLI plumbing" @@ -648,10 +648,10 @@ "exempt": "deferred: programmatic server boot (#865)" }, "storeSession": { - "exempt": "internal: session persistence primitive; apps use createAuth()/session" + "exempt": "deferred: production session storage (alias of storeSessionStorage); docs/app/docs/sessions (#859)" }, "storeSessionStorage": { - "exempt": "internal: session storage primitive" + "exempt": "deferred: production (Redis-backed) session storage; docs/app/docs/sessions + cache (#859)" }, "stream": { "demoed": true From 4c599c526d2aa97a684c791ba7a5da51411426ba Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 9 Jul 2026 14:21:27 +0530 Subject: [PATCH 9/9] fix: correct more internal/deferred mis-classes from #861 regex bucketing Review round 3: the #861 heuristic wrongly marked documented app-facing exports internal. Move render/renderStream/renderToString (public API), readBody/stampRemoteIp (api-routes/rate-limiting docs), attachWebSocket + the buildRouteTable/matchPage/matchApi/rawActionRequest test helpers (testing docs) to deferred. A proactive doc-import sweep of every remaining internal-marked core+server export confirms none others are mis-classed (parse/stringify appear only in a 'you never see this file' generated-stub snippet, so they stay internal). --- test/scaffolds/gallery-coverage.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/test/scaffolds/gallery-coverage.json b/test/scaffolds/gallery-coverage.json index 38512864..cac01319 100644 --- a/test/scaffolds/gallery-coverage.json +++ b/test/scaffolds/gallery-coverage.json @@ -240,16 +240,16 @@ "exempt": "internal: keyed-list registry plumbing" }, "render": { - "exempt": "internal: client render entry; the framework calls it, app code writes components" + "exempt": "deferred: client render(v, el) into a DOM element; public API, agent-docs/advanced (#859)" }, "renderStream": { - "exempt": "internal: the applier is auto-wired by the client router; app uses the tag / connectWS" + "exempt": "deferred: applier for a connectWS handler; public API, agent-docs/advanced (#859)" }, "renderToStream": { "exempt": "internal: server render entry; used by the framework and tests, not authored in app pages" }, "renderToString": { - "exempt": "internal: server render entry; used by the framework and tests, not authored in app pages" + "exempt": "deferred: SSR-to-string for test assertions; @webjsdev/core/server, agent-docs/testing (#859)" }, "repeat": { "demo": "modules/directives/components/directive-demo.ts" @@ -354,7 +354,7 @@ "exempt": "internal: framework/CLI plumbing" }, "attachWebSocket": { - "exempt": "internal: WS upgrade helper; apps export a WS() handler from route.ts" + "exempt": "deferred: WS test-server helper; docs/app/docs/testing (#859)" }, "auditPinned": { "exempt": "internal: framework/CLI plumbing" @@ -372,7 +372,7 @@ "exempt": "internal: framework/CLI plumbing" }, "buildRouteTable": { - "exempt": "internal: framework/CLI plumbing" + "exempt": "deferred: documented test helper (build a route table); docs/app/docs/testing (#859)" }, "cache": { "demoed": true @@ -516,10 +516,10 @@ "demoed": true }, "matchApi": { - "exempt": "internal: framework/CLI plumbing" + "exempt": "deferred: documented test helper (match an API route); docs/app/docs/testing (#859)" }, "matchPage": { - "exempt": "internal: framework/CLI plumbing" + "exempt": "deferred: documented test helper (match a page route); docs/app/docs/testing (#859)" }, "memoryStore": { "exempt": "deferred: in-memory store (#865)" @@ -546,10 +546,10 @@ "demoed": true }, "rawActionRequest": { - "exempt": "internal: framework/CLI plumbing" + "exempt": "deferred: documented test helper (build an action request); docs/app/docs/testing (#859)" }, "readBody": { - "exempt": "internal: framework/CLI plumbing" + "exempt": "deferred: route-handler body helper (inverse of json()); docs/app/docs/api-routes (#859)" }, "readPinFile": { "exempt": "internal: framework/CLI plumbing" @@ -642,7 +642,7 @@ "exempt": "internal: framework/CLI plumbing" }, "stampRemoteIp": { - "exempt": "internal: framework/CLI plumbing" + "exempt": "deferred: embedded-adapter IP helper; docs/app/docs/rate-limiting + agent-docs/advanced (#859)" }, "startServer": { "exempt": "deferred: programmatic server boot (#865)"