@webjsdev/cli 0.10.45 → 0.10.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/lib/create.js +109 -141
  2. package/package.json +1 -1
  3. package/templates/.agents/rules/workflow.md +7 -3
  4. package/templates/.agents/skills/webjs/SKILL.md +4 -2
  5. package/templates/.agents/skills/webjs/references/auth-and-sessions.md +78 -16
  6. package/templates/.agents/skills/webjs/references/built-ins.md +16 -2
  7. package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +28 -4
  8. package/templates/.agents/skills/webjs/references/components.md +82 -2
  9. package/templates/.agents/skills/webjs/references/data-and-actions.md +19 -2
  10. package/templates/.agents/skills/webjs/references/optimistic-ui.md +18 -0
  11. package/templates/.agents/skills/webjs/references/routing-and-pages.md +25 -2
  12. package/templates/.agents/skills/webjs/references/styling.md +82 -2
  13. package/templates/AGENTS.md +12 -5
  14. package/templates/gallery/app/apple-icon.ts +2 -5
  15. package/templates/gallery/app/examples/layout.ts +7 -2
  16. package/templates/gallery/app/examples/todo/page.ts +2 -1
  17. package/templates/gallery/app/features/async-render/page.ts +3 -2
  18. package/templates/gallery/app/features/auth/dashboard/layout.ts +2 -1
  19. package/templates/gallery/app/features/auth/dashboard/page.ts +4 -2
  20. package/templates/gallery/app/features/auth/dashboard/settings/page.ts +2 -1
  21. package/templates/gallery/app/features/auth/login/middleware.ts +15 -0
  22. package/templates/gallery/app/features/auth/login/page.ts +7 -4
  23. package/templates/gallery/app/features/auth/page.ts +6 -5
  24. package/templates/gallery/app/features/auth/signup/middleware.ts +11 -0
  25. package/templates/gallery/app/features/auth/signup/page.ts +7 -4
  26. package/templates/gallery/app/features/boundaries/error.ts +5 -4
  27. package/templates/gallery/app/features/boundaries/gated/forbidden.ts +5 -4
  28. package/templates/gallery/app/features/boundaries/not-found.ts +5 -4
  29. package/templates/gallery/app/features/boundaries/page.ts +11 -10
  30. package/templates/gallery/app/features/boundaries/private/unauthorized.ts +5 -4
  31. package/templates/gallery/app/features/broadcast/page.ts +4 -3
  32. package/templates/gallery/app/features/caching/page.ts +5 -4
  33. package/templates/gallery/app/features/client-router/page.ts +7 -5
  34. package/templates/gallery/app/features/client-router/second/page.ts +4 -3
  35. package/templates/gallery/app/features/components/page.ts +3 -2
  36. package/templates/gallery/app/features/directives/page.ts +3 -2
  37. package/templates/gallery/app/features/env/page.ts +4 -3
  38. package/templates/gallery/app/features/file-storage/page.ts +8 -5
  39. package/templates/gallery/app/features/forms/page.ts +10 -6
  40. package/templates/gallery/app/features/frames/page.ts +16 -8
  41. package/templates/gallery/app/features/layout.ts +60 -5
  42. package/templates/gallery/app/features/metadata/page.ts +7 -6
  43. package/templates/gallery/app/features/optimistic-ui/page.ts +3 -2
  44. package/templates/gallery/app/features/rate-limit/page.ts +6 -5
  45. package/templates/gallery/app/features/route-handler/page.ts +4 -3
  46. package/templates/gallery/app/features/routing/[id]/page.ts +7 -6
  47. package/templates/gallery/app/features/routing/page.ts +10 -9
  48. package/templates/gallery/app/features/server-actions/page.ts +5 -4
  49. package/templates/gallery/app/features/service-worker/page.ts +4 -3
  50. package/templates/gallery/app/features/sessions/page.ts +5 -4
  51. package/templates/gallery/app/features/stream/page.ts +4 -3
  52. package/templates/gallery/app/features/streaming/page.ts +4 -3
  53. package/templates/gallery/app/features/suspense/page.ts +4 -3
  54. package/templates/gallery/app/features/view-transitions/page.ts +6 -3
  55. package/templates/gallery/app/features/view-transitions/second/page.ts +4 -2
  56. package/templates/gallery/app/features/websockets/page.ts +4 -3
  57. package/templates/gallery/app/global-error.ts +2 -5
  58. package/templates/gallery/app/global-not-found.ts +4 -6
  59. package/templates/gallery/app/icon.ts +2 -5
  60. package/templates/gallery/app/manifest.ts +1 -4
  61. package/templates/gallery/app/opengraph-image.ts +3 -6
  62. package/templates/gallery/app/robots.ts +0 -3
  63. package/templates/gallery/app/sitemap.ts +0 -3
  64. package/templates/gallery/app/twitter-image.ts +3 -6
  65. package/templates/gallery/components/ui/badge.ts +41 -0
  66. package/templates/gallery/components/ui/button.ts +86 -0
  67. package/templates/gallery/components/ui/card.ts +36 -0
  68. package/templates/gallery/components/ui/input.ts +50 -0
  69. package/templates/gallery/lib/utils/ui.ts +31 -0
  70. package/templates/gallery/modules/broadcast/components/broadcast-feed.ts +4 -2
  71. package/templates/gallery/modules/caching/components/cache-buster.ts +2 -1
  72. package/templates/gallery/modules/client-router/components/router-controls.ts +4 -3
  73. package/templates/gallery/modules/components/components/counter-card.ts +4 -2
  74. package/templates/gallery/modules/components/components/reactive-meter.ts +9 -1
  75. package/templates/gallery/modules/components/components/task-loader.ts +3 -2
  76. package/templates/gallery/modules/components/components/theme-context.ts +5 -3
  77. package/templates/gallery/modules/directives/components/directive-demo.ts +17 -10
  78. package/templates/gallery/modules/gallery/components/gallery-nav.ts +54 -0
  79. package/templates/gallery/modules/gallery/nav.ts +79 -0
  80. package/templates/gallery/modules/optimistic-ui/components/like-button.ts +19 -1
  81. package/templates/gallery/modules/rate-limit/components/rate-probe.ts +2 -1
  82. package/templates/gallery/modules/route-handler/components/rich-data.ts +2 -1
  83. package/templates/gallery/modules/server-actions/components/greeter.ts +7 -4
  84. package/templates/gallery/modules/stream/components/stream-demo.ts +10 -5
  85. package/templates/gallery/modules/streaming/components/token-stream.ts +10 -3
  86. package/templates/gallery/modules/suspense/components/slow-fact.ts +2 -1
  87. package/templates/gallery/modules/todo/components/todo-app.ts +8 -4
  88. package/templates/gallery/modules/websockets/components/ws-echo.ts +5 -3
  89. package/templates/public/favicon.svg +10 -3
  90. package/templates/scripts/clear-gallery.mjs +126 -19
@@ -1,6 +1,3 @@
1
- // (delete this file), then delete this marker line. webjs check fails while the
2
- // marker remains.
3
- //
4
1
  // app/robots.ts serves /robots.txt. The default export returns a string (served
5
2
  // as text/plain) or an object. This example allows all crawlers and points them
6
3
  // at the sitemap. Tighten the rules (Disallow paths) for a real app.
@@ -1,6 +1,3 @@
1
- // routes, or prune it (delete this file), then delete this marker line. webjs
2
- // check fails while the marker remains.
3
- //
4
1
  // app/sitemap.ts serves /sitemap.xml. The default export is a (possibly async)
5
2
  // server function; `sitemap(entries)` from @webjsdev/server serializes a
6
3
  // spec-valid XML sitemap. In a real app, build the entries from your content
@@ -1,6 +1,3 @@
1
- // (delete this file), then delete this marker line. webjs check fails while the
2
- // marker remains.
3
- //
4
1
  // app/twitter-image.ts serves /twitter-image (the card image shown when the
5
2
  // site is shared on Twitter/X). Its own route so the Twitter card can differ
6
3
  // from the Open Graph image (opengraph-image.ts); when they are identical, drop
@@ -8,9 +5,9 @@
8
5
  // roughly 1200x630. Reference it via metadata `twitter: { images: [...] }`.
9
6
  export default function TwitterImage() {
10
7
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
11
- <rect width="1200" height="630" fill="#1c1613"/>
12
- <text x="80" y="330" font-family="system-ui, sans-serif" font-size="88" font-weight="700" fill="#f5f0eb">My App</text>
13
- <text x="80" y="410" font-family="system-ui, sans-serif" font-size="36" fill="#ff8a3d">Build on the platform, not against it</text>
8
+ <rect width="1200" height="630" fill="#1e2226"/>
9
+ <text x="80" y="330" font-family="system-ui, sans-serif" font-size="88" font-weight="700" fill="#dee2e6">My App</text>
10
+ <text x="80" y="410" font-family="system-ui, sans-serif" font-size="36" fill="#94989c">Build on the platform, not against it</text>
14
11
  </svg>`;
15
12
  return new Response(svg, {
16
13
  headers: { 'content-type': 'image/svg+xml', 'cache-control': 'public, max-age=3600' },
@@ -0,0 +1,41 @@
1
+ /**
2
+ * badgeClass: the gallery's badge/pill design token, built on @webjsdev/ui.
3
+ *
4
+ * A `@webjsdev/ui` tier-1 CLASS HELPER (like buttonClass): it returns a Tailwind
5
+ * class string you spread onto a NATIVE inline element, almost always a `<span>`
6
+ * (a static label) or an `<a>` (a linked tag). Native markup means no wrapper
7
+ * component and correct semantics for free:
8
+ *
9
+ * <span class=${badgeClass()}>Signed in</span>
10
+ * <span class=${badgeClass({ variant: 'outline' })}>Example app</span>
11
+ *
12
+ * OWN-AND-THEME: this is `@webjsdev/ui`'s badge THEMED to this scaffold's two
13
+ * real uses, NOT the fuller shadcn badge (solid primary, secondary, destructive,
14
+ * ghost, link). Keep only the variants you use, styled to your look. `default`
15
+ * is a soft primary pill (a status chip); `outline` is a quiet muted tag.
16
+ *
17
+ * A11y: a static badge is a plain non-focusable `<span>`. Make it an `<a>` only
18
+ * when it links somewhere.
19
+ *
20
+ * Design tokens used (app/layout.ts): --color-primary, --color-border,
21
+ * --color-muted-foreground.
22
+ */
23
+ import { cn } from '#lib/utils/cn.ts';
24
+
25
+ // Shared by every variant: an inline pill that hugs its content and never wraps.
26
+ const BASE = 'inline-flex w-fit shrink-0 items-center whitespace-nowrap';
27
+
28
+ const VARIANTS = {
29
+ // A soft, tinted primary chip. Used for a status ("Signed in").
30
+ default: 'rounded-full bg-primary/15 text-primary px-2.5 py-1 text-xs font-medium',
31
+ // A tiny, quiet outline tag. Used to label a kind of thing ("Example app").
32
+ outline:
33
+ 'rounded border border-border text-muted-foreground px-1.5 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wider',
34
+ } as const;
35
+
36
+ export type BadgeVariant = keyof typeof VARIANTS;
37
+
38
+ /** Compose the Tailwind classes for a badge/pill. Append your own layout as needed. */
39
+ export function badgeClass(opts: { variant?: BadgeVariant } = {}): string {
40
+ return cn(BASE, VARIANTS[opts.variant ?? 'default']);
41
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * buttonClass: the gallery's button design token, built on @webjsdev/ui.
3
+ *
4
+ * This is a `@webjsdev/ui` tier-1 CLASS HELPER (the shadcn model): instead of a
5
+ * `<ui-button>` wrapper element, it returns a Tailwind class string you spread
6
+ * onto a NATIVE `<button>` (or `<a>` for a link-styled button). Native markup
7
+ * means form submission, focus, keyboard activation, and screen-reader
8
+ * semantics all work with no extra wiring:
9
+ *
10
+ * <button class=${buttonClass()} @click=${...}>Save</button>
11
+ * <button class=${buttonClass({ variant: 'secondary', size: 'sm' })}>Cancel</button>
12
+ * <a class=${buttonClass({ variant: 'link', size: 'none' })} href="/x">More</a>
13
+ *
14
+ * WHY a helper and not a component: the class helper adds NO indirection, so a
15
+ * demo's real markup (the `@click`, the `?disabled`, the concept it teaches)
16
+ * stays fully visible, while every button in the gallery shares one source of
17
+ * truth for its look. That is also why the #1057-style "one button forgot
18
+ * cursor-pointer" gap cannot happen: `cursor-pointer` lives on BASE.
19
+ *
20
+ * OWN-AND-THEME your copy: this file is `webjs ui add button` output THEMED to
21
+ * this scaffold's look (rounded-xl, the primary / card / muted-link styles the
22
+ * gallery uses). That is the intended `@webjsdev/ui` workflow: you own the
23
+ * component file and tune its class values to your brand. Run
24
+ * `webjs ui add button` (or edit here) to change the button system app-wide.
25
+ *
26
+ * A11y: an icon-only button has no visible text, so give it an `aria-label`.
27
+ * Native `<button>` focus + keyboard activation are already correct.
28
+ *
29
+ * Design tokens used (defined in app/layout.ts): --color-primary,
30
+ * --color-primary-foreground, --color-card, --color-border, --color-foreground,
31
+ * --color-muted-foreground, --color-accent, --color-ring.
32
+ */
33
+ import { cn } from '#lib/utils/cn.ts';
34
+
35
+ // BASE: shared by every variant + size, so a fix here (e.g. the focus ring, or
36
+ // cursor-pointer) applies to the whole button system at once.
37
+ // Focus is handled ONCE by the design system's global :focus-visible ring (the
38
+ // ui theme's @layer base in public/input.css), so buttons need no per-element
39
+ // focus style here.
40
+ const BASE =
41
+ 'inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap text-sm transition-all disabled:pointer-events-none disabled:opacity-60';
42
+
43
+ // VARIANTS carry COLOR + weight + hover (what the button IS). Add a variant here
44
+ // and it is instantly available to every demo.
45
+ const VARIANTS = {
46
+ // The filled call-to-action (Send, Upload, Stream tokens, Greet).
47
+ default: 'bg-primary text-primary-foreground font-semibold hover:bg-primary/90 active:scale-[0.97]',
48
+ // The quieter card-surfaced action (Reset, Read once, secondary controls).
49
+ secondary: 'bg-card border border-border text-foreground font-medium hover:border-border-strong',
50
+ // Transparent until hovered (a toolbar / menu action, e.g. Log out).
51
+ ghost: 'bg-transparent text-foreground font-medium hover:bg-accent',
52
+ // A button that reads as an inline text link (navigate, toggle, reveal).
53
+ link: 'text-muted-foreground font-medium underline decoration-dotted underline-offset-4 hover:text-foreground',
54
+ // A subtle destructive action (a delete icon / a remove control): transparent
55
+ // and muted at rest, turning red on hover. NOT a loud solid-red button; theme
56
+ // it to a filled `bg-destructive` variant if you want a prominent confirm.
57
+ destructive: 'bg-transparent text-muted-foreground font-medium hover:text-destructive hover:bg-destructive/10',
58
+ } as const;
59
+
60
+ // SIZES carry SHAPE (padding + radius). `none` is for the link variant, which
61
+ // is inline text and takes no box padding.
62
+ const SIZES = {
63
+ default: 'px-4 py-2 rounded-xl',
64
+ sm: 'px-3.5 py-1.5 rounded-xl',
65
+ xs: 'px-3 py-1 rounded-lg',
66
+ none: '',
67
+ } as const;
68
+
69
+ export type ButtonVariant = keyof typeof VARIANTS;
70
+ export type ButtonSize = keyof typeof SIZES;
71
+
72
+ export interface ButtonClassOptions {
73
+ variant?: ButtonVariant;
74
+ size?: ButtonSize;
75
+ }
76
+
77
+ /**
78
+ * Compose the Tailwind classes for a button. Both options are optional
79
+ * (`buttonClass()` is the primary call-to-action). `cn` de-duplicates, so you
80
+ * can append your own layout classes: `class="${buttonClass()} w-full"`.
81
+ */
82
+ export function buttonClass(opts: ButtonClassOptions = {}): string {
83
+ const variant = opts.variant ?? 'default';
84
+ const size = opts.size ?? 'default';
85
+ return cn(BASE, VARIANTS[variant], SIZES[size]);
86
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * cardClass: the gallery's panel-surface design token, built on @webjsdev/ui.
3
+ *
4
+ * A `@webjsdev/ui` tier-1 CLASS HELPER for the "card" surface every demo panel
5
+ * sits on. It owns only the SURFACE (radius, border, background), NOT the
6
+ * padding or inner layout, because those genuinely differ per panel (a form
7
+ * grid, a centered card, a tight toolbar). So you compose:
8
+ *
9
+ * <div class="${cardClass()} p-5 grid gap-4">...</div>
10
+ * <div class="${cardClass()} p-2 flex items-center gap-2">...</div>
11
+ *
12
+ * WHY split surface from layout: the surface is the thing that MUST be identical
13
+ * everywhere (so all panels read as one system), while padding and layout are a
14
+ * per-panel decision. Centralizing only the surface gives consistency without
15
+ * flattening intentional layout differences. Contrast the fuller shadcn Card
16
+ * (header / title / content / footer subparts) that `webjs ui add card` ships:
17
+ * this scaffold's panels are simple containers, so it keeps only what it uses.
18
+ *
19
+ * OWN-AND-THEME: this is `@webjsdev/ui`'s card THEMED to this scaffold (a
20
+ * `rounded-2xl` panel on `bg-card` with a hairline border). Change the surface
21
+ * here and every panel updates at once. When you build a real app on
22
+ * `@webjsdev/ui`, do the same: pull the primitive, keep the parts you use, and
23
+ * theme it to your brand.
24
+ */
25
+ import { cn } from '#lib/utils/cn.ts';
26
+
27
+ // The shared panel surface. Padding + inner layout stay on the call site.
28
+ const SURFACE = 'rounded-2xl border border-border bg-card';
29
+
30
+ /**
31
+ * Compose the Tailwind classes for a card/panel surface. Append the panel's own
32
+ * padding + layout: `class="${cardClass()} p-5 grid gap-4 max-w-[460px]"`.
33
+ */
34
+ export function cardClass(extra?: string): string {
35
+ return cn(SURFACE, extra);
36
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * inputClass: the gallery's text-field design token, built on @webjsdev/ui.
3
+ *
4
+ * Like buttonClass, this is a `@webjsdev/ui` tier-1 CLASS HELPER: it returns a
5
+ * Tailwind class string you spread onto a NATIVE `<input>` or `<textarea>`, so
6
+ * the browser's built-in form behaviour, validation, and accessibility all work
7
+ * with nothing extra:
8
+ *
9
+ * <input class=${inputClass()} name="email" type="email" />
10
+ * <textarea class=${inputClass()} name="message" rows="3"></textarea>
11
+ *
12
+ * The same helper styles inputs AND textareas here, because this app wants them
13
+ * to look identical. That is the point of a design token: one place decides how
14
+ * every field looks, so they cannot drift apart. If you later want a distinct
15
+ * textarea look, add a `textareaClass` alongside this and theme it separately
16
+ * (that is exactly how `webjs ui add textarea` would give you one).
17
+ *
18
+ * OWN-AND-THEME: this is `@webjsdev/ui`'s input, THEMED to this scaffold's look
19
+ * (a filled `bg-background` field, `rounded-xl`, a primary focus border). You
20
+ * own this file: change the class values here and every field in the app
21
+ * updates at once. This is the intended workflow when building on `@webjsdev/ui`
22
+ * for a real app: pull the primitive, then make it yours.
23
+ */
24
+ import { cn } from '#lib/utils/cn.ts';
25
+
26
+ // One surface for every text field. `focus:border-primary` is the whole focus
27
+ // affordance (no ring), to match this app's quiet, token-driven chrome.
28
+ const BASE =
29
+ 'w-full bg-background border border-border rounded-xl px-3 py-2 text-[15px] text-foreground outline-none transition-colors focus:border-primary placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-60';
30
+
31
+ /**
32
+ * Compose the Tailwind classes for a text input or textarea. Append your own
33
+ * layout classes as needed: `class="${inputClass()} max-w-sm"`.
34
+ */
35
+ export function inputClass(extra?: string): string {
36
+ return cn(BASE, extra);
37
+ }
38
+
39
+ // A BORDERLESS field for composing INSIDE a card/pill: a `cardClass()` form with a
40
+ // button (a search-bar). The card is the bordered container, so the field is
41
+ // seamless (transparent, no border), and it flexes to fill the row. Distinct from
42
+ // inputClass (a standalone bordered field); a bordered input inside a bordered
43
+ // card would be a box-in-a-box.
44
+ const BARE =
45
+ 'flex-1 min-w-0 bg-transparent border-0 outline-none py-1.5 text-[15px] text-foreground placeholder:text-muted-foreground';
46
+
47
+ /** Compose the classes for a borderless text field composed inside a card pill. */
48
+ export function bareInputClass(extra?: string): string {
49
+ return cn(BARE, extra);
50
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * ui.ts: repeated MARKUP-chunk helpers (the `lib/utils/ui.ts` pattern).
3
+ *
4
+ * A design system has two kinds of helper. A repeated PRIMITIVE with variants
5
+ * (button, input, card, badge) is a CLASS helper in `components/ui/` returning a
6
+ * class string. A repeated markup CHUNK (a heading, a lede, a back link) is an
7
+ * `html`-fragment helper here in `lib/utils/ui.ts`: it returns SSR-time markup,
8
+ * so the output is byte-identical to writing the tags inline, with no client
9
+ * runtime. Use these for chunks that repeat across pages; keep genuinely one-off
10
+ * or trivial prose (a lone `<code>`, a single text link) inline for readability.
11
+ *
12
+ * OWN-AND-THEME: these fragments are THIS gallery's shared bits. Change a class
13
+ * here and every page updates at once. Build your own for your app.
14
+ */
15
+ import { html } from '@webjsdev/core';
16
+
17
+ /** A page's `<h1>` heading (the standard demo/page title). */
18
+ export function pageHeading(title: unknown) {
19
+ return html`<h1 class="text-h2 font-bold mb-4">${title}</h1>`;
20
+ }
21
+
22
+ /** The muted intro paragraph under a heading. Pass a string OR an `html` fragment
23
+ * (a lede often wraps an inline `<code>`). */
24
+ export function lede(content: unknown) {
25
+ return html`<p class="text-muted-foreground mb-4">${content}</p>`;
26
+ }
27
+
28
+ /** A slim "back" link (used above each gallery demo to return to the index). */
29
+ export function backLink(href: string, label: unknown) {
30
+ return html`<a href=${href} class="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors no-underline mb-6">${label}</a>`;
31
+ }
@@ -4,6 +4,8 @@
4
4
  // closes in disconnectedCallback. At SSR it renders the disconnected state, so
5
5
  // the page still reads with JS off (a live feed has no no-JS equivalent).
6
6
  import { WebComponent, signal, html, connectWS } from '@webjsdev/core';
7
+ import { buttonClass } from '#components/ui/button.ts';
8
+ import { inputClass } from '#components/ui/input.ts';
7
9
 
8
10
  export class BroadcastFeed extends WebComponent {
9
11
  private connected = signal(false);
@@ -45,9 +47,9 @@ export class BroadcastFeed extends WebComponent {
45
47
  </div>
46
48
  <form @submit=${(e: SubmitEvent) => this.send(e)} class="flex gap-2">
47
49
  <input name="msg" autocomplete="off" placeholder="Message everyone"
48
- class="flex-1 px-3 py-2 rounded-xl bg-card border border-border text-foreground text-sm outline-none focus:border-border-strong" />
50
+ class=${inputClass('flex-1 min-w-0')} />
49
51
  <button type="submit"
50
- 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>
52
+ class=${buttonClass({ size: 'sm' })}>Send</button>
51
53
  </form>
52
54
  <ul class="grid gap-1.5 list-none m-0 p-0">
53
55
  ${this.lines.get().map((line) => html`
@@ -3,6 +3,7 @@
3
3
  // button is inert (cache eviction there happens as a side effect of the write
4
4
  // action that changed the data).
5
5
  import { WebComponent, signal, html } from '@webjsdev/core';
6
+ import { buttonClass } from '#components/ui/button.ts';
6
7
  import { bustCaches } from '#modules/caching/actions/bust-caches.server.ts';
7
8
 
8
9
  export class CacheBuster extends WebComponent {
@@ -21,7 +22,7 @@ export class CacheBuster extends WebComponent {
21
22
  return html`
22
23
  <div class="flex items-center gap-3 text-[15px]">
23
24
  <button @click=${() => this.bust()}
24
- 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">revalidate this page</button>
25
+ class=${buttonClass({ variant: 'secondary', size: 'sm' })}>revalidate this page</button>
25
26
  <span class="text-muted-foreground">${this.status.get()}</span>
26
27
  </div>
27
28
  `;
@@ -12,6 +12,7 @@
12
12
  // home; a page/layout never hydrates. With JS off the plain link still works
13
13
  // (progressive enhancement), while the buttons are inert.
14
14
  import { WebComponent, html, signal, navigate, revalidate, disableClientRouter, enableClientRouter } from '@webjsdev/core';
15
+ import { buttonClass } from '#components/ui/button.ts';
15
16
 
16
17
  export class RouterControls extends WebComponent {
17
18
  // Instance signal mirroring whether soft navigation is currently on.
@@ -30,13 +31,13 @@ export class RouterControls extends WebComponent {
30
31
  <div class="flex flex-wrap gap-3 items-center">
31
32
  <button
32
33
  @click=${() => navigate('/features/client-router/second')}
33
- class="inline-flex items-center px-4 py-2 rounded-xl bg-card border border-border text-foreground font-medium text-sm cursor-pointer transition-colors hover:border-border-strong">navigate() to page two</button>
34
+ class=${buttonClass({ variant: 'secondary' })}>navigate() to page two</button>
34
35
  <button
35
36
  @click=${() => revalidate()}
36
- class="text-muted-foreground font-medium text-sm cursor-pointer transition-colors hover:text-foreground underline decoration-dotted underline-offset-4">revalidate() the snapshot cache</button>
37
+ class=${buttonClass({ variant: 'link', size: 'none' })}>revalidate() the snapshot cache</button>
37
38
  <button
38
39
  @click=${() => this.toggleRouter()}
39
- class="text-muted-foreground font-medium text-sm cursor-pointer transition-colors hover:text-foreground underline decoration-dotted underline-offset-4">${soft ? 'disableClientRouter()' : 'enableClientRouter()'} (soft nav: ${soft ? 'on' : 'off'})</button>
40
+ class=${buttonClass({ variant: 'link', size: 'none' })}>${soft ? 'disableClientRouter()' : 'enableClientRouter()'} (soft nav: ${soft ? 'on' : 'off'})</button>
40
41
  </div>
41
42
  <p class="text-sm text-muted-foreground">
42
43
  Plain link:
@@ -4,6 +4,8 @@
4
4
  // component authoring shape. Styling is crafted Tailwind on the app's design
5
5
  // tokens so it stays visually coherent with the rest of the app.
6
6
  import { WebComponent, prop, signal, html } from '@webjsdev/core';
7
+ import { cardClass } from '#components/ui/card.ts';
8
+ import { buttonClass } from '#components/ui/button.ts';
7
9
 
8
10
  export class CounterCard extends WebComponent({
9
11
  // A reactive prop rides an HTML attribute / SSR hydration. Declared ONLY here
@@ -20,14 +22,14 @@ export class CounterCard extends WebComponent({
20
22
 
21
23
  render() {
22
24
  return html`
23
- <div class="grid gap-4 p-5 rounded-2xl bg-card border border-border max-w-[320px]">
25
+ <div class="${cardClass()} grid gap-4 p-5 max-w-[320px]">
24
26
  <slot></slot>
25
27
  <div class="flex items-baseline gap-2">
26
28
  <span class="text-[2.5rem] font-bold tabular-nums leading-none text-foreground">${this.count.get()}</span>
27
29
  <span class="text-sm text-muted-foreground">${this.label}</span>
28
30
  </div>
29
31
  <button @click=${() => this.count.set(this.count.get() + 1)}
30
- class="w-fit 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]">Increment</button>
32
+ class="${buttonClass()} w-fit">Increment</button>
31
33
  </div>
32
34
  `;
33
35
  }
@@ -10,9 +10,17 @@ export class ReactiveMeter extends WebComponent {
10
10
  static styles = css`
11
11
  .row { display: flex; align-items: center; gap: 8px; }
12
12
  button {
13
- padding: 6px 12px; border-radius: 10px; border: 1px solid #8883;
13
+ padding: 6px 12px; border-radius: 10px; border: 1px solid var(--color-border);
14
14
  background: transparent; color: inherit; cursor: pointer; font: inherit;
15
15
  }
16
+ /* Tailwind's focus styles do NOT cross the shadow boundary, so a shadow-DOM
17
+ component styles its own focus, else the browser's default outline (thick,
18
+ shown on window-refocus) appears. Match the design system's global ring
19
+ EXACTLY: the --ring colour at 50% (the same as outline-ring/50). */
20
+ button:focus-visible {
21
+ outline: 2px solid color-mix(in oklab, var(--color-ring) 50%, transparent);
22
+ outline-offset: 2px;
23
+ }
16
24
  .val { font-variant-numeric: tabular-nums; font-weight: 600; }
17
25
  .muted { opacity: 0.6; font-size: 13px; }
18
26
  `;
@@ -6,6 +6,7 @@
6
6
  // `async render()` instead, which blocks SSR so the data is server-rendered;
7
7
  // a Task shows its PENDING state at SSR, so the value is not in the first paint.
8
8
  import { WebComponent, html } from '@webjsdev/core';
9
+ import { buttonClass } from '#components/ui/button.ts';
9
10
  import { Task, TaskStatus } from '@webjsdev/core/task';
10
11
 
11
12
  export class TaskLoader extends WebComponent {
@@ -33,13 +34,13 @@ export class TaskLoader extends WebComponent {
33
34
  const t = this.task;
34
35
  const body =
35
36
  t.status === TaskStatus.PENDING ? html`<span class="text-muted-foreground">loading…</span>`
36
- : t.status === TaskStatus.ERROR ? html`<span class="text-red-500">error: ${String((t.error as Error)?.message ?? t.error)}</span>`
37
+ : t.status === TaskStatus.ERROR ? html`<span class="text-destructive">error: ${String((t.error as Error)?.message ?? t.error)}</span>`
37
38
  : t.status === TaskStatus.COMPLETE ? html`<span class="text-foreground">${t.value}</span>`
38
39
  : html`<span class="text-muted-foreground">idle</span>`;
39
40
  return html`
40
41
  <div class="flex items-center gap-3 text-[15px]">
41
42
  <button @click=${() => this.reload()}
42
- 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">reload</button>
43
+ class=${buttonClass({ variant: 'secondary', size: 'sm' })}>reload</button>
43
44
  ${body}
44
45
  </div>
45
46
  `;
@@ -7,6 +7,8 @@
7
7
  // controllers automate. All light DOM, so the provider's <slot> projects its
8
8
  // children normally.
9
9
  import { WebComponent, html, signal } from '@webjsdev/core';
10
+ import { buttonClass } from '#components/ui/button.ts';
11
+ import { cardClass } from '#components/ui/card.ts';
10
12
  import { createContext, ContextProvider, ContextConsumer, ContextRequestEvent } from '@webjsdev/core/context';
11
13
 
12
14
  type Theme = 'light' | 'dark';
@@ -26,9 +28,9 @@ export class ThemeProvider extends WebComponent {
26
28
 
27
29
  render() {
28
30
  return html`
29
- <div class="grid gap-3 p-3 rounded-xl bg-card border border-border max-w-[420px]">
31
+ <div class=${cardClass('grid gap-3 p-3 max-w-[420px]')}>
30
32
  <button @click=${() => this.toggle()}
31
- class="w-fit 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]">
33
+ class="${buttonClass({ size: 'sm' })} w-fit">
32
34
  provider theme: ${this.provider.value} (toggle)
33
35
  </button>
34
36
  <slot></slot>
@@ -58,7 +60,7 @@ export class ThemeConsumer extends WebComponent {
58
60
  <div class="flex items-center gap-3 text-[15px] text-foreground">
59
61
  <span>child sees: <strong>${theme}</strong></span>
60
62
  <button @click=${() => this.readOnce()}
61
- class="px-3 py-1 rounded-lg text-sm bg-card border border-border text-foreground cursor-pointer transition-colors hover:border-border-strong">read once</button>
63
+ class=${buttonClass({ variant: 'secondary', size: 'xs' })}>read once</button>
62
64
  <span class="text-muted-foreground text-sm">last one-shot: ${this.lastRead.get()}</span>
63
65
  </div>
64
66
  `;
@@ -12,6 +12,9 @@
12
12
  // HTML), and `asyncAppend` / `asyncReplace` (stream values from an async
13
13
  // iterable, appending each or replacing with the latest).
14
14
  import { WebComponent, signal, html } from '@webjsdev/core';
15
+ import { buttonClass } from '#components/ui/button.ts';
16
+ import { cn } from '#lib/utils/cn.ts';
17
+ import { inputClass } from '#components/ui/input.ts';
15
18
  import { repeat, watch, live, until, keyed, unsafeHTML, ref, createRef, guard, cache, templateContent, asyncAppend, asyncReplace } from '@webjsdev/core/directives';
16
19
 
17
20
  interface Item { id: number; label: string }
@@ -107,9 +110,9 @@ export class DirectiveDemo extends WebComponent {
107
110
  <div class="grid gap-4">
108
111
  <div class="flex gap-2">
109
112
  <button @click=${() => this.reverse()}
110
- 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]">Reverse</button>
113
+ class=${buttonClass({ size: 'sm' })}>Reverse</button>
111
114
  <button @click=${() => this.add()}
112
- class="px-3.5 py-1.5 rounded-xl bg-card border border-border text-foreground font-medium text-sm cursor-pointer transition-colors hover:border-border-strong">Add</button>
115
+ class=${buttonClass({ variant: 'secondary', size: 'sm' })}>Add</button>
113
116
  </div>
114
117
  <!-- repeat keyed by item.id: reversing reuses the existing <li> nodes. -->
115
118
  <ul class="grid gap-2 list-none m-0 p-0">
@@ -121,7 +124,7 @@ export class DirectiveDemo extends WebComponent {
121
124
  </ul>
122
125
  <!-- watch(signal) swaps only this node when ticks changes. -->
123
126
  <button @click=${() => this.ticks.set(this.ticks.get() + 1)}
124
- class="w-fit text-sm text-muted-foreground cursor-pointer transition-colors hover:text-foreground underline decoration-dotted underline-offset-4">ticks: ${watch(this.ticks)}</button>
127
+ class="${buttonClass({ variant: 'link', size: 'none' })} w-fit">ticks: ${watch(this.ticks)}</button>
125
128
  </div>
126
129
 
127
130
  <div class="grid gap-3 border-t border-border pt-4">
@@ -132,18 +135,18 @@ export class DirectiveDemo extends WebComponent {
132
135
  <input
133
136
  ${ref(this.inputRef)}
134
137
  aria-label="Editable text for the ref focus demo"
135
- class="flex-1 px-3 py-1.5 rounded-xl bg-card border border-border text-[15px] text-foreground"
138
+ class=${inputClass('flex-1 min-w-0')}
136
139
  .value=${live(this.text.get())}
137
140
  @input=${(e: Event) => this.text.set((e.target as HTMLInputElement).value)}>
138
141
  <button @click=${() => this.focusInput()}
139
- 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</button>
142
+ class=${buttonClass({ variant: 'secondary', size: 'sm' })}>Focus</button>
140
143
  </div>
141
144
  <!-- until shows the fallback until the promise resolves. -->
142
145
  <p class="text-sm text-muted-foreground">async: ${until(this.asyncValue, 'loading...')}</p>
143
146
  <!-- keyed discards and rebuilds this subtree when the key changes;
144
147
  unsafeHTML injects trusted, author-written HTML (NEVER user input). -->
145
148
  <button @click=${() => this.variant.set(variant + 1)}
146
- class="w-fit text-sm text-muted-foreground cursor-pointer transition-colors hover:text-foreground underline decoration-dotted underline-offset-4">rekey</button>
149
+ class="${buttonClass({ variant: 'link', size: 'none' })} w-fit">rekey</button>
147
150
  ${keyed(variant, html`<div class="text-[15px] text-foreground">${unsafeHTML('<em>fresh subtree</em>')} #${variant}</div>`)}
148
151
  </div>
149
152
 
@@ -152,16 +155,20 @@ export class DirectiveDemo extends WebComponent {
152
155
  guard counter re-renders it; the OTHER counter (ticks above) does
153
156
  not, so the guarded value stays put. -->
154
157
  <button @click=${() => this.guardBumps.set(this.guardBumps.get() + 1)}
155
- class="w-fit text-sm text-muted-foreground cursor-pointer transition-colors hover:text-foreground underline decoration-dotted underline-offset-4">bump guard dep</button>
158
+ class="${buttonClass({ variant: 'link', size: 'none' })} w-fit">bump guard dep</button>
156
159
  <p class="text-sm text-foreground">guarded: ${guard([this.guardBumps.get()], () => html`computed at bump #${this.guardBumps.get()}`)}</p>
157
160
 
158
161
  <!-- cache(value) keeps the inactive tab's DOM alive while you toggle,
159
162
  so switching back is instant and preserves any element state. -->
163
+ <!-- A tab toggle must keep a CONSTANT size between states, or the row
164
+ reflows and the content below dances. So both tabs use ONE variant
165
+ (same weight, border, padding) and cn() overrides only the active
166
+ COLORS, never the box. -->
160
167
  <div class="flex gap-2">
161
168
  <button @click=${() => this.tab.set('a')}
162
- class="px-3 py-1 rounded-lg text-sm border cursor-pointer transition-colors ${this.tab.get() === 'a' ? 'bg-primary text-primary-foreground border-transparent' : 'bg-card border-border text-foreground hover:border-border-strong'}">Tab A</button>
169
+ class=${cn(buttonClass({ variant: 'secondary', size: 'xs' }), this.tab.get() === 'a' && 'bg-primary text-primary-foreground border-primary hover:border-primary')}>Tab A</button>
163
170
  <button @click=${() => this.tab.set('b')}
164
- class="px-3 py-1 rounded-lg text-sm border cursor-pointer transition-colors ${this.tab.get() === 'b' ? 'bg-primary text-primary-foreground border-transparent' : 'bg-card border-border text-foreground hover:border-border-strong'}">Tab B</button>
171
+ class=${cn(buttonClass({ variant: 'secondary', size: 'xs' }), this.tab.get() === 'b' && 'bg-primary text-primary-foreground border-primary hover:border-primary')}>Tab B</button>
165
172
  </div>
166
173
  <div class="text-[15px] text-foreground">${cache(
167
174
  this.tab.get() === 'a'
@@ -181,7 +188,7 @@ export class DirectiveDemo extends WebComponent {
181
188
  you can watch them replay. The run counter forces the re-render. -->
182
189
  <div class="flex items-center gap-3">
183
190
  <button @click=${() => this.restartStreams()}
184
- 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">restart streams</button>
191
+ class="${buttonClass({ variant: 'secondary', size: 'sm' })} w-fit">restart streams</button>
185
192
  <span class="text-sm text-muted-foreground">run #${this.streamRun.get()}</span>
186
193
  </div>
187
194
  <div class="text-sm text-muted-foreground">log (asyncAppend, one row per value):</div>
@@ -0,0 +1,54 @@
1
+ // The gallery's left sidebar: a grouped index of every demo, with the current
2
+ // one highlighted (docs-site style). WHY a component and not plain layout markup:
3
+ // the features layout is PRESERVED across a soft navigation (only the page
4
+ // content swaps), so a server-rendered "active" highlight would go stale. This
5
+ // component listens for the router's `webjs:navigate` event and re-derives the
6
+ // active item from location.pathname, so the highlight follows soft-nav. SSR is
7
+ // still correct: `render()` reads the `current` prop (the pathname the layout
8
+ // passes) for the first paint, and the client takes over from location after.
9
+ import { WebComponent, prop, html, signal } from '@webjsdev/core';
10
+ import { FEATURE_GROUPS } from '#modules/gallery/nav.ts';
11
+
12
+ // Module-scope so the value survives re-renders; set on the client only (SSR
13
+ // reads the `current` prop instead, so it never touches location during render).
14
+ const activePath = signal('');
15
+
16
+ export class GalleryNav extends WebComponent({ current: prop(String) }) {
17
+ #onNav = () => activePath.set(location.pathname);
18
+
19
+ connectedCallback() {
20
+ super.connectedCallback();
21
+ this.#onNav(); // seed from the real URL on hydrate
22
+ document.addEventListener('webjs:navigate', this.#onNav); // soft-nav (link + navigate())
23
+ window.addEventListener('popstate', this.#onNav); // back / forward
24
+ }
25
+
26
+ disconnectedCallback() {
27
+ super.disconnectedCallback();
28
+ document.removeEventListener('webjs:navigate', this.#onNav);
29
+ window.removeEventListener('popstate', this.#onNav);
30
+ }
31
+
32
+ render() {
33
+ // activePath is '' at SSR (and before hydrate), so fall back to the prop.
34
+ const active = activePath.get() || this.current;
35
+ const link = (href: string, title: string) => {
36
+ // Highlight the demo whose route we are on, INCLUDING its subroutes
37
+ // (/features/auth/dashboard highlights Auth). The trailing slash keeps
38
+ // /features/stream from matching /features/streaming.
39
+ const on = active === href || active.startsWith(href + '/');
40
+ const cls = 'block px-3 py-1.5 rounded-lg no-underline transition-colors ' +
41
+ (on ? 'bg-accent text-foreground font-medium' : 'text-muted-foreground hover:bg-accent hover:text-foreground');
42
+ return html`<a href=${href} aria-current=${on ? 'page' : 'false'} class=${cls}>${title}</a>`;
43
+ };
44
+ return html`
45
+ ${FEATURE_GROUPS.map((g) => html`
46
+ <div class="mb-5">
47
+ <div class="px-3 mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">${g.label}</div>
48
+ <nav class="flex flex-col gap-0.5">${g.items.map((i) => link(i.href, i.title))}</nav>
49
+ </div>
50
+ `)}
51
+ `;
52
+ }
53
+ }
54
+ GalleryNav.register('gallery-nav');