@webjsdev/cli 0.10.44 → 0.10.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +2 -3
  2. package/bin/webjs.js +18 -18
  3. package/lib/api-gallery.js +1 -1
  4. package/lib/create.js +154 -243
  5. package/package.json +1 -1
  6. package/templates/.agents/rules/workflow.md +7 -3
  7. package/templates/.agents/skills/webjs/SKILL.md +4 -2
  8. package/templates/.agents/skills/webjs/references/auth-and-sessions.md +78 -16
  9. package/templates/.agents/skills/webjs/references/built-ins.md +16 -2
  10. package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +38 -6
  11. package/templates/.agents/skills/webjs/references/components.md +101 -6
  12. package/templates/.agents/skills/webjs/references/data-and-actions.md +19 -2
  13. package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +5 -1
  14. package/templates/.agents/skills/webjs/references/optimistic-ui.md +18 -0
  15. package/templates/.agents/skills/webjs/references/routing-and-pages.md +25 -2
  16. package/templates/.agents/skills/webjs/references/runtime.md +1 -1
  17. package/templates/.agents/skills/webjs/references/service-worker.md +1 -1
  18. package/templates/.agents/skills/webjs/references/styling.md +82 -2
  19. package/templates/AGENTS.md +12 -5
  20. package/templates/gallery/app/api/auth/[...path]/route.ts +7 -0
  21. package/templates/gallery/app/apple-icon.ts +2 -5
  22. package/templates/gallery/app/examples/layout.ts +16 -0
  23. package/templates/gallery/app/examples/todo/page.ts +2 -1
  24. package/templates/gallery/app/features/async-render/page.ts +3 -2
  25. package/templates/gallery/app/features/auth/dashboard/layout.ts +21 -0
  26. package/templates/gallery/app/features/auth/dashboard/middleware.ts +14 -0
  27. package/templates/gallery/app/features/auth/dashboard/page.ts +20 -0
  28. package/templates/gallery/app/features/auth/dashboard/settings/page.ts +22 -0
  29. package/templates/gallery/app/features/auth/login/middleware.ts +15 -0
  30. package/templates/gallery/app/features/auth/login/page.ts +43 -0
  31. package/templates/gallery/app/features/auth/page.ts +34 -0
  32. package/templates/gallery/app/features/auth/signup/middleware.ts +11 -0
  33. package/templates/gallery/app/features/auth/signup/page.ts +61 -0
  34. package/templates/gallery/app/features/boundaries/error.ts +5 -4
  35. package/templates/gallery/app/features/boundaries/gated/forbidden.ts +5 -4
  36. package/templates/gallery/app/features/boundaries/not-found.ts +5 -4
  37. package/templates/gallery/app/features/boundaries/page.ts +11 -10
  38. package/templates/gallery/app/features/boundaries/private/unauthorized.ts +5 -4
  39. package/templates/gallery/app/features/broadcast/page.ts +4 -3
  40. package/templates/gallery/app/features/caching/page.ts +5 -4
  41. package/templates/gallery/app/features/client-router/page.ts +7 -5
  42. package/templates/gallery/app/features/client-router/second/page.ts +4 -3
  43. package/templates/gallery/app/features/components/page.ts +3 -2
  44. package/templates/gallery/app/features/directives/page.ts +3 -2
  45. package/templates/gallery/app/features/env/page.ts +4 -3
  46. package/templates/gallery/app/features/file-storage/page.ts +8 -5
  47. package/templates/gallery/app/features/forms/page.ts +10 -6
  48. package/templates/gallery/app/features/frames/page.ts +26 -10
  49. package/templates/gallery/app/features/layout.ts +67 -0
  50. package/templates/gallery/app/features/metadata/page.ts +7 -6
  51. package/templates/gallery/app/features/optimistic-ui/page.ts +3 -2
  52. package/templates/gallery/app/features/rate-limit/page.ts +6 -5
  53. package/templates/gallery/app/features/route-handler/page.ts +4 -3
  54. package/templates/gallery/app/features/routing/[id]/page.ts +7 -6
  55. package/templates/gallery/app/features/routing/page.ts +10 -9
  56. package/templates/gallery/app/features/server-actions/page.ts +7 -4
  57. package/templates/gallery/app/features/service-worker/page.ts +4 -3
  58. package/templates/gallery/app/features/sessions/page.ts +5 -4
  59. package/templates/gallery/app/features/stream/page.ts +46 -0
  60. package/templates/gallery/app/features/streaming/page.ts +32 -0
  61. package/templates/gallery/app/features/suspense/page.ts +35 -0
  62. package/templates/gallery/app/features/view-transitions/page.ts +44 -0
  63. package/templates/gallery/app/features/view-transitions/second/page.ts +30 -0
  64. package/templates/gallery/app/features/websockets/page.ts +4 -3
  65. package/templates/gallery/app/global-error.ts +2 -5
  66. package/templates/gallery/app/global-not-found.ts +4 -6
  67. package/templates/gallery/app/icon.ts +2 -5
  68. package/templates/gallery/app/manifest.ts +1 -4
  69. package/templates/gallery/app/opengraph-image.ts +3 -6
  70. package/templates/gallery/app/robots.ts +0 -3
  71. package/templates/gallery/app/sitemap.ts +0 -3
  72. package/templates/gallery/app/twitter-image.ts +3 -6
  73. package/templates/gallery/components/ui/badge.ts +41 -0
  74. package/templates/gallery/components/ui/button.ts +86 -0
  75. package/templates/gallery/components/ui/card.ts +36 -0
  76. package/templates/gallery/components/ui/input.ts +50 -0
  77. package/templates/gallery/lib/utils/ui.ts +31 -0
  78. package/templates/gallery/modules/auth/actions/signup.server.ts +19 -0
  79. package/templates/gallery/modules/auth/auth.server.ts +53 -0
  80. package/templates/gallery/modules/auth/password.server.ts +20 -0
  81. package/templates/gallery/modules/auth/queries/current-user.server.ts +12 -0
  82. package/templates/gallery/modules/auth/types.ts +9 -0
  83. package/templates/gallery/modules/broadcast/components/broadcast-feed.ts +4 -2
  84. package/templates/gallery/modules/caching/components/cache-buster.ts +2 -1
  85. package/templates/gallery/modules/client-router/components/router-controls.ts +4 -3
  86. package/templates/gallery/modules/components/components/counter-card.ts +4 -2
  87. package/templates/gallery/modules/components/components/reactive-meter.ts +9 -1
  88. package/templates/gallery/modules/components/components/task-loader.ts +3 -2
  89. package/templates/gallery/modules/components/components/theme-context.ts +5 -3
  90. package/templates/gallery/modules/directives/components/directive-demo.ts +17 -10
  91. package/templates/gallery/modules/gallery/components/gallery-nav.ts +54 -0
  92. package/templates/gallery/modules/gallery/nav.ts +79 -0
  93. package/templates/gallery/modules/optimistic-ui/components/like-button.ts +19 -1
  94. package/templates/gallery/modules/rate-limit/components/rate-probe.ts +2 -1
  95. package/templates/gallery/modules/route-handler/components/rich-data.ts +2 -1
  96. package/templates/gallery/modules/server-actions/actions/greet.server.ts +2 -2
  97. package/templates/gallery/modules/server-actions/actions/greet.test.ts +39 -36
  98. package/templates/gallery/modules/server-actions/components/greeter.ts +11 -11
  99. package/templates/gallery/modules/server-actions/middleware/require-auth.server.ts +14 -9
  100. package/templates/gallery/modules/stream/components/stream-demo.ts +81 -0
  101. package/templates/gallery/modules/streaming/actions/stream-tokens.server.ts +17 -0
  102. package/templates/gallery/modules/streaming/components/token-stream.ts +53 -0
  103. package/templates/gallery/modules/suspense/components/slow-fact.ts +20 -0
  104. package/templates/gallery/modules/todo/components/todo-app.ts +8 -4
  105. package/templates/gallery/modules/websockets/components/ws-echo.ts +5 -3
  106. package/templates/gallery/test/auth/auth.test.ts +81 -0
  107. package/templates/public/favicon.svg +10 -3
  108. package/templates/scripts/clear-api-gallery.mjs +55 -0
  109. package/templates/scripts/clear-gallery.mjs +143 -24
  110. package/lib/lean-copy.js +0 -43
  111. package/lib/saas-template.js +0 -568
@@ -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
+ }
@@ -0,0 +1,19 @@
1
+ 'use server';
2
+
3
+ import { db } from '#db/connection.server.ts';
4
+ import { users } from '#db/schema.server.ts';
5
+ import { hash } from '../password.server.ts';
6
+ import { signIn } from '../auth.server.ts';
7
+
8
+ // Creates the account, then signs the new user in and lands on the dashboard.
9
+ // signIn returns a 302 Response carrying the session cookie; the signup page
10
+ // action returns that Response as-is (a page action may return a Response).
11
+ // signIn lives in the server-only auth module, imported here server-to-server,
12
+ // so it never reaches the browser (the signup page only imports this action's
13
+ // RPC stub).
14
+ export async function signup(input: { name: string; email: string; password: string }) {
15
+ const exists = await db.query.users.findFirst({ where: { email: input.email }, columns: { id: true } });
16
+ if (exists) return { success: false as const, error: 'Email already registered', status: 409 };
17
+ await db.insert(users).values({ name: input.name, email: input.email, passwordHash: await hash(input.password) });
18
+ return signIn('credentials', { email: input.email, password: input.password }, { redirectTo: '/features/auth/dashboard' });
19
+ }
@@ -0,0 +1,53 @@
1
+ // The auth configuration. A server-only utility (a .server.ts with NO
2
+ // 'use server'), so it runs server-side and its browser import is a throw-at-load
3
+ // stub. Pages and route handlers import the pieces they need server-to-server;
4
+ // the client only ever reaches auth through a 'use server' action's RPC stub.
5
+ //
6
+ // createAuth returns { auth, signIn, signOut, handlers }. `handlers` mounts at
7
+ // app/api/auth/[...path]/route.ts (createAuth hardcodes the /api/auth/signin/*
8
+ // and /api/auth/callback/* paths, which is why that route stays at the app root
9
+ // even though the rest of this card lives under app/features/auth/).
10
+ import { createAuth, Credentials, GitHub, Google } from '@webjsdev/server';
11
+ import { db } from '#db/connection.server.ts';
12
+ import { compare } from './password.server.ts';
13
+
14
+ // AUTH_SECRET signs session tokens. Set a strong value in .env for any real
15
+ // deployment. The dev fallback keeps a fresh scaffold booting, but is NOT safe
16
+ // for production, so we fail fast if it is missing or blank there.
17
+ const trimmedSecret = process.env.AUTH_SECRET?.trim();
18
+ if (process.env.NODE_ENV === 'production' && !trimmedSecret) {
19
+ throw new Error('AUTH_SECRET must be set in production');
20
+ }
21
+ const authSecret = trimmedSecret || 'dev-insecure-secret-change-me';
22
+
23
+ export const { auth, signIn, signOut, handlers } = createAuth({
24
+ providers: [
25
+ Credentials({
26
+ async authorize(credentials: { email: string; password: string }) {
27
+ const user = await db.query.users.findFirst({ where: { email: credentials.email } });
28
+ if (!user?.passwordHash || !await compare(credentials.password, user.passwordHash)) return null;
29
+ return { id: String(user.id), name: user.name, email: user.email };
30
+ },
31
+ }),
32
+ // OAuth providers: add GitHub / Google sign-in by setting the matching env
33
+ // vars. Each preset (GitHub(), Google()) reads AUTH_<PROVIDER>_ID / _SECRET,
34
+ // so they only activate once configured and a fresh scaffold still boots with
35
+ // just Credentials.
36
+ ...(process.env.AUTH_GITHUB_ID ? [GitHub({ clientId: process.env.AUTH_GITHUB_ID, clientSecret: process.env.AUTH_GITHUB_SECRET })] : []),
37
+ ...(process.env.AUTH_GOOGLE_ID ? [Google({ clientId: process.env.AUTH_GOOGLE_ID, clientSecret: process.env.AUTH_GOOGLE_SECRET })] : []),
38
+ ],
39
+ secret: authSecret,
40
+ // A failed credentials sign-in 302s to `${pages.error}?error=CredentialsSignin`.
41
+ // Point it at the login page so it reads searchParams.error and shows a message,
42
+ // instead of the createAuth default (the home page) swallowing the error.
43
+ pages: { error: '/features/auth/login' },
44
+ });
45
+
46
+ // Read the signed-in user off a request. auth(req) reads the session cookie
47
+ // (falling back to the ambient request when called with no argument), so this
48
+ // works from a page/layout, a segment middleware, or an action middleware that
49
+ // holds the request (the greet demo's require-auth reads it this way).
50
+ export async function getCurrentUser(req?: Request) {
51
+ const session = await auth(req);
52
+ return session?.user ?? null;
53
+ }
@@ -0,0 +1,20 @@
1
+ // Password hashing with scrypt from node:crypto (built into Node AND Bun, no
2
+ // dependency). A server-only utility: hashes live server-side and never reach
3
+ // the browser. Swap in argon2/bcrypt here if you prefer; the call sites only use
4
+ // hash() and compare().
5
+ import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
6
+ import { promisify } from 'node:util';
7
+
8
+ const scryptAsync = promisify(scrypt);
9
+
10
+ export async function hash(password: string): Promise<string> {
11
+ const salt = randomBytes(16).toString('hex');
12
+ const buf = (await scryptAsync(password, salt, 64)) as Buffer;
13
+ return salt + ':' + buf.toString('hex');
14
+ }
15
+
16
+ export async function compare(password: string, stored: string): Promise<boolean> {
17
+ const [salt, key] = stored.split(':');
18
+ const buf = (await scryptAsync(password, salt, 64)) as Buffer;
19
+ return timingSafeEqual(buf, Buffer.from(key, 'hex'));
20
+ }
@@ -0,0 +1,12 @@
1
+ 'use server';
2
+
3
+ import { getCurrentUser } from '../auth.server.ts';
4
+
5
+ // This read deliberately stays POST-default (no 'method' export). A GET server
6
+ // action (a cacheable, SSR-seeded read) is wrong for a per-session lookup: the
7
+ // result differs per user and changes on sign-in / sign-out, so it must never be
8
+ // browser-cached or shared. Reserve GET + cache + tags for data identical for
9
+ // every visitor.
10
+ export async function currentUser() {
11
+ return getCurrentUser();
12
+ }
@@ -0,0 +1,9 @@
1
+ export interface User {
2
+ id: number;
3
+ name: string | null;
4
+ email: string;
5
+ }
6
+
7
+ export type ActionResult<T> =
8
+ | { success: true; data: T }
9
+ | { success: false; error: string; status: number };
@@ -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>