@webjsdev/cli 0.10.45 → 0.10.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/lib/create.js +109 -141
  2. package/package.json +1 -1
  3. package/templates/.agents/rules/workflow.md +7 -3
  4. package/templates/.agents/skills/webjs/SKILL.md +4 -2
  5. package/templates/.agents/skills/webjs/references/auth-and-sessions.md +78 -16
  6. package/templates/.agents/skills/webjs/references/built-ins.md +16 -2
  7. package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +28 -4
  8. package/templates/.agents/skills/webjs/references/components.md +82 -2
  9. package/templates/.agents/skills/webjs/references/data-and-actions.md +19 -2
  10. package/templates/.agents/skills/webjs/references/optimistic-ui.md +18 -0
  11. package/templates/.agents/skills/webjs/references/routing-and-pages.md +25 -2
  12. package/templates/.agents/skills/webjs/references/styling.md +82 -2
  13. package/templates/AGENTS.md +12 -5
  14. package/templates/gallery/app/apple-icon.ts +2 -5
  15. package/templates/gallery/app/examples/layout.ts +7 -2
  16. package/templates/gallery/app/examples/todo/page.ts +2 -1
  17. package/templates/gallery/app/features/async-render/page.ts +3 -2
  18. package/templates/gallery/app/features/auth/dashboard/layout.ts +2 -1
  19. package/templates/gallery/app/features/auth/dashboard/page.ts +4 -2
  20. package/templates/gallery/app/features/auth/dashboard/settings/page.ts +2 -1
  21. package/templates/gallery/app/features/auth/login/middleware.ts +15 -0
  22. package/templates/gallery/app/features/auth/login/page.ts +7 -4
  23. package/templates/gallery/app/features/auth/page.ts +6 -5
  24. package/templates/gallery/app/features/auth/signup/middleware.ts +11 -0
  25. package/templates/gallery/app/features/auth/signup/page.ts +7 -4
  26. package/templates/gallery/app/features/boundaries/error.ts +5 -4
  27. package/templates/gallery/app/features/boundaries/gated/forbidden.ts +5 -4
  28. package/templates/gallery/app/features/boundaries/not-found.ts +5 -4
  29. package/templates/gallery/app/features/boundaries/page.ts +11 -10
  30. package/templates/gallery/app/features/boundaries/private/unauthorized.ts +5 -4
  31. package/templates/gallery/app/features/broadcast/page.ts +4 -3
  32. package/templates/gallery/app/features/caching/page.ts +5 -4
  33. package/templates/gallery/app/features/client-router/page.ts +7 -5
  34. package/templates/gallery/app/features/client-router/second/page.ts +4 -3
  35. package/templates/gallery/app/features/components/page.ts +3 -2
  36. package/templates/gallery/app/features/directives/page.ts +3 -2
  37. package/templates/gallery/app/features/env/page.ts +4 -3
  38. package/templates/gallery/app/features/file-storage/page.ts +8 -5
  39. package/templates/gallery/app/features/forms/page.ts +10 -6
  40. package/templates/gallery/app/features/frames/page.ts +16 -8
  41. package/templates/gallery/app/features/layout.ts +60 -5
  42. package/templates/gallery/app/features/metadata/page.ts +7 -6
  43. package/templates/gallery/app/features/optimistic-ui/page.ts +3 -2
  44. package/templates/gallery/app/features/rate-limit/page.ts +6 -5
  45. package/templates/gallery/app/features/route-handler/page.ts +4 -3
  46. package/templates/gallery/app/features/routing/[id]/page.ts +7 -6
  47. package/templates/gallery/app/features/routing/page.ts +10 -9
  48. package/templates/gallery/app/features/server-actions/page.ts +5 -4
  49. package/templates/gallery/app/features/service-worker/page.ts +4 -3
  50. package/templates/gallery/app/features/sessions/page.ts +5 -4
  51. package/templates/gallery/app/features/stream/page.ts +4 -3
  52. package/templates/gallery/app/features/streaming/page.ts +4 -3
  53. package/templates/gallery/app/features/suspense/page.ts +4 -3
  54. package/templates/gallery/app/features/view-transitions/page.ts +6 -3
  55. package/templates/gallery/app/features/view-transitions/second/page.ts +4 -2
  56. package/templates/gallery/app/features/websockets/page.ts +4 -3
  57. package/templates/gallery/app/global-error.ts +2 -5
  58. package/templates/gallery/app/global-not-found.ts +4 -6
  59. package/templates/gallery/app/icon.ts +2 -5
  60. package/templates/gallery/app/manifest.ts +1 -4
  61. package/templates/gallery/app/opengraph-image.ts +3 -6
  62. package/templates/gallery/app/robots.ts +0 -3
  63. package/templates/gallery/app/sitemap.ts +0 -3
  64. package/templates/gallery/app/twitter-image.ts +3 -6
  65. package/templates/gallery/components/ui/badge.ts +41 -0
  66. package/templates/gallery/components/ui/button.ts +86 -0
  67. package/templates/gallery/components/ui/card.ts +36 -0
  68. package/templates/gallery/components/ui/input.ts +50 -0
  69. package/templates/gallery/lib/utils/ui.ts +31 -0
  70. package/templates/gallery/modules/broadcast/components/broadcast-feed.ts +4 -2
  71. package/templates/gallery/modules/caching/components/cache-buster.ts +2 -1
  72. package/templates/gallery/modules/client-router/components/router-controls.ts +4 -3
  73. package/templates/gallery/modules/components/components/counter-card.ts +4 -2
  74. package/templates/gallery/modules/components/components/reactive-meter.ts +9 -1
  75. package/templates/gallery/modules/components/components/task-loader.ts +3 -2
  76. package/templates/gallery/modules/components/components/theme-context.ts +5 -3
  77. package/templates/gallery/modules/directives/components/directive-demo.ts +17 -10
  78. package/templates/gallery/modules/gallery/components/gallery-nav.ts +54 -0
  79. package/templates/gallery/modules/gallery/nav.ts +79 -0
  80. package/templates/gallery/modules/optimistic-ui/components/like-button.ts +19 -1
  81. package/templates/gallery/modules/rate-limit/components/rate-probe.ts +2 -1
  82. package/templates/gallery/modules/route-handler/components/rich-data.ts +2 -1
  83. package/templates/gallery/modules/server-actions/components/greeter.ts +7 -4
  84. package/templates/gallery/modules/stream/components/stream-demo.ts +10 -5
  85. package/templates/gallery/modules/streaming/components/token-stream.ts +10 -3
  86. package/templates/gallery/modules/suspense/components/slow-fact.ts +2 -1
  87. package/templates/gallery/modules/todo/components/todo-app.ts +8 -4
  88. package/templates/gallery/modules/websockets/components/ws-echo.ts +5 -3
  89. package/templates/public/favicon.svg +10 -3
  90. package/templates/scripts/clear-gallery.mjs +126 -19
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The gallery's demo index, ONE source of truth for both the home cards and the
3
+ * left sidebar (so they can never drift). A browser-safe data module (no server
4
+ * imports, no client globals): the home flattens the groups into its card grid,
5
+ * and <gallery-nav> renders them grouped. gallery:clear removes this module.
6
+ */
7
+ export interface NavItem { href: string; title: string; blurb: string; }
8
+ export interface NavGroup { label: string; items: NavItem[]; }
9
+
10
+ export const FEATURE_GROUPS: NavGroup[] = [
11
+ {
12
+ label: 'Routing',
13
+ items: [
14
+ { href: '/features/routing', title: 'Routing', blurb: 'A static route plus a dynamic [id] segment that reads params. The file-based router in miniature.' },
15
+ { href: '/features/boundaries', title: 'Boundaries', blurb: 'The control-flow throws (forbidden / unauthorized / notFound) and the nearest boundary file that catches each.' },
16
+ { href: '/features/metadata', title: 'Metadata', blurb: 'Static metadata plus generateMetadata(ctx), which reads the request to compute the title and Open Graph tags.' },
17
+ ],
18
+ },
19
+ {
20
+ label: 'Components',
21
+ items: [
22
+ { href: '/features/components', title: 'Components', blurb: 'The WebComponent factory, reactive props, instance signals, and slot projection in light DOM.' },
23
+ { href: '/features/directives', title: 'Directives', blurb: 'The lit-html directive set: repeat for keyed lists, watch(signal) for a fine-grained node swap.' },
24
+ { href: '/features/async-render', title: 'Async render', blurb: 'A component that awaits server data in async render(), so the resolved value is in the first paint.' },
25
+ ],
26
+ },
27
+ {
28
+ label: 'Data & actions',
29
+ items: [
30
+ { 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.' },
31
+ { href: '/features/route-handler', title: 'Route handlers', blurb: 'A server-only route.ts HTTP endpoint returning JSON, the WebJs equivalent of a Next route handler.' },
32
+ { href: '/features/forms', title: 'Forms', blurb: 'A no-JS progressive-enhancement form posting to the page action, with server-side validation errors.' },
33
+ { href: '/features/optimistic-ui', title: 'Optimistic UI', blurb: 'The imperative optimistic(signal, value, action) flip: instant update, automatic rollback on failure.' },
34
+ ],
35
+ },
36
+ {
37
+ label: 'Client & streaming',
38
+ items: [
39
+ { href: '/features/client-router', title: 'Client router', blurb: 'Automatic soft navigation: fragment-only fetches, hover prefetch, scroll restore, and graceful no-JS fallback.' },
40
+ { href: '/features/view-transitions', title: 'View transitions', blurb: 'The opt-in view-transition meta cross-fades a soft navigation, with a data-webjs-permanent element persisted across the swap.' },
41
+ { href: '/features/streaming', title: 'Streaming actions', blurb: 'A use-server action that returns an async generator, streamed to the call site token by token with for await.' },
42
+ { href: '/features/stream', title: 'Stream updates', blurb: 'The <webjs-stream> element: renderStream() applies surgical append / replace / remove DOM updates by target id, no region redraw.' },
43
+ { href: '/features/suspense', title: 'Suspense boundary', blurb: 'The <webjs-suspense> element: a first-paint fallback for a SLOW component, with the resolved content streamed in.' },
44
+ { href: '/features/frames', title: 'Frames', blurb: 'A webjs-frame region that swaps a filtered sub-list in place from a link, shipping zero component JS, with a no-JS full-nav fallback.' },
45
+ ],
46
+ },
47
+ {
48
+ label: 'Real-time',
49
+ items: [
50
+ { href: '/features/websockets', title: 'WebSockets', blurb: 'A WS(ws, req) route endpoint plus the connectWS() client, echoing messages over a live socket.' },
51
+ { href: '/features/broadcast', title: 'Broadcast', blurb: 'Fan a message out to every connected client on a WebSocket path, so all open tabs stay in sync.' },
52
+ ],
53
+ },
54
+ {
55
+ label: 'Auth & sessions',
56
+ items: [
57
+ { href: '/features/auth', title: 'Auth', blurb: 'Password login on createAuth, a signed session cookie, and a real protected route that redirects anonymous visitors to login.' },
58
+ { href: '/features/sessions', title: 'Sessions', blurb: 'A signed-cookie session applied by a segment middleware, read and written per visitor with getSession() in a route.' },
59
+ ],
60
+ },
61
+ {
62
+ label: 'Built-ins',
63
+ items: [
64
+ { href: '/features/caching', title: 'Caching', blurb: 'export const revalidate caches the page HTML per URL, with the safety rule for when a shared cache is allowed.' },
65
+ { href: '/features/env', title: 'Env vars', blurb: 'The server-only vs WEBJS_PUBLIC_ boundary, read during SSR so secrets never reach the browser.' },
66
+ { href: '/features/rate-limit', title: 'Rate limiting', blurb: 'The rateLimit() middleware scoped to one endpoint, returning a 429 with Retry-After past the window.' },
67
+ { href: '/features/file-storage', title: 'File storage', blurb: 'A no-JS multipart upload streamed into the FileStore, then served back through a streaming route.' },
68
+ { href: '/features/service-worker', title: 'Service worker', blurb: 'The opt-in offline enhancement, registered from a browser-only lifecycle hook (never a page or layout).' },
69
+ ],
70
+ },
71
+ ];
72
+
73
+ /** The whole example apps (composed features), shown after the single-feature demos. */
74
+ export const EXAMPLES: NavItem[] = [
75
+ { href: '/examples/todo', title: 'Optimistic todo', blurb: 'A whole app composing several features: the declarative optimistic() list API, progressive-enhancement forms, accessible labels, the modules split, and SQLite.' },
76
+ ];
77
+
78
+ /** Flattened single-feature list (for the home card grid). */
79
+ export const FEATURES: NavItem[] = FEATURE_GROUPS.flatMap((g) => g.items);
@@ -9,6 +9,8 @@
9
9
  // see the declarative optimistic(host, { source, update }) + .add() form in the
10
10
  // /examples/todo app.
11
11
  import { WebComponent, signal, optimistic, html } from '@webjsdev/core';
12
+ import { cn } from '#lib/utils/cn.ts';
13
+ import { buttonClass } from '#components/ui/button.ts';
12
14
  import { likePost } from '../actions/like-post.server.ts';
13
15
 
14
16
  export class LikeButton extends WebComponent {
@@ -23,9 +25,25 @@ export class LikeButton extends WebComponent {
23
25
 
24
26
  render() {
25
27
  const liked = this.liked.get();
28
+ // Build ON the design system, do not re-implement it. The like toggle IS the
29
+ // button variants: unliked reads as `secondary` (card surface), liked as
30
+ // `default` (primary fill). So compose buttonClass() for the base + colors +
31
+ // focus ring + cursor, then override only the genuinely bespoke bits with cn()
32
+ // (which tailwind-merges): a pill radius over the helper's rounded-xl, and a
33
+ // border on the liked state. The border is GEOMETRY, not decoration: the
34
+ // `secondary` variant carries a 1px border and `default` carries none, so under
35
+ // box-sizing border-box an auto-width inline-flex button would change its outer
36
+ // size by ~1px each toggle (a small label jitter). Re-adding `border
37
+ // border-primary` keeps a 1px border in both states so the pill stays put. On
38
+ // the primary fill that border color matches the fill, so it reads as invisible,
39
+ // doing only the no-jitter job. Only the heart SVG stays fully custom.
26
40
  return html`
27
41
  <button @click=${() => this.toggle()} aria-pressed=${liked ? 'true' : 'false'}
28
- class="inline-flex items-center gap-2 px-4 py-2 rounded-full font-semibold text-sm border cursor-pointer transition-all active:scale-[0.97] ${liked ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-foreground border-border hover:border-border-strong'}">
42
+ class=${cn(
43
+ buttonClass({ variant: liked ? 'default' : 'secondary' }),
44
+ 'rounded-full',
45
+ liked && 'border border-primary',
46
+ )}>
29
47
  <svg viewBox="0 0 24 24" class="w-4 h-4 ${liked ? 'fill-current stroke-none' : 'fill-none stroke-current'}" style="stroke-width:2;stroke-linecap:round;stroke-linejoin:round"><path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1-1.1a5.5 5.5 0 0 0-7.8 7.8l1.1 1L12 21l7.7-7.6 1.1-1a5.5 5.5 0 0 0 0-7.8Z"/></svg>
30
48
  ${liked ? 'Liked' : 'Like'}
31
49
  </button>
@@ -5,6 +5,7 @@
5
5
  // import and call instead). The button is the interactivity signal, so this
6
6
  // component ships and hydrates.
7
7
  import { WebComponent, signal, html } from '@webjsdev/core';
8
+ import { buttonClass } from '#components/ui/button.ts';
8
9
 
9
10
  interface Probe { n: number; status: number; remaining: string }
10
11
 
@@ -32,7 +33,7 @@ export class RateProbe extends WebComponent {
32
33
  return html`
33
34
  <div class="grid gap-4 max-w-[420px]">
34
35
  <button @click=${() => this.ping()}
35
- class="w-fit px-3.5 py-2 rounded-xl bg-primary text-primary-foreground font-semibold text-sm border-0 cursor-pointer transition-all hover:bg-primary/90 active:scale-[0.97]">Ping the endpoint</button>
36
+ class="${buttonClass({ size: 'sm' })} w-fit">Ping the endpoint</button>
36
37
  <ul class="grid gap-1.5 list-none m-0 p-0">
37
38
  ${this.log.get().map((p) => html`
38
39
  <li class="flex items-center justify-between px-3 py-2 rounded-xl bg-card border border-border text-sm">
@@ -6,6 +6,7 @@
6
6
  // (it runs in the browser), so it lives in a component; with JS off this button
7
7
  // is inert and the page still reads.
8
8
  import { WebComponent, signal, html, richFetch } from '@webjsdev/core';
9
+ import { buttonClass } from '#components/ui/button.ts';
9
10
 
10
11
  interface RichPayload { at: Date; ip: string; requestId: string; cookieCount: number }
11
12
 
@@ -22,7 +23,7 @@ export class RichData extends WebComponent {
22
23
  return html`
23
24
  <div class="flex items-center gap-3 text-[15px]">
24
25
  <button @click=${() => this.load()}
25
- class="px-3.5 py-1.5 rounded-xl bg-primary text-primary-foreground font-semibold text-sm border-0 cursor-pointer transition-all hover:bg-primary/90 active:scale-[0.97]">richFetch() the route</button>
26
+ class=${buttonClass({ size: 'sm' })}>richFetch() the route</button>
26
27
  <span class="text-muted-foreground">${this.line.get()}</span>
27
28
  </div>
28
29
  `;
@@ -1,6 +1,9 @@
1
1
  // Calls the 'use server' action via a normal import (the RPC stub). Never
2
2
  // hand-write fetch(); importing the action IS the API.
3
3
  import { WebComponent, signal, html } from '@webjsdev/core';
4
+ import { cardClass } from '#components/ui/card.ts';
5
+ import { buttonClass } from '#components/ui/button.ts';
6
+ import { bareInputClass } from '#components/ui/input.ts';
4
7
  import { greet } from '../actions/greet.server.ts';
5
8
 
6
9
  export class Greeter extends WebComponent {
@@ -21,13 +24,13 @@ export class Greeter extends WebComponent {
21
24
  return html`
22
25
  <div class="grid gap-3 max-w-[420px]">
23
26
  <form @submit=${(e: SubmitEvent) => this.run(e)}
24
- class="flex items-center gap-2 p-2 pl-4 rounded-2xl bg-card border border-border">
27
+ class="${cardClass()} flex items-center gap-2 p-2 pl-4">
25
28
  <input name="name" placeholder="your name" autocomplete="off"
26
- class="flex-1 min-w-0 bg-transparent border-0 outline-none text-foreground text-[15px] placeholder:text-muted-foreground py-1.5" />
29
+ class=${bareInputClass()} />
27
30
  <button type="submit"
28
- class="shrink-0 px-4 py-2 rounded-xl bg-primary text-primary-foreground font-semibold text-sm border-0 cursor-pointer transition-all hover:bg-primary/90 active:scale-[0.97]">Greet</button>
31
+ class="${buttonClass()} shrink-0">Greet</button>
29
32
  </form>
30
- <p class="m-0 text-sm text-muted-foreground">The action is gated by requireAuth. <a class="text-primary" href="/features/auth/login">Sign in</a> to greet; signed out returns a real 401.</p>
33
+ <p class="m-0 text-sm text-muted-foreground">The action is gated by requireAuth. <a class="text-primary underline underline-offset-2" href="/features/auth/login">Sign in</a> to greet; signed out returns a real 401.</p>
31
34
  ${this.msg.get() ? html`<p class="m-0 font-semibold text-foreground">${this.msg.get()}</p>` : ''}
32
35
  </div>
33
36
  `;
@@ -12,6 +12,7 @@
12
12
  // That is the whole point: out-of-band updates that a signal re-render or a frame
13
13
  // region-swap would clobber.
14
14
  import { WebComponent, html, renderStream } from '@webjsdev/core';
15
+ import { buttonClass } from '#components/ui/button.ts';
15
16
 
16
17
  // Build a <webjs-stream> payload string. It is a plain string (NOT an html``
17
18
  // template), so interpolating the row markup here is fine. `remove` needs no
@@ -30,11 +31,15 @@ export class StreamDemo extends WebComponent {
30
31
  // component and blow away the streamed-in rows.
31
32
  #n = 2;
32
33
 
33
- append() {
34
+ // NOTE the method names: NOT append() / prepend(). Those are native
35
+ // ParentNode methods, and WebJs instruments them on every light-DOM host for
36
+ // the slot API (#1021), so a component method of the same name is shadowed and
37
+ // never runs. Name your handlers something else (see muscle-memory-gotchas).
38
+ appendRow() {
34
39
  this.#n++;
35
40
  renderStream(streamPayload('append', 'stream-list', row(`row-${this.#n}`, `Row ${this.#n} (appended)`)));
36
41
  }
37
- prepend() {
42
+ prependRow() {
38
43
  this.#n++;
39
44
  renderStream(streamPayload('prepend', 'stream-list', row(`row-${this.#n}`, `Row ${this.#n} (prepended)`)));
40
45
  }
@@ -53,12 +58,12 @@ export class StreamDemo extends WebComponent {
53
58
  }
54
59
 
55
60
  render() {
56
- const btn = 'px-3 py-1.5 rounded-lg bg-card border border-border text-sm font-medium text-foreground cursor-pointer transition-colors hover:border-border-strong';
61
+ const btn = buttonClass({ variant: 'secondary', size: 'xs' });
57
62
  return html`
58
63
  <div class="grid gap-4 max-w-[460px]">
59
64
  <div class="flex flex-wrap gap-2">
60
- <button class=${btn} @click=${() => this.append()}>Append</button>
61
- <button class=${btn} @click=${() => this.prepend()}>Prepend</button>
65
+ <button class=${btn} @click=${() => this.appendRow()}>Append</button>
66
+ <button class=${btn} @click=${() => this.prependRow()}>Prepend</button>
62
67
  <button class=${btn} @click=${() => this.replaceFirst()}>Replace Row 1</button>
63
68
  <button class=${btn} @click=${() => this.removeSecond()}>Remove Row 2</button>
64
69
  <button class=${btn} @click=${() => this.reset()}>Reset</button>
@@ -6,6 +6,8 @@
6
6
  // the button is inert and the empty output renders (streaming is inherently a
7
7
  // JS behaviour), so nothing here breaks the no-JS first paint.
8
8
  import { WebComponent, signal, html } from '@webjsdev/core';
9
+ import { cardClass } from '#components/ui/card.ts';
10
+ import { buttonClass } from '#components/ui/button.ts';
9
11
  import { streamTokens } from '../actions/stream-tokens.server.ts';
10
12
 
11
13
  export class TokenStream extends WebComponent {
@@ -29,16 +31,21 @@ export class TokenStream extends WebComponent {
29
31
 
30
32
  render() {
31
33
  const busy = this.busy.get();
34
+ const output = this.output.get();
35
+ // Only reserve the output area once streaming starts (or has produced text),
36
+ // so an idle demo has no empty box below the button.
32
37
  return html`
33
- <div class="rounded-2xl border border-border bg-card p-5">
38
+ <div class="${cardClass()} p-5">
34
39
  <button
35
40
  @click=${() => this.run()}
36
41
  ?disabled=${busy}
37
- class="inline-flex items-center px-4 py-2 rounded-xl bg-primary text-primary-foreground font-semibold text-sm transition-all hover:bg-primary/90 active:scale-[0.97] disabled:opacity-60"
42
+ class=${buttonClass()}
38
43
  >
39
44
  ${busy ? 'streaming…' : 'Stream tokens'}
40
45
  </button>
41
- <pre class="mt-4 whitespace-pre-wrap font-mono text-sm text-foreground min-h-[3rem]">${this.output.get()}</pre>
46
+ ${busy || output
47
+ ? html`<pre class="mt-4 whitespace-pre-wrap font-mono text-sm text-foreground">${output}</pre>`
48
+ : ''}
42
49
  </div>
43
50
  `;
44
51
  }
@@ -7,11 +7,12 @@
7
7
  // navigation too). Same component either way; the boundary is what changes the
8
8
  // SSR behaviour from block to stream.
9
9
  import { WebComponent, html } from '@webjsdev/core';
10
+ import { cardClass } from '#components/ui/card.ts';
10
11
 
11
12
  export class SlowFact extends WebComponent {
12
13
  async render() {
13
14
  await new Promise((r) => setTimeout(r, 900));
14
- return html`<p class="rounded-2xl border border-border bg-card p-5 text-foreground">
15
+ return html`<p class="${cardClass()} p-5 text-foreground">
15
16
  The answer, after a slow lookup, is <strong>42</strong>.
16
17
  </p>`;
17
18
  }
@@ -10,6 +10,10 @@
10
10
  // one coherent theme. Prefer these tokens (and opacity modifiers like
11
11
  // bg-primary/90) over ad-hoc colors.
12
12
  import { WebComponent, prop, optimistic, html } from '@webjsdev/core';
13
+ import { cardClass } from '#components/ui/card.ts';
14
+ import { buttonClass } from '#components/ui/button.ts';
15
+ import { bareInputClass } from '#components/ui/input.ts';
16
+ import { cn } from '#lib/utils/cn.ts';
13
17
  import { createTodo } from '../actions/create-todo.server.ts';
14
18
  import { toggleTodo } from '../actions/toggle-todo.server.ts';
15
19
  import { deleteTodo } from '../actions/delete-todo.server.ts';
@@ -94,12 +98,12 @@ export class TodoApp extends WebComponent({
94
98
  <!-- Add: a real <form> so it works with JS off (posts to the page action);
95
99
  with JS, @submit intercepts and runs the optimistic path. -->
96
100
  <form method="post" action="" @submit=${(e: SubmitEvent) => this.add(e)}
97
- class="flex items-center gap-2 p-2 pl-4 rounded-2xl bg-card border border-border shadow-[0_1px_0_0_color-mix(in_oklch,var(--foreground)_5%,transparent)]">
101
+ class="${cardClass()} flex items-center gap-2 p-2 pl-4 shadow-[0_1px_0_0_color-mix(in_oklch,var(--foreground)_5%,transparent)]">
98
102
  <input type="hidden" name="intent" value="create" />
99
103
  <input name="title" required maxlength="280" autocomplete="off" placeholder="What needs doing?"
100
- class="flex-1 min-w-0 bg-transparent border-0 outline-none text-foreground text-[15px] placeholder:text-muted-foreground py-1.5" />
104
+ class=${bareInputClass()} />
101
105
  <button type="submit"
102
- class="shrink-0 px-4 py-2 rounded-xl bg-primary text-primary-foreground font-semibold text-sm border-0 cursor-pointer transition-all hover:bg-primary/90 active:scale-[0.97]">Add</button>
106
+ class="${buttonClass()} shrink-0">Add</button>
103
107
  </form>
104
108
 
105
109
  <ul class="list-none m-0 p-0 grid gap-2">
@@ -124,7 +128,7 @@ export class TodoApp extends WebComponent({
124
128
  <!-- Delete: a proper icon button, revealed on row hover / focus. -->
125
129
  <button type="submit" name="intent" value="delete" aria-label="Delete task"
126
130
  @click=${(e: Event) => this.removeTodo(e, todo)}
127
- class="shrink-0 grid place-items-center w-7 h-7 rounded-lg border-0 bg-transparent text-muted-foreground cursor-pointer opacity-0 group-hover:opacity-100 focus:opacity-100 transition-all hover:text-destructive hover:bg-[color-mix(in_oklch,var(--color-destructive)_12%,transparent)]">
131
+ class=${cn(buttonClass({ variant: 'destructive', size: 'none' }), 'w-7 h-7 rounded-lg opacity-0 group-hover:opacity-100 focus:opacity-100')}>
128
132
  <svg viewBox="0 0 24 24" class="w-4 h-4 stroke-current fill-none" style="stroke-width:2;stroke-linecap:round;stroke-linejoin:round"><path d="M18 6 6 18M6 6l12 12"/></svg>
129
133
  </button>
130
134
  </form>
@@ -5,6 +5,8 @@
5
5
  // component renders its disconnected state, so with JS off the page still reads
6
6
  // (a live socket has no no-JS equivalent, which is the honest fallback here).
7
7
  import { WebComponent, signal, html, connectWS, renderStream } from '@webjsdev/core';
8
+ import { buttonClass } from '#components/ui/button.ts';
9
+ import { inputClass } from '#components/ui/input.ts';
8
10
 
9
11
  export class WsEcho extends WebComponent {
10
12
  private connected = signal(false);
@@ -59,9 +61,9 @@ export class WsEcho extends WebComponent {
59
61
  </div>
60
62
  <form @submit=${(e: SubmitEvent) => this.send(e)} class="flex gap-2">
61
63
  <input name="msg" autocomplete="off" placeholder="Say something"
62
- class="flex-1 px-3 py-2 rounded-xl bg-card border border-border text-foreground text-sm outline-none focus:border-border-strong" />
64
+ class=${inputClass('flex-1 min-w-0')} />
63
65
  <button type="submit"
64
- class="px-3.5 py-2 rounded-xl bg-primary text-primary-foreground font-semibold text-sm border-0 cursor-pointer transition-all hover:bg-primary/90 active:scale-[0.97]">Send</button>
66
+ class=${buttonClass({ size: 'sm' })}>Send</button>
65
67
  </form>
66
68
  <ul class="grid gap-1.5 list-none m-0 p-0">
67
69
  ${this.lines.get().map((line) => html`
@@ -70,7 +72,7 @@ export class WsEcho extends WebComponent {
70
72
  </ul>
71
73
  <div class="grid gap-2 border-t border-border pt-4">
72
74
  <button @click=${() => this.applyStreamUpdate()}
73
- class="w-fit 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">renderStream() an element-level update</button>
75
+ class="${buttonClass({ variant: 'secondary', size: 'sm' })} w-fit">renderStream() an element-level update</button>
74
76
  <ul id="ws-stream-log" class="grid gap-1.5 list-none m-0 p-0"></ul>
75
77
  </div>
76
78
  </div>
@@ -1,5 +1,12 @@
1
1
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" role="img" aria-label="WebJs">
2
- <rect width="32" height="32" rx="7" fill="#e9edf0"/>
3
- <path d="M8 11.5l4.5 4.5L8 20.5" fill="none" stroke="#1e2226" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
4
- <line x1="15.5" y1="21.5" x2="23" y2="21.5" stroke="#1e2226" stroke-width="2.6" stroke-linecap="round"/>
2
+ <!-- Matches the gallery navbar brand mark (a rounded square with a
3
+ foreground -> muted-foreground gradient), using the DARK-theme token
4
+ values (#dee2e6 -> #94989c) so it reads on a dark browser tab bar. -->
5
+ <defs>
6
+ <linearGradient id="wj" x1="0" y1="0" x2="1" y2="1">
7
+ <stop offset="0" stop-color="#dee2e6"/>
8
+ <stop offset="1" stop-color="#94989c"/>
9
+ </linearGradient>
10
+ </defs>
11
+ <rect width="32" height="32" rx="10" fill="url(#wj)"/>
5
12
  </svg>
@@ -9,18 +9,38 @@
9
9
  // same patterns and SURVIVES this reset, so nothing is lost), then run this
10
10
  // once to shed the gallery, then grow the app in place.
11
11
  //
12
- // It removes the gallery routes + modules + demo metadata routes, resets
13
- // app/page.ts to a minimal home, and drops the demo `todos` table plus the auth
14
- // card's `passwordHash` column from the schema. It KEEPS the agent skill
15
- // (.agents/skills/webjs/), the layout, the database wiring, the theme toggle, and
16
- // the example `users` table. It is a one-time reset: if the gallery is already
17
- // gone (no app/features/) it does nothing, so a rerun never clobbers an app you
18
- // built.
19
- import { rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
12
+ // It strips the app down to a TRULY BAREBONES blank slate: it removes the
13
+ // gallery routes + modules + demo metadata routes, the gallery's example design
14
+ // system (components/ui/), the example theme-toggle component, the example test
15
+ // suite (test/hello/), and every empty leftover dir, then resets app/page.ts to
16
+ // a minimal home AND app/layout.ts to a blank-slate base (the gallery's navbar,
17
+ // theme toggle, Google Fonts, AND design-token palette dropped: the reset layout
18
+ // carries NO design system, just the OS light/dark system colours, so the agent
19
+ // builds the palette as its own, guided by the skill), and drops the demo `todos`
20
+ // table plus the auth card's `passwordHash` column from the schema. It KEEPS only
21
+ // the buildable base: the agent skill (.agents/skills/webjs/), the reset root
22
+ // layout (the Tailwind wiring + a system-colour base, no tokens), the database
23
+ // wiring, the example `users` table, and `lib/utils/cn.ts` (the `webjs ui add`
24
+ // prerequisite).
25
+ //
26
+ // Why strip so much: the demos, the design system, and the example component /
27
+ // tests are all EXAMPLES to learn FROM, not a base to inherit. Leaving them
28
+ // nudges the implementing agent to lean on the scaffold's choices instead of
29
+ // building the app's own. The DURABLE knowledge is the pattern itself, which
30
+ // lives in the skill (.agents/skills/webjs/, re-read every session): learn it
31
+ // from the gallery here, then after this reset build your own (run
32
+ // `webjs ui add <name>` for UI primitives, cn.ts is kept for it) themed to your
33
+ // app. It is a one-time reset: if the gallery is already gone (no app/features/)
34
+ // it does nothing, so a rerun never clobbers an app you built.
35
+ import { rmSync, rmdirSync, existsSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
20
36
  import { join } from 'node:path';
21
37
 
22
38
  const root = process.cwd();
23
39
  const rm = (p) => { if (existsSync(join(root, p))) { rmSync(join(root, p), { recursive: true, force: true }); return true; } return false; };
40
+ // Remove a directory ONLY if it exists and is empty (used to clean the empty
41
+ // parents a partial gallery removal leaves behind, e.g. app/api after its lone
42
+ // auth handler is gone). Never touches a dir that still holds files.
43
+ const pruneEmpty = (p) => { const abs = join(root, p); if (existsSync(abs) && readdirSync(abs).length === 0) { rmdirSync(abs); return true; } return false; };
24
44
 
25
45
  // Guard: the gallery is identified by app/features/. If it is absent, the gallery
26
46
  // was already cleared (or this is not a gallery scaffold), so exit before any
@@ -40,13 +60,26 @@ const galleryPaths = [
40
60
  'app/icon.ts', 'app/apple-icon.ts', 'app/manifest.ts', 'app/opengraph-image.ts',
41
61
  'app/twitter-image.ts', 'app/robots.ts', 'app/sitemap.ts',
42
62
  'app/global-error.ts', 'app/global-not-found.ts',
63
+ // The gallery's EXAMPLE design system. Removed so the reset app is a blank
64
+ // slate: the agent builds its own components/ui/ (run `webjs ui add`, cn.ts is
65
+ // kept), learning the pattern from the skill, not inheriting the gallery's.
66
+ 'components/ui',
67
+ // Example scaffold artifacts (invariant 2): the theme-toggle component and the
68
+ // example test suite are things to learn from, not the agent's app. Removed
69
+ // for a true blank slate; the root layout is reset to a minimal base below
70
+ // (which drops its toggle wiring). The agent writes its own tests + components.
71
+ 'components/theme-toggle.ts', 'test/hello',
72
+ // The gallery's markup-chunk helpers. Their only users (the feature pages +
73
+ // the features/examples layouts) are removed above, so drop the example file
74
+ // too; cn.ts + dom.ts (real infrastructure) stay under lib/utils/.
75
+ 'lib/utils/ui.ts',
43
76
  ];
44
77
  // 2) The gallery's feature modules (by name). `auth` is the auth card's server
45
78
  // modules (createAuth config, password hashing, signup action, current-user
46
79
  // query), pruned with the rest of the card.
47
80
  const galleryModules = [
48
81
  'async-render', 'auth', 'broadcast', 'caching', 'client-router', 'components',
49
- 'directives', 'file-storage', 'frames', 'optimistic-ui', 'rate-limit',
82
+ 'directives', 'file-storage', 'frames', 'gallery', 'optimistic-ui', 'rate-limit',
50
83
  'route-handler', 'server-actions', 'sessions', 'stream', 'streaming', 'suspense',
51
84
  'todo', 'websockets',
52
85
  ].map((m) => `modules/${m}`);
@@ -71,17 +104,51 @@ if (existsSync(schemaPath)) {
71
104
  writeFileSync(schemaPath, s);
72
105
  }
73
106
 
74
- // 5) Drop generated migrations + the dev database so the next db:generate is
107
+ // 5) Reset the root layout to a blank-slate base. The gallery layout ships a
108
+ // navbar, a theme toggle, Google Fonts, and a full design-token palette; a blank
109
+ // slate should inherit NONE of that, so OVERWRITE it with a minimal layout that
110
+ // carries no design system: the Tailwind stylesheet + OS light/dark system
111
+ // colours (Canvas / CanvasText) + a bare <main>. The agent then builds the
112
+ // palette (CSS tokens + @theme + a light-dark() theme) as its own, guided by
113
+ // .agents/skills/webjs/references/styling.md.
114
+ // GUARD: overwrite only while the layout still carries the gallery's own navbar
115
+ // brand. A hand-customised layout (the brand removed / replaced) is the user's
116
+ // work, so it is KEPT and only the dangling references to the removed
117
+ // theme-toggle file are stripped surgically.
118
+ const layoutPath = join(root, 'app/layout.ts');
119
+ if (existsSync(layoutPath)) {
120
+ const current = readFileSync(layoutPath, 'utf8');
121
+ if (current.includes('WebJs Gallery')) {
122
+ writeFileSync(layoutPath, MINIMAL_LAYOUT());
123
+ } else {
124
+ console.log('app/layout.ts looks customised; keeping it (only the theme-toggle wiring is stripped).');
125
+ writeFileSync(
126
+ layoutPath,
127
+ current
128
+ .replace(/^import '#components\/theme-toggle\.ts';\n/m, '')
129
+ .replace(/^\s*<theme-toggle><\/theme-toggle>\n?/m, ''),
130
+ );
131
+ }
132
+ }
133
+
134
+ // 6) Drop generated migrations + the dev database so the next db:generate is
75
135
  // clean against the reset schema (safe: the scaffold has no real data yet).
76
136
  rm('db/migrations');
77
137
  for (const f of ['db/dev.db', 'db/dev.db-shm', 'db/dev.db-wal']) rm(f);
78
138
 
79
- console.log(`Gallery cleared (${removed} paths removed). The agent skill and your database wiring are kept.`);
139
+ // 7) Prune the empty leftover dirs a partial removal left behind, so the reset
140
+ // tree is a clean blank slate: app/api (held only the gallery auth handler),
141
+ // test/unit + test/e2e (empty base placeholders), and test/ itself once its
142
+ // example suites are gone. modules/ + components/ are kept as empty build
143
+ // targets (the reset home + `webjs ui add` land there). Order matters: prune the
144
+ // children before test/ so it reads as empty.
145
+ for (const d of ['app/api', 'test/unit', 'test/e2e', 'test']) if (pruneEmpty(d)) removed++;
146
+
147
+ console.log(`Gallery cleared (${removed} paths removed). The agent skill and your database wiring are kept. Build your own design system: run \`npx webjsdev ui add <name>\` and theme it (see .agents/skills/webjs/references/styling.md).`);
80
148
  console.log('Next: regenerate the database (db:generate then db:migrate), then start the dev server and build your app in app/ and modules/.');
81
149
 
82
150
  function MINIMAL_PAGE() {
83
151
  return `import { html } from '@webjsdev/core';
84
- import '#components/theme-toggle.ts';
85
152
 
86
153
  export const metadata = {
87
154
  title: 'Home',
@@ -89,19 +156,59 @@ export const metadata = {
89
156
 
90
157
  export default function Home() {
91
158
  return html\`
92
- <div class="fixed top-4 right-4 z-10"><theme-toggle></theme-toggle></div>
93
159
  <div class="max-w-2xl mx-auto px-6 py-24 flex flex-col items-center text-center gap-6">
94
160
  <h1 class="text-4xl font-bold tracking-tight m-0">Your app</h1>
95
- <p class="text-base text-muted-foreground leading-relaxed m-0">
96
- The gallery is cleared. This is <code class="text-[0.9em] text-foreground">app/page.ts</code>. Build your
97
- app from here. The guide is <code class="text-[0.9em] text-foreground">.agents/skills/webjs/SKILL.md</code>.
161
+ <p class="text-base leading-relaxed m-0 opacity-70">
162
+ The gallery is cleared. This is <code class="text-[0.9em]">app/page.ts</code>. Build your
163
+ app from here. The guide is <code class="text-[0.9em]">.agents/skills/webjs/SKILL.md</code>.
98
164
  </p>
99
- <nav class="flex items-center gap-5 text-sm text-muted-foreground">
100
- <a href="https://docs.webjs.dev" class="hover:text-foreground transition-colors no-underline">Docs</a>
101
- <a href="https://github.com/webjsdev/webjs" class="hover:text-foreground transition-colors no-underline">GitHub</a>
165
+ <nav class="flex items-center gap-5 text-sm opacity-70">
166
+ <a href="https://docs.webjs.dev" target="_blank" rel="noopener" class="hover:opacity-100 transition-opacity no-underline">Docs</a>
167
+ <a href="https://github.com/webjsdev/webjs" target="_blank" rel="noopener" class="hover:opacity-100 transition-opacity no-underline">GitHub</a>
102
168
  </nav>
103
169
  </div>
104
170
  \`;
105
171
  }
106
172
  `;
107
173
  }
174
+
175
+ function MINIMAL_LAYOUT() {
176
+ return `import { html } from '@webjsdev/core';
177
+
178
+ /**
179
+ * Root layout: the ONLY file that writes the document shell. It links the
180
+ * Tailwind stylesheet and renders \${children} in a bare container. This is a
181
+ * BLANK SLATE with no design system: it uses the OS light/dark system colours
182
+ * (Canvas / CanvasText) so it reads fine immediately, and it is where YOU build
183
+ * the app's look. The recommended setup, CSS custom-property tokens mapped into
184
+ * Tailwind via @theme (so bg-background / text-foreground work), a DRY
185
+ * light-dark() palette, a header/nav, and the ui class helpers, is taught in
186
+ * .agents/skills/webjs/references/styling.md. Run \`npx webjsdev ui add <name>\`
187
+ * to pull primitives, then theme them here.
188
+ */
189
+
190
+ // Favicon via metadata.icons so the framework emits the <link> into <head> (a
191
+ // hand-written <link> in the template body is ignored by browsers).
192
+ export const metadata = { icons: '/public/favicon.svg' };
193
+
194
+ export default function RootLayout({ children }: { children: unknown }) {
195
+ return html\`
196
+ <meta name="color-scheme" content="light dark">
197
+ <link rel="stylesheet" href="/public/tailwind.css">
198
+ <style>
199
+ html, body { margin: 0; }
200
+ body {
201
+ background: Canvas;
202
+ color: CanvasText;
203
+ font: 15px/1.6 ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
204
+ -webkit-font-smoothing: antialiased;
205
+ -moz-osx-font-smoothing: grayscale;
206
+ }
207
+ </style>
208
+ <main class="min-h-dvh max-w-3xl mx-auto px-6 py-10">
209
+ \${children}
210
+ </main>
211
+ \`;
212
+ }
213
+ `;
214
+ }