@webjskit/cli 0.4.2 → 0.5.0

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.
package/README.md CHANGED
@@ -36,13 +36,24 @@ webjs create <name> --template api # backend-only API app
36
36
  webjs create <name> --template saas # auth + dashboard + Prisma User model
37
37
 
38
38
  webjs dev # dev server with live reload
39
- webjs start # production server
40
- webjs build # production bundle
39
+ webjs start # production server (no build step — serves source directly)
41
40
  webjs check # validate project conventions
42
41
  webjs test # run server + browser tests
43
42
  webjs db <prisma-subcommand> # prisma passthrough (saas template)
43
+
44
+ webjs ui init # initialise @webjskit/ui in this project
45
+ webjs ui add <names...> # copy components from the registry (https://ui.webjs.dev/registry/<name>.json)
46
+ webjs ui list # list every component available in the registry
44
47
  ```
45
48
 
49
+ `webjs ui` proxies to [`@webjskit/ui`](https://www.npmjs.com/package/@webjskit/ui),
50
+ an AI-first component library + CLI that copies sources into your project — class
51
+ helpers (`buttonClass`, `cardClass`, …) for the visual primitives and a small set
52
+ of stateful custom elements (`<ui-dialog>`, `<ui-tabs>`, `<ui-popover>`) where
53
+ state matters. The package is a hard dependency of `@webjskit/cli` — installing
54
+ the CLI gives you `webjs ui` automatically. See
55
+ [https://ui.webjs.dev](https://ui.webjs.dev) for the catalogue.
56
+
46
57
  ## Scaffolded templates
47
58
 
48
59
  The scaffold seeds opinionated defaults so AI agents produce consistent code:
package/bin/webjs.js CHANGED
@@ -13,9 +13,7 @@ const TEMPLATES = ['full-stack', 'api', 'saas'];
13
13
 
14
14
  const USAGE = `webjs — commands:
15
15
  webjs dev [--port 3000] Start dev server with live reload
16
- webjs start [--port 3000] Start production server (serves source directly; no build required)
17
- [--http2 --cert <path> --key <path>] Serve HTTP/2 over TLS (falls back to h1.1)
18
- webjs build Optional: bundle client modules into a single file (advanced/perf opt-in)
16
+ webjs start [--port 3000] Start production server (serves source directly; no build step)
19
17
  webjs test [--server|--browser] Run server + browser tests
20
18
  webjs check Validate app against conventions
21
19
  webjs create <name> [--template full-stack|api|saas] Scaffold a new webjs app
@@ -23,6 +21,9 @@ const USAGE = `webjs — commands:
23
21
  webjs db generate Run \`prisma generate\`
24
22
  webjs db migrate [name] Run \`prisma migrate dev\`
25
23
  webjs db studio Run \`prisma studio\`
24
+ webjs ui <subcmd> AI-first component library CLI
25
+ (init / add / list / view / diff / info)
26
+ Requires @webjskit/ui installed in the project
26
27
  webjs help Show this help`;
27
28
 
28
29
  /** @param {string[]} args */
@@ -79,23 +80,7 @@ async function main() {
79
80
  case 'start': {
80
81
  const { startServer } = await import('@webjskit/server');
81
82
  const port = Number(flag(rest, '--port', process.env.PORT || 3000));
82
- const http2 = rest.includes('--http2');
83
- const cert = flag(rest, '--cert');
84
- const key = flag(rest, '--key');
85
- await startServer({ appDir: process.cwd(), port, dev: false, http2, cert, key });
86
- break;
87
- }
88
- case 'build': {
89
- const { buildBundle } = await import('@webjskit/server');
90
- const t = Date.now();
91
- const result = await buildBundle({
92
- appDir: process.cwd(),
93
- minify: rest.includes('--no-minify') ? false : true,
94
- sourcemap: rest.includes('--no-sourcemap') ? false : true,
95
- });
96
- if (result.bundleFile) {
97
- console.log(`webjs: bundled ${result.entries.length} entries → ${result.bundleFile} (${Date.now() - t}ms)`);
98
- }
83
+ await startServer({ appDir: process.cwd(), port, dev: false });
99
84
  break;
100
85
  }
101
86
  case 'db': {
@@ -108,6 +93,32 @@ async function main() {
108
93
  child.on('exit', (code) => process.exit(code ?? 0));
109
94
  break;
110
95
  }
96
+ case 'ui': {
97
+ // Delegate to @webjskit/ui. Bundled as a hard dependency of
98
+ // @webjskit/cli — `npm install -g @webjskit/cli` pulls it in
99
+ // automatically, so `webjs ui add button` works out of the box
100
+ // without an extra install in user projects.
101
+ const { createRequire } = await import('node:module');
102
+ const req = createRequire(import.meta.url);
103
+ let entry;
104
+ try {
105
+ entry = req.resolve('@webjskit/ui/bin/webjsui.js');
106
+ } catch {
107
+ // Fallback: try resolving from the user's cwd in case of weird
108
+ // workspace setups.
109
+ try {
110
+ const userReq = createRequire(join(process.cwd(), 'package.json'));
111
+ entry = userReq.resolve('@webjskit/ui/bin/webjsui.js');
112
+ } catch {
113
+ console.error('@webjskit/ui could not be resolved.');
114
+ console.error('Reinstall the CLI: npm install -g @webjskit/cli');
115
+ process.exit(1);
116
+ }
117
+ }
118
+ const child = spawn('node', [entry, ...rest], { stdio: 'inherit', cwd: process.cwd() });
119
+ child.on('exit', (code) => process.exit(code ?? 0));
120
+ break;
121
+ }
111
122
  case 'test': {
112
123
  const cwd = process.cwd();
113
124
  const { existsSync } = await import('node:fs');
package/lib/create.js CHANGED
@@ -19,6 +19,122 @@ import { existsSync } from 'node:fs';
19
19
  const __dirname = dirname(fileURLToPath(import.meta.url));
20
20
  const TEMPLATES = resolve(__dirname, '..', 'templates');
21
21
 
22
+ // Root of the @webjskit/ui registry workspace. We read component sources
23
+ // directly from disk at create time so the scaffolded app boots ready for
24
+ // `webjs ui add` without an HTTP round-trip during scaffolding.
25
+ //
26
+ // Layout in the monorepo:
27
+ // packages/cli/lib/create.js ← __dirname
28
+ // packages/ui/packages/registry/components/*.ts
29
+ // packages/ui/packages/registry/lib/utils.ts
30
+ // packages/ui/packages/registry/themes/index.css
31
+ const UI_REGISTRY_ROOT = resolve(
32
+ __dirname, '..', '..', 'ui', 'packages', 'registry',
33
+ );
34
+
35
+ /**
36
+ * Read a single @webjskit/ui registry component, rewrite its relative import
37
+ * of `../lib/utils.ts` so it resolves correctly when written to
38
+ * `components/ui/<name>.ts` in the scaffolded app (which puts utils at
39
+ * `lib/utils.ts`, i.e. two levels up), and return the rewritten source.
40
+ *
41
+ * @param {string} name component name without `.ts` (e.g. 'button')
42
+ * @returns {Promise<string|null>} source or null if not found
43
+ */
44
+ async function readUiComponent(name) {
45
+ const src = join(UI_REGISTRY_ROOT, 'components', `${name}.ts`);
46
+ if (!existsSync(src)) return null;
47
+ const raw = await readFile(src, 'utf8');
48
+ // The registry source lives at <registry>/components/<x>.ts and imports
49
+ // its sibling utils via '../lib/utils.ts'. Once copied to the user's
50
+ // project at components/ui/<x>.ts, the equivalent path is two-up:
51
+ // '../../lib/utils.ts'. Same rewrite for the unquoted form.
52
+ return raw
53
+ .replaceAll("'../lib/utils.ts'", "'../../lib/utils.ts'")
54
+ .replaceAll('"../lib/utils.ts"', '"../../lib/utils.ts"');
55
+ }
56
+
57
+ /**
58
+ * Copy a list of @webjskit/ui registry components into the scaffolded app
59
+ * under `components/ui/`. Silently skips any name that isn't in the registry.
60
+ *
61
+ * @param {string} appDir destination app root
62
+ * @param {string[]} names list of component file basenames (without `.ts`)
63
+ */
64
+ async function copyUiComponents(appDir, names) {
65
+ const uiDir = join(appDir, 'components', 'ui');
66
+ await mkdir(uiDir, { recursive: true });
67
+ for (const n of names) {
68
+ const content = await readUiComponent(n);
69
+ if (content == null) continue;
70
+ await writeFile(join(uiDir, `${n}.ts`), content);
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Write `lib/utils.ts` (the `cn()` helper) and `components.json` so the
76
+ * scaffolded app is pre-initialised for `webjs ui add`. Reads `lib/utils.ts`
77
+ * verbatim from the registry source so we never drift.
78
+ *
79
+ * @param {string} appDir
80
+ */
81
+ async function writeUiBootstrap(appDir) {
82
+ // 1) lib/utils.ts — the cn() helper
83
+ const utilsSrc = join(UI_REGISTRY_ROOT, 'lib', 'utils.ts');
84
+ if (existsSync(utilsSrc)) {
85
+ const content = await readFile(utilsSrc, 'utf8');
86
+ await mkdir(join(appDir, 'lib'), { recursive: true });
87
+ await writeFile(join(appDir, 'lib', 'utils.ts'), content);
88
+ }
89
+
90
+ // 2) components.json — the same shape `webjsui init` writes for webjs
91
+ // projects (see packages/ui/src/utils/detect-project.js).
92
+ const componentsJson = {
93
+ $schema: 'https://ui.webjs.dev/schema.json',
94
+ style: 'default',
95
+ tailwind: {
96
+ css: 'app/globals.css',
97
+ baseColor: 'neutral',
98
+ cssVariables: true,
99
+ },
100
+ aliases: {
101
+ components: 'components',
102
+ utils: 'lib/utils',
103
+ ui: 'components/ui',
104
+ lib: 'lib',
105
+ },
106
+ iconLibrary: 'lucide',
107
+ };
108
+ await writeFile(
109
+ join(appDir, 'components.json'),
110
+ JSON.stringify(componentsJson, null, 2) + '\n',
111
+ );
112
+
113
+ // 3) app/globals.css — copy the neutral theme verbatim. components.json
114
+ // references this path; future `webjs ui add` calls append to it.
115
+ const themeSrc = join(UI_REGISTRY_ROOT, 'themes', 'index.css');
116
+ if (existsSync(themeSrc)) {
117
+ const css = await readFile(themeSrc, 'utf8');
118
+ await mkdir(join(appDir, 'app'), { recursive: true });
119
+ await writeFile(join(appDir, 'app', 'globals.css'), css);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Read the shadcn theme CSS so we can inline it into the layout's
125
+ * `<style type="text/tailwindcss">` block. The Tailwind browser runtime
126
+ * picks up inline `<style type="text/tailwindcss">` content, so the theme
127
+ * tokens (`--color-primary`, `--color-card`, …) the registry components
128
+ * consume are available at runtime without a build step.
129
+ *
130
+ * @returns {Promise<string>} theme CSS source, or '' if registry missing
131
+ */
132
+ async function readThemeCss() {
133
+ const src = join(UI_REGISTRY_ROOT, 'themes', 'index.css');
134
+ if (!existsSync(src)) return '';
135
+ return await readFile(src, 'utf8');
136
+ }
137
+
22
138
  /**
23
139
  * @param {string} name App directory name
24
140
  * @param {string} cwd Current working directory
@@ -87,10 +203,14 @@ export async function scaffoldApp(name, cwd, opts = {}) {
87
203
  '@web/test-runner': '^0.20.0',
88
204
  '@web/test-runner-playwright': '^0.11.0',
89
205
  'playwright': '^1.59.0',
90
- // tsserver plugins for editor intelligence inside html`` templates.
91
- // Order in tsconfig matters see below.
92
- 'ts-lit-plugin': '^2.0.0',
206
+ // tsserver plugin for editor intelligence inside html`` templates.
207
+ // @webjskit/ts-plugin bundles ts-lit-plugin internally, so just one
208
+ // plugin entry is needed in tsconfig (see below).
93
209
  '@webjskit/ts-plugin': 'latest',
210
+ // AI-first component library CLI — preinstalled so `webjs ui add button`
211
+ // works immediately after scaffold. Users can remove if they prefer
212
+ // to add it later.
213
+ '@webjskit/ui': 'latest',
94
214
  },
95
215
  }, null, 2) + '\n');
96
216
 
@@ -104,14 +224,16 @@ export async function scaffoldApp(name, cwd, opts = {}) {
104
224
  noEmit: true,
105
225
  allowImportingTsExtensions: true,
106
226
  skipLibCheck: true,
107
- // ts-lit-plugin: type-check + diagnostics inside html`` templates.
108
- // @webjskit/ts-plugin: webjs-aware go-to-definition, "Unknown tag/attr"
109
- // suppression for elements registered via Class.register('tag'), and
110
- // attribute auto-complete sourced from `static properties`.
111
- // Order matters list ts-lit-plugin first; @webjskit/ts-plugin wraps
112
- // it. Remove either entry if you don't want that capability.
227
+ // @webjskit/ts-plugin gives the editor:
228
+ // type-check + diagnostics inside html`` templates (via the
229
+ // ts-lit-plugin it bundles internally)
230
+ // webjs-aware go-to-definition on custom-element tags
231
+ // "Unknown tag/attribute" suppression for elements registered
232
+ // via Class.register('tag-name')
233
+ // • attribute auto-complete sourced from `static properties`
234
+ // • attribute-value type-check against `declare` annotations
235
+ // Editor-only — the framework runs without it.
113
236
  plugins: [
114
- { name: 'ts-lit-plugin', strict: true },
115
237
  { name: '@webjskit/ts-plugin' },
116
238
  ],
117
239
  },
@@ -255,8 +377,8 @@ export async function createUser(input: { name: string; email: string }) {
255
377
  * /api/users — thin route wrapper over typed server actions.
256
378
  * Business logic lives in modules/users/, not here.
257
379
  */
258
- import { listUsers } from '../../../../modules/users/queries/list-users.server.ts';
259
- import { createUser } from '../../../../modules/users/actions/create-user.server.ts';
380
+ import { listUsers } from '../../../modules/users/queries/list-users.server.ts';
381
+ import { createUser } from '../../../modules/users/actions/create-user.server.ts';
260
382
 
261
383
  export async function GET() {
262
384
  return Response.json(await listUsers());
@@ -320,9 +442,45 @@ export type ActionResult<T> =
320
442
  await cp(uiSrc, join(utilsDir, 'ui.ts'));
321
443
  }
322
444
 
445
+ // Pre-initialise @webjskit/ui so the scaffold boots ready for
446
+ // `webjs ui add <name>`: writes components.json + lib/utils.ts +
447
+ // app/globals.css (the shadcn theme).
448
+ await writeUiBootstrap(appDir);
449
+
450
+ // Copy the standard ui-* component kit the scaffold's example pages
451
+ // use. Sources are read from packages/ui/packages/registry/ in this
452
+ // monorepo. Users can `webjs ui add <name>` for anything else.
453
+ await copyUiComponents(appDir, [
454
+ 'button', 'card', 'alert', 'badge', 'separator', 'label', 'input',
455
+ ]);
456
+
457
+ // The shadcn theme tokens (`--color-primary`, `--color-card`, …) the
458
+ // ui-* components consume. We read the registry's themes/index.css at
459
+ // create time and inline it into the layout's
460
+ // `<style type="text/tailwindcss">` block so the Tailwind browser
461
+ // runtime picks it up. Same content also lives at app/globals.css for
462
+ // `webjsui` tooling.
463
+ const SHADCN_THEME = (await readThemeCss())
464
+ // Escape backticks + ${} so the CSS survives interpolation into the
465
+ // layout's template literal below.
466
+ .replace(/\\/g, '\\\\')
467
+ .replace(/`/g, '\\`')
468
+ .replace(/\$\{/g, '\\${');
469
+
323
470
  await writeFile(join(appDir, 'app', 'layout.ts'), `import { html } from '@webjskit/core';
324
471
  import '@webjskit/core/client-router';
325
472
  import '../components/theme-toggle.ts';
473
+ // Webjs UI components are tiered:
474
+ // - Tier 1 (button, card, input, label, alert, badge, separator, etc.) are
475
+ // class-helper FUNCTIONS — no custom element to register. Each page
476
+ // imports the specific helpers it needs (e.g.
477
+ // \`import { buttonClass } from '../components/ui/button.ts'\`).
478
+ // - Tier 2 (dialog, popover, tooltip, tabs, accordion, etc.) ARE custom
479
+ // elements. Register them by side-effect-importing here once so they
480
+ // work transitively across every page:
481
+ // import '../components/ui/dialog.ts';
482
+ // The example app/page.ts below uses only Tier-1 helpers, so nothing
483
+ // extra needs to be registered. Add Tier-2 imports as you 'webjs ui add'.
326
484
 
327
485
  /**
328
486
  * Root layout — globals + chrome.
@@ -353,6 +511,16 @@ export default function RootLayout({ children }: { children: unknown }) {
353
511
  })();
354
512
  </script>
355
513
  <script src="/public/tailwind-browser.js"></script>
514
+ <!--
515
+ Webjs UI theme — design tokens (--color-primary,
516
+ --color-card, --radius, etc.) the ui-* components consume.
517
+ The same content is also at app/globals.css; we inline it here so
518
+ the Tailwind browser runtime resolves the tokens without a build step.
519
+ Edit base palette via the :root / .dark blocks below.
520
+ -->
521
+ <style type="text/tailwindcss">
522
+ ${SHADCN_THEME}
523
+ </style>
356
524
  <style type="text/tailwindcss">
357
525
  @theme {
358
526
  --color-fg: var(--fg);
@@ -458,6 +626,17 @@ export default function RootLayout({ children }: { children: unknown }) {
458
626
 
459
627
  await writeFile(join(appDir, 'app', 'page.ts'), `import { html } from '@webjskit/core';
460
628
  import { rubric, displayH1, accentLink } from './_utils/ui.ts';
629
+ import { buttonClass } from '../components/ui/button.ts';
630
+ import { badgeClass } from '../components/ui/badge.ts';
631
+ import {
632
+ cardClass,
633
+ cardHeaderClass,
634
+ cardTitleClass,
635
+ cardDescriptionClass,
636
+ cardContentClass,
637
+ } from '../components/ui/card.ts';
638
+ import { alertClass, alertTitleClass, alertDescriptionClass } from '../components/ui/alert.ts';
639
+ import { separatorClass } from '../components/ui/separator.ts';
461
640
 
462
641
  export const metadata = {
463
642
  title: '${name} — built with webjs',
@@ -468,14 +647,42 @@ export default function Home() {
468
647
  <section class="mb-18">
469
648
  \${rubric('welcome')}
470
649
  \${displayH1(html\`Hello from <span class="text-accent italic">${name}</span>.\`)}
471
- <p class="text-lede leading-[1.5] text-fg-muted max-w-[56ch] m-0">
650
+ <p class="text-lede leading-[1.5] text-fg-muted max-w-[56ch] m-0 mb-6">
472
651
  Edit <code class="font-mono text-[0.9em]">app/page.ts</code> to get started.
473
652
  Run \${accentLink('#', 'webjs test')} to run tests and
474
653
  \${accentLink('#', 'webjs check')} to validate conventions.
475
654
  </p>
655
+ <div class="flex gap-3 items-center">
656
+ <button class=\${buttonClass()}>Get started</button>
657
+ <button class=\${buttonClass({ variant: 'outline' })}>View docs</button>
658
+ <span class=\${badgeClass({ variant: 'secondary' })}>v0.1</span>
659
+ </div>
476
660
  </section>
477
661
 
478
- <section class="mt-18 pt-6 border-t border-border">
662
+ <div class=\${cardClass()} style="margin-bottom: 3rem">
663
+ <div class=\${cardHeaderClass()}>
664
+ <h3 class=\${cardTitleClass()}>Web Components + Server Actions</h3>
665
+ <p class=\${cardDescriptionClass()}>
666
+ Drop a custom element anywhere. Call a server action like a local
667
+ function — webjs rewrites the import into a typed RPC stub.
668
+ </p>
669
+ </div>
670
+ <div class=\${cardContentClass()}>
671
+ <div class=\${alertClass()}>
672
+ <h5 class=\${alertTitleClass()}>AI-first component kit included</h5>
673
+ <div class=\${alertDescriptionClass()}>
674
+ button, card, alert, badge, separator, label, input are already
675
+ in <code class="font-mono text-[0.9em]">components/ui/</code> as
676
+ class-helper functions you call from a native element. Add more
677
+ with <code class="font-mono text-[0.9em]">webjs ui add &lt;name&gt;</code>.
678
+ </div>
679
+ </div>
680
+ </div>
681
+ </div>
682
+
683
+ <div class=\${separatorClass()} style="margin: 2.5rem 0"></div>
684
+
685
+ <section class="mt-10">
479
686
  <h2 class="font-serif text-[1.6rem] tracking-[-0.02em] font-bold m-0 mb-2">Light DOM + Tailwind</h2>
480
687
  <p class="text-fg-muted text-sm m-0 mb-4">
481
688
  Components render into light DOM by default. Tailwind utility classes
@@ -590,25 +797,43 @@ ThemeToggle.register('theme-toggle');
590
797
  app/layout.ts, page.ts, login/, signup/
591
798
  app/dashboard/{page,settings,middleware}.ts ← protected
592
799
  app/api/auth/[...path]/route.ts ← auth API
800
+ app/globals.css ← @webjskit/ui theme tokens
801
+ components.json ← preconfigured for \`webjs ui add\`
802
+ components/ui/{button,card,alert,badge,separator,label,input,
803
+ dialog,form,field,switch,checkbox}.ts
804
+ components/theme-toggle.ts
593
805
  modules/auth/{actions,queries,types.ts}
594
- lib/{auth,prisma,password}.ts
806
+ lib/{auth,prisma,password,utils}.ts ← utils.ts is the cn() helper
595
807
  prisma/schema.prisma ← User model
596
- components/theme-toggle.ts
597
808
  CONVENTIONS.md, AGENTS.md, CLAUDE.md
598
809
  `);
599
810
  } else {
600
811
  console.log(` ${name}/
601
812
  app/layout.ts, page.ts ← light DOM + Tailwind + @theme tokens
602
813
  app/_utils/ui.ts ← JS helpers for repeated class bundles
603
- public/tailwind-browser.js Tailwind runtime
814
+ app/globals.css @webjskit/ui theme tokens
815
+ components.json ← preconfigured for \`webjs ui add\`
816
+ components/ui/{button,card,alert,badge,separator,label,input}.ts
604
817
  components/theme-toggle.ts ← light DOM web component
818
+ lib/utils.ts ← cn() helper for ui-* components
819
+ public/tailwind-browser.js ← Tailwind runtime
605
820
  modules/
606
821
  CONVENTIONS.md, AGENTS.md, CLAUDE.md
607
822
  `);
608
823
  }
824
+ // Post-scaffold guidance. The full-stack and saas templates ship with
825
+ // @webjskit/ui already initialised (components.json, lib/utils.ts, the
826
+ // standard kit under components/ui/), so the user only runs `webjs dev`.
827
+ // The api template has no UI; we only mention `webjs ui` in case the
828
+ // user later adds one.
829
+ const uiNote = isApi
830
+ ? `# If you later add a UI to this API project:
831
+ # webjs ui init && webjs ui add button card dialog`
832
+ : `webjs ui add <name> # optional — add more ui-* components later`;
609
833
  console.log(`Next steps:
610
834
  cd ${name}
611
835
  npm install${isSaas ? '\n npx prisma migrate dev --name init' : ''}
836
+ ${uiNote}
612
837
  webjs dev
613
838
 
614
839
  AI-driven development (enforced for all AI agents):
@@ -3,13 +3,53 @@
3
3
  * Extracted to avoid nested template literal escaping issues.
4
4
  */
5
5
 
6
- import { mkdir, writeFile } from 'node:fs/promises';
7
- import { join } from 'node:path';
6
+ import { mkdir, writeFile, readFile } from 'node:fs/promises';
7
+ import { existsSync } from 'node:fs';
8
+ import { join, resolve, dirname } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const UI_REGISTRY_ROOT = resolve(
13
+ __dirname, '..', '..', 'ui', 'packages', 'registry',
14
+ );
15
+
16
+ /**
17
+ * Read a registry component and rewrite its `'../lib/utils.ts'` import for
18
+ * the scaffolded app's `components/ui/<name>.ts` layout (two-up to lib/).
19
+ * Mirrors the helper in `create.js` — kept private here to avoid coupling.
20
+ */
21
+ async function readUiComponent(name) {
22
+ const src = join(UI_REGISTRY_ROOT, 'components', `${name}.ts`);
23
+ if (!existsSync(src)) return null;
24
+ const raw = await readFile(src, 'utf8');
25
+ return raw
26
+ .replaceAll("'../lib/utils.ts'", "'../../lib/utils.ts'")
27
+ .replaceAll('"../lib/utils.ts"', '"../../lib/utils.ts"');
28
+ }
29
+
30
+ /** Copy named registry components into `<appDir>/components/ui/`. */
31
+ async function copyUiComponents(appDir, names) {
32
+ const uiDir = join(appDir, 'components', 'ui');
33
+ await mkdir(uiDir, { recursive: true });
34
+ for (const n of names) {
35
+ const content = await readUiComponent(n);
36
+ if (content == null) continue;
37
+ await writeFile(join(uiDir, `${n}.ts`), content);
38
+ }
39
+ }
8
40
 
9
41
  /**
10
42
  * @param {string} appDir
11
43
  */
12
44
  export async function writeSaasFiles(appDir) {
45
+ // SaaS pages use auth forms — copy the extra ui-* components on top of
46
+ // the standard set the full-stack scaffold already wrote. Pre-importing
47
+ // them in login/signup/dashboard pages below means the dev server will
48
+ // SSR these elements with full styling on first paint.
49
+ // `form` and `field` are deferred to v2 (see packages/ui/AGENTS.md) —
50
+ // the saas auth pages use raw <form> + label/input class helpers instead.
51
+ await copyUiComponents(appDir, ['dialog', 'switch', 'checkbox']);
52
+
13
53
  // lib/prisma.ts
14
54
  await mkdir(join(appDir, 'lib'), { recursive: true });
15
55
  await writeFile(join(appDir, 'lib', 'prisma.ts'), [
@@ -194,18 +234,39 @@ export async function writeSaasFiles(appDir) {
194
234
  await mkdir(join(appDir, 'app', 'login'), { recursive: true });
195
235
  await writeFile(join(appDir, 'app', 'login', 'page.ts'), [
196
236
  "import { html } from '@webjskit/core';",
237
+ "import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass, cardFooterClass } from '../../components/ui/card.ts';",
238
+ "import { buttonClass } from '../../components/ui/button.ts';",
239
+ "import { inputClass } from '../../components/ui/input.ts';",
240
+ "import { labelClass } from '../../components/ui/label.ts';",
197
241
  "",
198
242
  "export const metadata = { title: 'Login' };",
199
243
  "",
200
244
  "export default function LoginPage() {",
201
245
  " return html`",
202
- " <h1>Login</h1>",
203
- " <form method=\"POST\" action=\"/api/auth/callback/credentials\">",
204
- " <label>Email <input type=\"email\" name=\"email\" required></label>",
205
- " <label>Password <input type=\"password\" name=\"password\" required></label>",
206
- " <button type=\"submit\">Sign in</button>",
207
- " </form>",
208
- " <p>Don't have an account? <a href=\"/signup\">Sign up</a></p>",
246
+ " <div class=\"max-w-sm mx-auto mt-12\">",
247
+ " <div class=${cardClass()}>",
248
+ " <div class=${cardHeaderClass()}>",
249
+ " <h3 class=${cardTitleClass()}>Sign in</h3>",
250
+ " <p class=${cardDescriptionClass()}>Welcome back — log in to continue.</p>",
251
+ " </div>",
252
+ " <div class=${cardContentClass()}>",
253
+ " <form method=\"POST\" action=\"/api/auth/callback/credentials\" class=\"flex flex-col gap-4\">",
254
+ " <div class=\"flex flex-col gap-1.5\">",
255
+ " <label class=${labelClass()} for=\"email\">Email</label>",
256
+ " <input class=${inputClass()} id=\"email\" name=\"email\" type=\"email\" required>",
257
+ " </div>",
258
+ " <div class=\"flex flex-col gap-1.5\">",
259
+ " <label class=${labelClass()} for=\"password\">Password</label>",
260
+ " <input class=${inputClass()} id=\"password\" name=\"password\" type=\"password\" required>",
261
+ " </div>",
262
+ " <button class=${buttonClass()} type=\"submit\">Sign in</button>",
263
+ " </form>",
264
+ " </div>",
265
+ " <div class=${cardFooterClass()}>",
266
+ " <p class=\"text-sm text-muted-foreground\">Don't have an account? <a href=\"/signup\" class=\"underline\">Sign up</a></p>",
267
+ " </div>",
268
+ " </div>",
269
+ " </div>",
209
270
  " `;",
210
271
  "}",
211
272
  "",
@@ -215,19 +276,43 @@ export async function writeSaasFiles(appDir) {
215
276
  await mkdir(join(appDir, 'app', 'signup'), { recursive: true });
216
277
  await writeFile(join(appDir, 'app', 'signup', 'page.ts'), [
217
278
  "import { html } from '@webjskit/core';",
279
+ "import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass, cardFooterClass } from '../../components/ui/card.ts';",
280
+ "import { buttonClass } from '../../components/ui/button.ts';",
281
+ "import { inputClass } from '../../components/ui/input.ts';",
282
+ "import { labelClass } from '../../components/ui/label.ts';",
218
283
  "",
219
284
  "export const metadata = { title: 'Sign up' };",
220
285
  "",
221
286
  "export default function SignupPage() {",
222
287
  " return html`",
223
- " <h1>Sign up</h1>",
224
- " <form id=\"signup-form\">",
225
- " <label>Name <input type=\"text\" name=\"name\" required></label>",
226
- " <label>Email <input type=\"email\" name=\"email\" required></label>",
227
- " <label>Password <input type=\"password\" name=\"password\" required minlength=\"8\"></label>",
228
- " <button type=\"submit\">Create account</button>",
229
- " </form>",
230
- " <p>Already have an account? <a href=\"/login\">Log in</a></p>",
288
+ " <div class=\"max-w-sm mx-auto mt-12\">",
289
+ " <div class=${cardClass()}>",
290
+ " <div class=${cardHeaderClass()}>",
291
+ " <h3 class=${cardTitleClass()}>Create an account</h3>",
292
+ " <p class=${cardDescriptionClass()}>Get started with your new workspace.</p>",
293
+ " </div>",
294
+ " <div class=${cardContentClass()}>",
295
+ " <form id=\"signup-form\" class=\"flex flex-col gap-4\">",
296
+ " <div class=\"flex flex-col gap-1.5\">",
297
+ " <label class=${labelClass()} for=\"name\">Name</label>",
298
+ " <input class=${inputClass()} id=\"name\" name=\"name\" type=\"text\" required>",
299
+ " </div>",
300
+ " <div class=\"flex flex-col gap-1.5\">",
301
+ " <label class=${labelClass()} for=\"email\">Email</label>",
302
+ " <input class=${inputClass()} id=\"email\" name=\"email\" type=\"email\" required>",
303
+ " </div>",
304
+ " <div class=\"flex flex-col gap-1.5\">",
305
+ " <label class=${labelClass()} for=\"password\">Password</label>",
306
+ " <input class=${inputClass()} id=\"password\" name=\"password\" type=\"password\" required>",
307
+ " </div>",
308
+ " <button class=${buttonClass()} type=\"submit\">Create account</button>",
309
+ " </form>",
310
+ " </div>",
311
+ " <div class=${cardFooterClass()}>",
312
+ " <p class=\"text-sm text-muted-foreground\">Already have an account? <a href=\"/login\" class=\"underline\">Log in</a></p>",
313
+ " </div>",
314
+ " </div>",
315
+ " </div>",
231
316
  " `;",
232
317
  "}",
233
318
  "",
@@ -252,15 +337,28 @@ export async function writeSaasFiles(appDir) {
252
337
  await writeFile(join(appDir, 'app', 'dashboard', 'page.ts'), [
253
338
  "import { html } from '@webjskit/core';",
254
339
  "import { currentUser } from '../../modules/auth/queries/current-user.server.ts';",
340
+ "import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass } from '../../components/ui/card.ts';",
341
+ "import { buttonClass } from '../../components/ui/button.ts';",
342
+ "import { badgeClass } from '../../components/ui/badge.ts';",
255
343
  "",
256
344
  "export const metadata = { title: 'Dashboard' };",
257
345
  "",
258
346
  "export default async function Dashboard() {",
259
347
  " const user = await currentUser();",
260
348
  " return html`",
261
- " <h1>Dashboard</h1>",
262
- " <p>Welcome, ${`\\$\\{user?.name || user?.email\\}`}!</p>",
263
- " <a href=\"/dashboard/settings\">Settings</a>",
349
+ " <div class=\"flex items-center justify-between mb-6\">",
350
+ " <h1 class=\"text-2xl font-semibold\">Dashboard</h1>",
351
+ " <span class=${badgeClass({ variant: 'secondary' })}>Signed in</span>",
352
+ " </div>",
353
+ " <div class=${cardClass()}>",
354
+ " <div class=${cardHeaderClass()}>",
355
+ " <h3 class=${cardTitleClass()}>Welcome, ${`\\$\\{user?.name || user?.email\\}`}!</h3>",
356
+ " <p class=${cardDescriptionClass()}>You're authenticated. Replace this scaffold with your real app.</p>",
357
+ " </div>",
358
+ " <div class=${cardContentClass()}>",
359
+ " <a class=${buttonClass({ variant: 'outline' })} href=\"/dashboard/settings\">Settings</a>",
360
+ " </div>",
361
+ " </div>",
264
362
  " `;",
265
363
  "}",
266
364
  "",
@@ -270,15 +368,28 @@ export async function writeSaasFiles(appDir) {
270
368
  await writeFile(join(appDir, 'app', 'dashboard', 'settings', 'page.ts'), [
271
369
  "import { html } from '@webjskit/core';",
272
370
  "import { currentUser } from '../../../modules/auth/queries/current-user.server.ts';",
371
+ "import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass } from '../../../components/ui/card.ts';",
273
372
  "",
274
373
  "export const metadata = { title: 'Settings' };",
275
374
  "",
276
375
  "export default async function Settings() {",
277
376
  " const user = await currentUser();",
278
377
  " return html`",
279
- " <h1>Settings</h1>",
280
- " <p>Email: ${`\\$\\{user?.email\\}`}</p>",
281
- " <p>Name: ${`\\$\\{user?.name || 'Not set'\\}`}</p>",
378
+ " <h1 class=\"text-2xl font-semibold mb-6\">Settings</h1>",
379
+ " <div class=${cardClass()}>",
380
+ " <div class=${cardHeaderClass()}>",
381
+ " <h3 class=${cardTitleClass()}>Account</h3>",
382
+ " <p class=${cardDescriptionClass()}>Your basic profile information.</p>",
383
+ " </div>",
384
+ " <div class=${cardContentClass()}>",
385
+ " <dl class=\"grid grid-cols-[max-content_1fr] gap-x-6 gap-y-2 text-sm\">",
386
+ " <dt class=\"text-muted-foreground\">Email</dt>",
387
+ " <dd>${`\\$\\{user?.email\\}`}</dd>",
388
+ " <dt class=\"text-muted-foreground\">Name</dt>",
389
+ " <dd>${`\\$\\{user?.name || 'Not set'\\}`}</dd>",
390
+ " </dl>",
391
+ " </div>",
392
+ " </div>",
282
393
  " `;",
283
394
  "}",
284
395
  "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjskit/cli",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "webjs CLI — dev, start, create, db",
6
6
  "bin": {
@@ -13,7 +13,8 @@
13
13
  "README.md"
14
14
  ],
15
15
  "dependencies": {
16
- "@webjskit/server": "0.4.1"
16
+ "@webjskit/server": "0.5.0",
17
+ "@webjskit/ui": "0.1.0"
17
18
  },
18
19
  "publishConfig": {
19
20
  "access": "public"
@@ -80,5 +80,5 @@ Quality bar stays the same — just no blocking on questions.
80
80
  - One function per server action file (*.server.ts)
81
81
  - Components must call customElements.define('tag', Class)
82
82
  - Never import @prisma/client or node:* from client components
83
- - Use directives (classMap, styleMap, ref, etc.) from '@webjskit/core/directives'
83
+ - Directives are deliberately minimal: only `unsafeHTML`, `live`, and `repeat` ship. Lit's `classMap` / `styleMap` / `ref` / `when` / `choose` / `guard` are NOT exported — use plain template-literal expressions (`class=${cond ? 'a' : 'b'}`, `${cond ? a : b}`) and lifecycle hooks (`this.query('#el')` in `firstUpdated`) instead.
84
84
  - See AGENTS.md for the complete directive decision guide
@@ -64,9 +64,9 @@ Every code change must include:
64
64
  ## Code patterns
65
65
 
66
66
  - Tagged template: html`<div>${value}</div>` with css`...` for styles
67
- - Components: extend WebComponent, use static tag/styles/properties, call Class.register('tag')
67
+ - Components: extend WebComponent, declare `static properties` (and `static styles` for shadow-DOM components), call `Class.register('tag-name')` at the bottom of the file. The tag name is the argument to `.register()`, not a static field.
68
68
  - Server actions: *.server.ts files with one exported async function each
69
- - Directives: import { classMap, styleMap, ref, when, ... } from '@webjskit/core/directives'
69
+ - Directives: webjs ships only `unsafeHTML`, `live`, and `repeat`. Lit's `classMap` / `styleMap` / `ref` / `when` / `choose` / `guard` are NOT exported — use plain template-literal expressions and lifecycle hooks instead.
70
70
  - Context: import { createContext, ContextProvider, ContextConsumer } from '@webjskit/core/context'
71
71
  - Task: import { Task, TaskStatus } from '@webjskit/core/task'
72
72
  - Routing: file-based under app/ (page.ts, layout.ts, route.ts, middleware.ts)
@@ -67,8 +67,9 @@ The user should never have to ask for tests or documentation.
67
67
  ## Framework specifics
68
68
 
69
69
  - No build step: ES modules served directly
70
- - Web components with shadow DOM by default
70
+ - Web components render into light DOM by default (so Tailwind / global CSS apply directly). Opt in to shadow DOM per component with `static shadow = true` when you need scoped styles, slot projection, or third-party-embed isolation.
71
+ - Custom-element tag names are passed to `.register('tag-name')` — they are NOT a static field on the class.
71
72
  - One function per server action file (*.server.ts)
72
- - Use webjs directives: classMap, styleMap, ref, when, choose, guard, etc.
73
+ - Directives are deliberately minimal: only `unsafeHTML`, `live`, and `repeat` ship. Use plain template-literal expressions (`class=${active ? 'btn active' : 'btn'}`, `style=${'color:' + color}`, `${cond ? a : b}`) and lifecycle hooks (`this.query('#el')` in `firstUpdated`) instead of Lit's `classMap` / `styleMap` / `ref` / `when` / `choose` / `guard`.
73
74
  - Use Context for cross-component data, Task for async data in components
74
75
  - Full API reference in AGENTS.md
@@ -84,6 +84,141 @@ node_modules/@webjskit/
84
84
  Reaching straight for the source is the fastest way to resolve "why
85
85
  doesn't X work?" — no documentation guesswork, no stale blog posts.
86
86
 
87
+ ## Editor TS plugin — `@webjskit/ts-plugin`
88
+
89
+ This scaffold's `tsconfig.json` lists a single tsserver plugin. It is
90
+ editor-only — not required for the framework to run.
91
+
92
+ ```jsonc
93
+ // tsconfig.json (already wired by the scaffold)
94
+ "plugins": [
95
+ { "name": "@webjskit/ts-plugin" }
96
+ ]
97
+ ```
98
+
99
+ `@webjskit/ts-plugin` bundles `ts-lit-plugin` internally (it's a runtime
100
+ dependency of the plugin) and loads it programmatically — so users
101
+ list one entry, not two. You get the full stack of template-literal
102
+ intelligence (type-checking, diagnostics, go-to-def inside
103
+ `` html`…` `` and `` css`…` `` templates) **plus** webjs-aware behaviour
104
+ layered on top:
105
+
106
+ - "Unknown tag/attribute" diagnostics are silenced for elements
107
+ registered via `Class.register('tag-name')`.
108
+ - Attribute auto-complete sourced from each component's
109
+ `static properties`.
110
+ - Attribute-value type-check against `declare propName: T` annotations.
111
+
112
+ See [docs.webjs.com → Editor setup](https://docs.webjs.com/docs/editor-setup)
113
+ for the full walkthrough.
114
+
115
+ ## UI components — Webjs UI (preinstalled)
116
+
117
+ This scaffold ships with the standard Webjs UI component kit
118
+ **already installed at `components/ui/`**. The kit is **AI-first** and
119
+ splits into two tiers. Internalise the split — picking the wrong tier
120
+ produces broken markup.
121
+
122
+ ### Tier 1 — class-helper functions (the majority)
123
+
124
+ Pure functions that return Tailwind class strings. You apply them to
125
+ **raw native HTML elements** that you write yourself. Examples:
126
+ `button`, `card`, `input`, `label`, `alert`, `badge`, `separator`,
127
+ `skeleton`, `kbd`, `table`, `breadcrumb`, `pagination`, `native-select`,
128
+ `avatar`, `checkbox`, `switch`, `radio-group`, `textarea`, `toggle`,
129
+ `aspect-ratio`.
130
+
131
+ ```ts
132
+ import {
133
+ cardClass, cardHeaderClass, cardTitleClass,
134
+ cardContentClass, cardFooterClass,
135
+ } from '../../components/ui/card.ts';
136
+ import { inputClass } from '../../components/ui/input.ts';
137
+ import { labelClass } from '../../components/ui/label.ts';
138
+ import { buttonClass } from '../../components/ui/button.ts';
139
+
140
+ return html`
141
+ <div class=${cardClass()}>
142
+ <div class=${cardHeaderClass()}>
143
+ <h3 class=${cardTitleClass()}>Profile</h3>
144
+ </div>
145
+ <div class=${cardContentClass()}>
146
+ <label class=${labelClass()} for="name">Name</label>
147
+ <input class=${inputClass()} id="name" name="name">
148
+ </div>
149
+ <div class=${cardFooterClass()}>
150
+ <button class=${buttonClass()}>Save</button>
151
+ </div>
152
+ </div>
153
+ `;
154
+ ```
155
+
156
+ Helpers with variants take an options object:
157
+ `buttonClass({ variant: 'outline', size: 'sm' })`.
158
+
159
+ ### Tier 2 — stateful custom elements
160
+
161
+ For things the browser doesn't provide natively (focus traps, portaled
162
+ overlays, keyboard-navigated lists): `dialog`, `alert-dialog`, `popover`,
163
+ `tooltip`, `hover-card`, `tabs`, `accordion`, `collapsible`,
164
+ `dropdown-menu`, `progress`, `sonner`, `toggle-group`. These ARE custom
165
+ elements — import them once (typically in `app/layout.ts`) and use
166
+ `<ui-X>` tags:
167
+
168
+ ```ts
169
+ // app/layout.ts (registers the custom elements for every page)
170
+ import '../components/ui/dialog.ts';
171
+ import '../components/ui/tabs.ts';
172
+ ```
173
+
174
+ ```ts
175
+ // app/some-page/page.ts (uses the registered elements)
176
+ import { buttonClass } from '../../components/ui/button.ts';
177
+
178
+ return html`
179
+ <ui-dialog>
180
+ <ui-dialog-trigger>
181
+ <button class=${buttonClass({ variant: 'outline' })}>Edit</button>
182
+ </ui-dialog-trigger>
183
+ <ui-dialog-content>
184
+ <h2>Edit profile</h2>
185
+ ...
186
+ </ui-dialog-content>
187
+ </ui-dialog>
188
+ `;
189
+ ```
190
+
191
+ ### Adding more components
192
+
193
+ ```sh
194
+ webjs ui add dialog dropdown-menu tabs progress
195
+ ```
196
+
197
+ Each `webjs ui add` call fetches the component source from
198
+ `https://ui.webjs.dev/registry/<name>.json`, copies it into
199
+ `components/ui/`, and installs any required npm deps. Run
200
+ `webjs ui list` to browse the catalogue or visit
201
+ [https://ui.webjs.dev](https://ui.webjs.dev).
202
+
203
+ ### AI agents — picking the right tier
204
+
205
+ For forms, dashboards, settings pages, marketing layouts: **call the
206
+ Tier-1 class helpers on raw native elements**. You get accessibility,
207
+ visual consistency, and form submission semantics for free —
208
+ `<input class=${inputClass()}>` is a real `<input>`, with native
209
+ autofill, browser validation, and `<form>` submission unchanged.
210
+
211
+ For modals, dropdowns, tooltips, tab strips, accordions: use the
212
+ Tier-2 `<ui-X>` custom element tags after importing the corresponding
213
+ module.
214
+
215
+ The composition style is deliberately **not** shadcn's
216
+ component-everything React API. We use native elements + class helpers
217
+ for the visual stuff because hiding a `<button>` inside a `<Button>`
218
+ wrapper adds zero value and obscures the real element from inspection,
219
+ form submission, and screen readers. Custom elements are reserved for
220
+ behavior the browser can't deliver natively.
221
+
87
222
  ## File conventions
88
223
 
89
224
  ```
@@ -172,9 +307,8 @@ import { rateLimit, cache, createAuth, Credentials, Session } from '@webjskit/se
172
307
  import { WebComponent, html, css } from '@webjskit/core';
173
308
 
174
309
  export class Counter extends WebComponent {
175
- static tag = 'my-counter'; // required, must contain a hyphen
176
310
  static properties = { count: { type: Number } };
177
- static styles = css`button { padding: 8px 12px; }`;
311
+ static styles = css`button { padding: 8px 12px; }`; // shadow-DOM only
178
312
  // static shadow = true; // opt into shadow DOM (default: light DOM)
179
313
  // static lazy = true; // download JS only when scrolled into view
180
314
 
@@ -208,22 +342,89 @@ type-safe RPC stub automatically.
208
342
 
209
343
  ## Metadata (per-page)
210
344
 
345
+ The `metadata` export is Next.js-compatible. Common fields shown below;
346
+ the full surface includes `title.template / .default / .absolute`,
347
+ `metadataBase`, `alternates: { canonical, languages, media, types }`,
348
+ `robots`, `keywords`, `authors`, `creator`, `publisher`, `verification`,
349
+ `icons`, `manifest`, `appleWebApp`, `formatDetection`, `itunes`, and
350
+ the typed `other: { '<meta-name>': value }` escape hatch.
351
+
211
352
  ```ts
212
353
  export const metadata = {
213
354
  title: 'My page',
355
+ // OR: title: { template: '%s — {{APP_NAME}}', default: '{{APP_NAME}}' }
214
356
  description: 'A page in {{APP_NAME}}',
215
- openGraph: { type: 'website', image: 'https://...' },
357
+ metadataBase: 'https://example.com', // base for relative URLs below
358
+ openGraph: { type: 'website', image: '/og.png' },
216
359
  twitter: { card: 'summary_large_image' },
217
- cacheControl: 'public, max-age=60', // opt into caching (default: no-store)
360
+ icons: { icon: '/favicon.svg', apple: '/apple.png' },
361
+ alternates: { canonical: '/post' }, // → <link rel="canonical">
362
+ robots: { index: true, follow: true },
363
+ cacheControl: 'public, max-age=60', // opt into caching (default: no-store)
218
364
  };
219
365
  ```
220
366
 
221
367
  Use `generateMetadata(ctx)` when you need request-scoped values (e.g.
222
- absolute URLs from `ctx.url`).
368
+ absolute URLs from `ctx.url`):
369
+
370
+ ```ts
371
+ export function generateMetadata(ctx: { url: string }) {
372
+ return { metadataBase: new URL(ctx.url).origin, title: 'Hello' };
373
+ }
374
+ ```
375
+
376
+ Viewport may be split into its own export (Next.js 14+ pattern):
377
+
378
+ ```ts
379
+ export const viewport = {
380
+ width: 'device-width',
381
+ initialScale: 1,
382
+ themeColor: '#1c1613',
383
+ colorScheme: 'light dark',
384
+ };
385
+ ```
386
+
387
+ ## Document shell (`<html>` / `<head>` / `<body>`)
388
+
389
+ The framework owns the shell by default. The SSR pipeline auto-emits
390
+ `<!doctype html><html lang="en"><head>…</head><body>` around every
391
+ composition, and auto-hoists `<link>` / `<style>` / `<meta>` / `<script>`
392
+ tags returned anywhere in a layout/page into the real `<head>`. The
393
+ `metadata` export drives `<title>` and `<meta>` tags.
394
+
395
+ **Only `app/layout.ts` (the root layout)** may optionally write its
396
+ own `<!doctype><html><head>…</head><body>` shell to override `<html lang>`,
397
+ `<html dir>`, `<html data-*>`, `<body class>`, or add a custom
398
+ `<link rel="preconnect">` etc. When the root layout supplies a shell,
399
+ the framework respects it and splices its required tags into the
400
+ user's `<head>`.
401
+
402
+ ```ts
403
+ // app/layout.ts — root, optionally owning the shell
404
+ export default function RootLayout({ children }) {
405
+ return html`
406
+ <!doctype html>
407
+ <html lang="es" data-theme="dark">
408
+ <head>
409
+ <link rel="preconnect" href="https://cdn.example.com">
410
+ </head>
411
+ <body class="min-h-screen bg-bg">
412
+ <main>${children}</main>
413
+ </body>
414
+ </html>
415
+ `;
416
+ }
417
+ ```
418
+
419
+ **Non-root layouts** (`app/<segment>/layout.ts`) and **pages**
420
+ (`app/**/page.ts`) **must NOT** write `<!doctype>` / `<html>` / `<head>`
421
+ / `<body>`. The framework auto-emits the wrapper around the whole
422
+ composition, so a nested shell ends up dropped by the HTML parser.
423
+ `webjs check` enforces this via the `shell-in-non-root-layout` rule.
223
424
 
224
425
  ## Invariants (do not violate)
225
426
 
226
- 1. Custom element tags must contain a hyphen. Set `static tag`, call `.register()`.
427
+ 1. Custom element tags must contain a hyphen. Pass the tag to `.register('tag-name')` at the bottom of the file. The tag is not a static field.
227
428
  2. Never import `@prisma/client` or `node:*` from client-reachable files —
228
429
  only from `.server.ts` modules or `lib/*.ts`.
229
430
  3. Event / property / boolean holes in `` html`` `` are unquoted:
@@ -345,6 +345,64 @@ with puppeteer or playwright imports.
345
345
 
346
346
  ---
347
347
 
348
+ ## UI components — prefer the Webjs UI kit over raw Tailwind
349
+
350
+ <!-- OVERRIDE -->
351
+
352
+ This scaffold ships with the Webjs UI kit preinstalled at `components/ui/`.
353
+ The kit splits into **two tiers** — picking the wrong tier produces
354
+ broken markup.
355
+
356
+ **Tier 1 — class helpers** (button, card, input, label, alert, badge,
357
+ separator, skeleton, table, etc.): pure functions that return Tailwind
358
+ class strings. Call them and spread onto a **raw native element**.
359
+
360
+ **Tier 2 — custom elements** (dialog, popover, tooltip, dropdown-menu,
361
+ tabs, accordion, collapsible, progress, etc.): real `<ui-X>` tags. Import
362
+ the module once (typically in `app/layout.ts`) and use the tag.
363
+
364
+ ```ts
365
+ // Tier 1 — class helpers on native elements (use this for forms,
366
+ // dashboards, cards, layouts — anywhere the value is purely visual)
367
+ import { buttonClass } from '../components/ui/button.ts';
368
+ import { inputClass } from '../components/ui/input.ts';
369
+ return html`
370
+ <button class=${buttonClass({ size: 'lg' })}>Save</button>
371
+ <input class=${inputClass()} placeholder="Email">
372
+ `;
373
+
374
+ // Tier 2 — custom elements (modals, dropdowns, tab strips, tooltips —
375
+ // state the browser doesn't give you natively)
376
+ return html`
377
+ <ui-dialog>
378
+ <ui-dialog-trigger>
379
+ <button class=${buttonClass({ variant: 'outline' })}>Edit</button>
380
+ </ui-dialog-trigger>
381
+ <ui-dialog-content>…</ui-dialog-content>
382
+ </ui-dialog>
383
+ `;
384
+
385
+ // Avoid — hand-rolled Tailwind on every <button> loses visual
386
+ // consistency. Tier-1 helpers give you the same control with one import.
387
+ return html`
388
+ <button class="px-4 py-2 rounded-md bg-accent text-accent-fg">Save</button>
389
+ `;
390
+ ```
391
+
392
+ Add more components with `webjs ui add <name>` (e.g. `webjs ui add dialog
393
+ tabs popover`). The catalogue lives at
394
+ [https://ui.webjs.dev](https://ui.webjs.dev).
395
+
396
+ **Hand-rolled Tailwind is still appropriate for:**
397
+ - One-off marketing pages, hero sections, landing CTAs.
398
+ - Anywhere the visual design intentionally diverges from the kit baseline.
399
+ - Layout primitives (`<div class="grid grid-cols-3 gap-4">`).
400
+
401
+ The convention: any visual element with a Tier-1 helper uses the helper.
402
+ Any stateful behavior with a Tier-2 element uses the element.
403
+
404
+ ---
405
+
348
406
  ## Components
349
407
 
350
408
  <!-- OVERRIDE -->