@webjsdev/ui 0.3.7 → 0.3.9

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 (49) hide show
  1. package/README.md +14 -5
  2. package/package.json +2 -1
  3. package/packages/registry/components/accordion.ts +20 -15
  4. package/packages/registry/components/alert-dialog.ts +21 -18
  5. package/packages/registry/components/alert.ts +20 -18
  6. package/packages/registry/components/aspect-ratio.ts +11 -9
  7. package/packages/registry/components/avatar.ts +20 -12
  8. package/packages/registry/components/badge.ts +7 -5
  9. package/packages/registry/components/breadcrumb.ts +17 -15
  10. package/packages/registry/components/button.ts +8 -6
  11. package/packages/registry/components/card.ts +18 -14
  12. package/packages/registry/components/checkbox.ts +6 -4
  13. package/packages/registry/components/collapsible.ts +14 -12
  14. package/packages/registry/components/dialog.ts +32 -25
  15. package/packages/registry/components/dropdown-menu.ts +24 -21
  16. package/packages/registry/components/hover-card.ts +20 -11
  17. package/packages/registry/components/input.ts +6 -4
  18. package/packages/registry/components/kbd.ts +11 -9
  19. package/packages/registry/components/label.ts +6 -4
  20. package/packages/registry/components/native-select.ts +12 -10
  21. package/packages/registry/components/pagination.ts +15 -11
  22. package/packages/registry/components/popover.ts +24 -16
  23. package/packages/registry/components/progress.ts +8 -6
  24. package/packages/registry/components/radio-group.ts +14 -12
  25. package/packages/registry/components/separator.ts +8 -6
  26. package/packages/registry/components/skeleton.ts +6 -4
  27. package/packages/registry/components/sonner.ts +17 -13
  28. package/packages/registry/components/switch.ts +13 -11
  29. package/packages/registry/components/table.ts +21 -19
  30. package/packages/registry/components/tabs.ts +16 -10
  31. package/packages/registry/components/textarea.ts +6 -4
  32. package/packages/registry/components/toggle-group.ts +15 -13
  33. package/packages/registry/components/toggle.ts +17 -14
  34. package/packages/registry/components/tooltip.ts +12 -9
  35. package/packages/registry/lib/dom.ts +18 -0
  36. package/packages/registry/lib/utils.ts +6 -105
  37. package/packages/registry/registry.json +25 -5
  38. package/src/commands/add.js +78 -14
  39. package/src/commands/diff.js +20 -3
  40. package/src/commands/init.js +41 -25
  41. package/src/commands/list.js +2 -2
  42. package/src/commands/view.js +18 -3
  43. package/src/registry/example.js +120 -0
  44. package/src/registry/extract.js +151 -0
  45. package/src/registry/fetcher.js +60 -2
  46. package/src/registry/local.js +207 -0
  47. package/src/registry/resolver.js +2 -2
  48. package/src/utils/detect-project.js +3 -1
  49. package/src/utils/theme.js +68 -0
@@ -11,14 +11,6 @@
11
11
  * TooltipProvider → not needed; delay state is per-tooltip (and globally
12
12
  * shared via `skip-delay-duration` cooldown).
13
13
  *
14
- * Usage:
15
- * <ui-tooltip delay-duration="500" skip-delay-duration="300">
16
- * <ui-tooltip-trigger>
17
- * <button class=${buttonClass({ size: 'icon', variant: 'ghost' })} aria-label="Help">?</button>
18
- * </ui-tooltip-trigger>
19
- * <ui-tooltip-content side="top">Helpful tip</ui-tooltip-content>
20
- * </ui-tooltip>
21
- *
22
14
  * Attributes on <ui-tooltip>:
23
15
  * `open`: boolean (reflected). Open state.
24
16
  * `delay-duration`: ms, default 700. Initial hover delay before opening.
@@ -39,9 +31,20 @@
39
31
  * Programmatic API on <ui-tooltip>: `.show()` · `.hide()`.
40
32
  *
41
33
  * Design tokens used: --foreground, --background.
34
+ *
35
+ * @example
36
+ * ```html
37
+ * <ui-tooltip delay-duration="500" skip-delay-duration="300">
38
+ * <ui-tooltip-trigger>
39
+ * <button class=${buttonClass({ size: 'icon', variant: 'ghost' })} aria-label="Help">?</button>
40
+ * </ui-tooltip-trigger>
41
+ * <ui-tooltip-content side="top">Helpful tip</ui-tooltip-content>
42
+ * </ui-tooltip>
43
+ * ```
42
44
  */
43
45
  import { WebComponent, html, prop } from '@webjsdev/core';
44
- import { ensureId, onBeforeCache } from '../lib/utils.ts';
46
+ import { ensureId } from '../lib/utils.ts';
47
+ import { onBeforeCache } from '../lib/dom.ts';
45
48
  import { positionFloating, type PopoverSide, type PopoverAlign } from './popover.ts';
46
49
 
47
50
  // UA `[popover]` defaults paint a bordered, padded panel centered with
@@ -0,0 +1,18 @@
1
+ // Client-only DOM helpers. Kept SEPARATE from lib/utils.ts because these
2
+ // reference `document`: any module that references a client global is treated
3
+ // as client-effecting by the elision analyzer, so bundling this with the pure
4
+ // `cn()` would pin every page that imports `cn` to the browser (#819). Import
5
+ // these ONLY from components (which ship anyway), never from a page/layout.
6
+
7
+ /**
8
+ * Run `reset` just before the webjs client router snapshots the page into its
9
+ * back/forward cache (the `webjs:before-cache` event). Transient overlays use
10
+ * this to close themselves so a restored snapshot is clean. SSR-safe (a no-op
11
+ * when there is no `document`). Wire it in `connectedCallback` and call the
12
+ * returned disposer in `disconnectedCallback` so it never leaks across navs.
13
+ */
14
+ export function onBeforeCache(reset: () => void): () => void {
15
+ if (typeof document === 'undefined') return () => {};
16
+ document.addEventListener('webjs:before-cache', reset);
17
+ return () => document.removeEventListener('webjs:before-cache', reset);
18
+ }
@@ -127,95 +127,12 @@ export function ensureId(el: { id: string }, prefix = 'ui'): string {
127
127
  return el.id;
128
128
  }
129
129
 
130
- // ---------------------------------------------------------------------------
131
- // Custom-element base: SSR-safe. In the browser `Base = HTMLElement`. On
132
- // the server (Node, during SSR) `HTMLElement` is undefined; we substitute
133
- // a stub class so that `class Foo extends Base { … }` doesn't throw at
134
- // module-load time. The stub's methods are never actually called server-side
135
- // because connectedCallback/lifecycle hooks only run when the element is
136
- // inserted into a live DOM, which doesn't happen during webjs SSR.
137
- // ---------------------------------------------------------------------------
138
-
139
- const HasHTMLElement = typeof HTMLElement !== 'undefined';
140
-
141
- class ServerHTMLElementStub {
142
- // Minimal surface so attribute reads/writes inside synchronous code paths
143
- // that DO execute during SSR (e.g. attribute reflection) don't throw.
144
- _ssrAttrs: Record<string, string> = {};
145
- getAttribute(name: string): string | null {
146
- return this._ssrAttrs[name] ?? null;
147
- }
148
- setAttribute(name: string, value: string): void {
149
- this._ssrAttrs[name] = String(value);
150
- }
151
- hasAttribute(name: string): boolean {
152
- return name in this._ssrAttrs;
153
- }
154
- removeAttribute(name: string): void {
155
- delete this._ssrAttrs[name];
156
- }
157
- toggleAttribute(name: string, force?: boolean): boolean {
158
- const want = force === undefined ? !this.hasAttribute(name) : force;
159
- if (want) this.setAttribute(name, '');
160
- else this.removeAttribute(name);
161
- return want;
162
- }
163
- addEventListener(): void {}
164
- removeEventListener(): void {}
165
- dispatchEvent(): boolean {
166
- return true;
167
- }
168
- // Tree-walk APIs no-op into null: components that call them server-side
169
- // simply see "no children / no siblings", which is acceptable for SSR.
170
- closest(): null {
171
- return null;
172
- }
173
- querySelector(): null {
174
- return null;
175
- }
176
- querySelectorAll(): never[] {
177
- return [];
178
- }
179
- focus(): void {}
180
- blur(): void {}
181
- contains(): boolean {
182
- return false;
183
- }
184
- insertBefore<T>(node: T): T {
185
- return node;
186
- }
187
- appendChild<T>(node: T): T {
188
- return node;
189
- }
190
- replaceChildren(): void {}
191
- get firstChild(): null {
192
- return null;
193
- }
194
- get classList() {
195
- return { add: () => {}, remove: () => {}, toggle: () => false, contains: () => false };
196
- }
197
- get className(): string {
198
- return this._ssrAttrs.class ?? '';
199
- }
200
- set className(v: string) {
201
- this._ssrAttrs.class = v;
202
- }
203
- get style(): Record<string, string> {
204
- return {};
205
- }
206
- }
207
-
208
- /** SSR-safe base class: `HTMLElement` in browser, a thin stub in Node. */
209
- export const Base: typeof HTMLElement = (HasHTMLElement
210
- ? HTMLElement
211
- : (ServerHTMLElementStub as unknown as typeof HTMLElement)) as typeof HTMLElement;
212
-
213
- /** Register a custom element. No-op on server (no `customElements` global). */
214
- export function defineElement(name: string, cls: CustomElementConstructor): void {
215
- if (typeof customElements === 'undefined') return;
216
- if (customElements.get(name)) return; // already defined (HMR / double-import)
217
- customElements.define(name, cls);
218
- }
130
+ // NOTE: the old `HTMLElement`-era `Base` / `defineElement` / SSR-stub helpers
131
+ // were removed. The ui registry components extend `WebComponent` from
132
+ // `@webjsdev/core` now, so those helpers were dead code, and referencing
133
+ // `HTMLElement` / `customElements` at module scope marked this util (and any
134
+ // page that imports `cn`) as client-effecting. Everything below is pure or
135
+ // SSR-safe, so importing `cn` no longer pins a page to the browser (#819).
219
136
 
220
137
  // ---------------------------------------------------------------------------
221
138
  // Layout helpers: encode the design-system rhythm (spacing between label /
@@ -266,19 +183,3 @@ export const helpClass = () => 'text-xs text-muted-foreground';
266
183
 
267
184
  /** Validation error text: replaces hint when the field is invalid. */
268
185
  export const errorClass = () => 'text-sm font-medium text-destructive';
269
-
270
- /**
271
- * Run `reset` just before the webjs client router snapshots the page into its
272
- * back/forward cache (the `webjs:before-cache` event). Transient overlays use
273
- * this to close themselves so a restored snapshot is clean rather than, say, a
274
- * hover-card frozen open after Back then Forward (#766). Wire it in
275
- * `connectedCallback` and call the returned disposer in `disconnectedCallback`
276
- * so the listener never leaks across soft navigations.
277
- *
278
- * SSR-safe: a no-op when there is no `document` (server / no client router).
279
- */
280
- export function onBeforeCache(reset: () => void): () => void {
281
- if (typeof document === 'undefined') return () => {};
282
- document.addEventListener('webjs:before-cache', reset);
283
- return () => document.removeEventListener('webjs:before-cache', reset);
284
- }
@@ -15,6 +15,18 @@
15
15
  }
16
16
  ]
17
17
  },
18
+ {
19
+ "name": "lib-dom",
20
+ "type": "registry:lib",
21
+ "description": "onBeforeCache() client-router cache hook",
22
+ "files": [
23
+ {
24
+ "path": "lib/dom.ts",
25
+ "type": "registry:lib",
26
+ "target": "lib/dom.ts"
27
+ }
28
+ ]
29
+ },
18
30
  {
19
31
  "name": "theme-neutral",
20
32
  "type": "registry:theme",
@@ -59,7 +71,8 @@
59
71
  "type": "registry:ui",
60
72
  "registryDependencies": [
61
73
  "button",
62
- "dialog"
74
+ "dialog",
75
+ "lib-dom"
63
76
  ],
64
77
  "dependencies": [
65
78
  "@webjsdev/core"
@@ -189,7 +202,8 @@
189
202
  "type": "registry:ui",
190
203
  "registryDependencies": [
191
204
  "lib-utils",
192
- "button"
205
+ "button",
206
+ "lib-dom"
193
207
  ],
194
208
  "dependencies": [
195
209
  "@webjsdev/core"
@@ -205,7 +219,8 @@
205
219
  "name": "dropdown-menu",
206
220
  "type": "registry:ui",
207
221
  "registryDependencies": [
208
- "popover"
222
+ "popover",
223
+ "lib-dom"
209
224
  ],
210
225
  "dependencies": [
211
226
  "@webjsdev/core"
@@ -221,7 +236,8 @@
221
236
  "name": "hover-card",
222
237
  "type": "registry:ui",
223
238
  "registryDependencies": [
224
- "popover"
239
+ "popover",
240
+ "lib-dom"
225
241
  ],
226
242
  "dependencies": [
227
243
  "@webjsdev/core"
@@ -381,6 +397,9 @@
381
397
  {
382
398
  "name": "sonner",
383
399
  "type": "registry:ui",
400
+ "registryDependencies": [
401
+ "lib-dom"
402
+ ],
384
403
  "dependencies": [
385
404
  "@webjsdev/core"
386
405
  ],
@@ -476,7 +495,8 @@
476
495
  "name": "tooltip",
477
496
  "type": "registry:ui",
478
497
  "registryDependencies": [
479
- "popover"
498
+ "popover",
499
+ "lib-dom"
480
500
  ],
481
501
  "dependencies": [
482
502
  "@webjsdev/core"
@@ -7,6 +7,9 @@ import { getConfig } from '../utils/get-config.js';
7
7
  import { logger } from '../utils/logger.js';
8
8
  import { resolveTree, collectNpmDeps } from '../registry/resolver.js';
9
9
  import { DEFAULT_REGISTRY_URL } from '../registry/fetcher.js';
10
+ import { isCustomElementSource } from '../registry/local.js';
11
+ import { stripExample } from '../registry/example.js';
12
+ import { ensureTheme } from '../utils/theme.js';
10
13
 
11
14
  export const add = new Command()
12
15
  .name('add')
@@ -35,6 +38,20 @@ export const add = new Command()
35
38
  const tree = await resolveTree(components, opts.registry);
36
39
  logger.info(`Installing ${logger.bold(components.join(', '))}…`);
37
40
 
41
+ // Self-heal missing theme tokens (#983): the helpers render against CSS
42
+ // design tokens; if the app never ran (or lost) the theme block, plant it
43
+ // so a copied component is not unstyled. Idempotent when already present.
44
+ // Skip if the config predates / lacks the tailwind fields (never crash add).
45
+ const tw = config.tailwind || {};
46
+ if (tw.css && tw.baseColor) {
47
+ const theme = await ensureTheme(cwd, tw.baseColor, tw.css, opts.registry);
48
+ if (theme.status === 'written') {
49
+ logger.success(`Planted missing theme tokens into ${tw.css}`);
50
+ } else if (theme.status === 'failed') {
51
+ logger.warn(`Could not verify theme tokens in ${tw.css}: ${theme.error}`);
52
+ }
53
+ }
54
+
38
55
  for (const item of tree) {
39
56
  for (const file of item.files || []) {
40
57
  await writeRegistryFile(cwd, config, item, file, opts);
@@ -70,37 +87,84 @@ async function writeRegistryFile(cwd, config, item, file, opts) {
70
87
  }
71
88
  }
72
89
 
73
- const content = rewriteUtilsImport(file.content || '', target, config);
90
+ const content = transformForProject(file.content || '', target, config, item);
74
91
  writeFileSync(target, content, 'utf8');
75
92
  logger.success(`Wrote ${relative(cwd, target)}`);
76
93
  }
77
94
 
78
95
  /**
79
- * Rewrite the registry-relative `'../lib/utils.ts'` import to the path
80
- * that resolves correctly from the file's target location to the user's
81
- * cn() helper.
96
+ * The single transform that turns a registry file's raw content into what
97
+ * lands in the user's project. `add` writes this; `diff` compares against it,
98
+ * so the two cannot disagree (#983). Two steps:
99
+ *
100
+ * 1. Retarget the registry-relative `../lib/utils.ts` / `../lib/dom.ts` imports
101
+ * to the project's resolved helper paths (see {@link rewriteUtilsImport}).
102
+ * 2. For a Tier-1 helper component (a `registry:ui` file that is NOT a custom
103
+ * element), strip the worked `@example` and leave a one-line pointer, so the
104
+ * copied file keeps only the helpers + a lean header. Tier-2 custom-element
105
+ * files are left whole (the element IS the component), and lib/theme files
106
+ * have no example so the strip is a no-op.
107
+ *
108
+ * @param {string} content raw registry file content
109
+ * @param {string} target absolute path where the file will be written
110
+ * @param {{ resolvedPaths: { utils: string } }} config parsed components.json
111
+ * @param {{ name: string, type?: string }} [item] the registry item
112
+ * @returns {string}
113
+ */
114
+ export function transformForProject(content, target, config, item) {
115
+ let out = rewriteUtilsImport(content, target, config);
116
+ if (item && item.type === 'registry:ui' && !isCustomElementSource(content)) {
117
+ out = stripExample(out, item.name);
118
+ }
119
+ return out;
120
+ }
121
+
122
+ /**
123
+ * Rewrite the registry-relative `'../lib/utils.ts'` and `'../lib/dom.ts'`
124
+ * imports to the paths that resolve correctly from the file's target
125
+ * location to the user's cn() helper and onBeforeCache() DOM helper.
82
126
  *
83
127
  * The registry source assumes its own layout (`<registry>/components/<x>.ts`
84
- * imports `'../lib/utils.ts'`). When that file lands in the user's
85
- * components/ui/<x>.ts, the literal `'../lib/utils.ts'` resolves to
86
- * `components/lib/utils.ts`, which doesn't exist. We compute the actual
87
- * relative path from the target directory to `config.resolvedPaths.utils`
88
- * (an absolute path the user has already configured via components.json's
89
- * aliases.utils) and substitute it in.
128
+ * imports `'../lib/utils.ts'` / `'../lib/dom.ts'`). When that file lands in
129
+ * the user's components/ui/<x>.ts, the literal `'../lib/utils.ts'` resolves
130
+ * to `components/lib/utils.ts`, which doesn't exist. We compute the actual
131
+ * relative path from the target directory to the user's configured helper
132
+ * (`config.resolvedPaths.utils`, from components.json's aliases.utils) and
133
+ * substitute it in. The DOM helper is written as a SIBLING of the utils
134
+ * file (the scaffold puts it at `lib/utils/dom.ts`, next to `cn.ts`), so its
135
+ * absolute target is `dom.ts` in the utils file's directory. sonner imports
136
+ * ONLY the DOM helper, so both specifiers are handled independently and
137
+ * neither presence gates the other.
90
138
  *
91
139
  * @param {string} content raw file content from the registry
92
140
  * @param {string} target absolute path where the file will be written
93
141
  * @param {{ resolvedPaths: { utils: string } }} config parsed components.json
94
142
  */
95
143
  export function rewriteUtilsImport(content, target, config) {
96
- if (!content.includes('../lib/utils.ts')) return content;
97
144
  const utilsAbs = config?.resolvedPaths?.utils;
98
145
  if (!utilsAbs) return content;
99
- let rel = relPath(dirname(target), utilsAbs).split(/[\\/]/).join('/');
146
+ let out = replaceImportSpecifier(content, '../lib/utils.ts', utilsAbs, target);
147
+ const domAbs = join(dirname(utilsAbs), 'dom.ts');
148
+ out = replaceImportSpecifier(out, '../lib/dom.ts', domAbs, target);
149
+ return out;
150
+ }
151
+
152
+ /**
153
+ * Replace a single registry-relative import specifier with the relative path
154
+ * from `target`'s directory to `destAbs`. No-op when `spec` is absent.
155
+ *
156
+ * @param {string} content
157
+ * @param {string} spec the registry-relative specifier to replace
158
+ * @param {string} destAbs absolute path of the destination file
159
+ * @param {string} target absolute path where the importing file is written
160
+ */
161
+ function replaceImportSpecifier(content, spec, destAbs, target) {
162
+ if (!content.includes(spec)) return content;
163
+ let rel = relPath(dirname(target), destAbs).split(/[\\/]/).join('/');
100
164
  if (!rel.startsWith('.')) rel = './' + rel;
101
165
  return content
102
- .replaceAll("'../lib/utils.ts'", `'${rel}'`)
103
- .replaceAll('"../lib/utils.ts"', `"${rel}"`);
166
+ .replaceAll(`'${spec}'`, `'${rel}'`)
167
+ .replaceAll(`"${spec}"`, `"${rel}"`);
104
168
  }
105
169
 
106
170
  function resolveTarget(cwd, config, item, file) {
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs';
3
3
  import { join, basename } from 'node:path';
4
4
  import { getConfig } from '../utils/get-config.js';
5
5
  import { fetchRegistryItem, fetchRegistryIndex, DEFAULT_REGISTRY_URL } from '../registry/fetcher.js';
6
+ import { transformForProject } from './add.js';
6
7
  import { logger } from '../utils/logger.js';
7
8
 
8
9
  export const diff = new Command()
@@ -18,16 +19,32 @@ export const diff = new Command()
18
19
  process.exit(1);
19
20
  }
20
21
 
21
- const items = name ? [await fetchRegistryItem(name, opts.registry)] : (await fetchRegistryIndex(opts.registry)).filter((i) => i.type === 'registry:ui');
22
+ // diff compares local files against the LIVE upstream, so it stays on the
23
+ // network path (NOT local-first): local-first would compare the package
24
+ // against itself. Resolve the names to compare, then fetch each item's full
25
+ // content (the index is metadata-only).
26
+ let names;
27
+ if (name) {
28
+ names = [name];
29
+ } else {
30
+ const index = await fetchRegistryIndex(opts.registry);
31
+ names = index.filter((i) => i.type === 'registry:ui').map((i) => i.name);
32
+ }
33
+
22
34
  const uiDir = config.resolvedPaths.ui;
23
35
  let changed = 0;
24
36
 
25
- for (const item of items) {
37
+ for (const n of names) {
38
+ const item = await fetchRegistryItem(n, opts.registry);
26
39
  for (const file of item.files || []) {
27
40
  const target = join(uiDir, basename(file.path));
28
41
  if (!existsSync(target)) continue;
29
42
  const local = readFileSync(target, 'utf8');
30
- if (local !== (file.content || '')) {
43
+ // Compare against what `add` WOULD write (import-rewrite + example
44
+ // strip), not the raw registry content: otherwise a pristine install
45
+ // reports every import-rewritten component as differing (#983).
46
+ const expected = transformForProject(file.content || '', target, config, item);
47
+ if (local !== expected) {
31
48
  logger.info(`${logger.bold(item.name)}: ${basename(file.path)} differs from registry`);
32
49
  changed++;
33
50
  }
@@ -1,11 +1,12 @@
1
1
  import { Command } from 'commander';
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import prompts from 'prompts';
5
5
  import { defaultsForProject } from '../utils/detect-project.js';
6
6
  import { writeConfig, CONFIG_FILE } from '../utils/get-config.js';
7
7
  import { logger } from '../utils/logger.js';
8
- import { fetchRegistryItem, DEFAULT_REGISTRY_URL } from '../registry/fetcher.js';
8
+ import { getRegistryItem, DEFAULT_REGISTRY_URL } from '../registry/fetcher.js';
9
+ import { ensureTheme } from '../utils/theme.js';
9
10
 
10
11
  const BASE_COLORS = ['neutral', 'stone', 'zinc', 'mauve', 'olive', 'mist', 'taupe'];
11
12
 
@@ -66,7 +67,18 @@ export const init = new Command()
66
67
 
67
68
  // Pull lib/utils + the chosen theme from the registry and write them in.
68
69
  await writeLibUtils(cwd, defaults.aliases.utils, opts.registry);
69
- await writeTheme(cwd, answers.baseColor, answers.css, opts.registry);
70
+
71
+ // The theme tokens are what the class helpers render against. A silent
72
+ // failure here (the old behaviour) left an unstyled install with a clean
73
+ // exit code, the exact trap for an autonomous agent, so hard-fail (#983).
74
+ const theme = await ensureTheme(cwd, answers.baseColor, answers.css, opts.registry);
75
+ if (theme.status === 'failed') {
76
+ logger.error(`Could not write theme tokens into ${answers.css}: ${theme.error}`);
77
+ logger.info('The class helpers render against these tokens, so this must succeed.');
78
+ process.exit(1);
79
+ }
80
+ if (theme.status === 'written') logger.success(`Wrote theme into ${answers.css}`);
81
+ else logger.info(`Theme already present in ${answers.css}: skipping.`);
70
82
 
71
83
  logger.break();
72
84
  logger.success('Done.');
@@ -75,41 +87,45 @@ export const init = new Command()
75
87
  });
76
88
 
77
89
  async function writeLibUtils(cwd, utilsAlias, registryUrl) {
90
+ // `utils` alias points at e.g. "lib/utils" so we write to lib/utils.ts
91
+ const utilsRel = utilsAlias.replace(/^@\//, '') + '.ts';
92
+ const utilsTarget = join(cwd, utilsRel);
78
93
  try {
79
- const item = await fetchRegistryItem('lib-utils', registryUrl);
80
- if (!item.files) return;
81
- for (const f of item.files) {
82
- // `utils` alias points at e.g. "lib/utils" → we write to lib/utils.ts
83
- const target = join(cwd, utilsAlias.replace(/^@\//, '') + '.ts');
84
- ensureDir(dirname(target));
85
- writeFileSync(target, f.content || '', 'utf8');
86
- logger.success(`Wrote ${utilsAlias}.ts`);
94
+ const item = await getRegistryItem('lib-utils', registryUrl);
95
+ if (item.files) {
96
+ for (const f of item.files) {
97
+ ensureDir(dirname(utilsTarget));
98
+ writeFileSync(utilsTarget, f.content || '', 'utf8');
99
+ logger.success(`Wrote ${utilsAlias}.ts`);
100
+ }
87
101
  }
88
102
  } catch (e) {
89
103
  logger.warn(`Could not fetch lib-utils from registry (${e.message}). You may need to write lib/utils.ts manually.`);
90
104
  }
91
- }
92
105
 
93
- async function writeTheme(cwd, baseColor, cssPath, registryUrl) {
106
+ // The onBeforeCache() DOM helper lives in a SEPARATE module (#819) because
107
+ // it references `document`, so keeping it out of cn()'s file prevents the
108
+ // elision analyzer pinning every page that imports cn to the browser.
109
+ // Overlay components import it from `../lib/dom.ts`, so write it as a
110
+ // sibling of the utils file (e.g. lib/utils/dom.ts next to cn.ts).
111
+ const domTarget = join(dirname(utilsTarget), 'dom.ts');
94
112
  try {
95
- const item = await fetchRegistryItem(`theme-${baseColor}`, registryUrl);
113
+ const item = await getRegistryItem('lib-dom', registryUrl);
96
114
  if (!item.files) return;
97
- const target = join(cwd, cssPath);
98
- ensureDir(dirname(target));
99
- const existing = existsSync(target) ? readFileSync(target, 'utf8') : '';
100
- const themeBlock = item.files[0]?.content || '';
101
- // Idempotent: only append if our marker isn't already present.
102
- if (existing.includes('/* @webjsdev/ui theme */')) {
103
- logger.info(`Theme already present in ${cssPath}: skipping.`);
104
- return;
115
+ for (const f of item.files) {
116
+ ensureDir(dirname(domTarget));
117
+ writeFileSync(domTarget, f.content || '', 'utf8');
118
+ logger.success(`Wrote ${relative(cwd, domTarget)}`);
105
119
  }
106
- writeFileSync(target, existing + (existing && !existing.endsWith('\n') ? '\n' : '') + themeBlock, 'utf8');
107
- logger.success(`Wrote theme into ${cssPath}`);
108
120
  } catch (e) {
109
- logger.warn(`Could not fetch theme-${baseColor} (${e.message}). Skipping theme install.`);
121
+ logger.warn(`Could not fetch lib-dom from registry (${e.message}). You may need to write lib/dom.ts manually.`);
110
122
  }
111
123
  }
112
124
 
125
+ function relative(cwd, p) {
126
+ return p.startsWith(cwd) ? p.slice(cwd.length + 1) : p;
127
+ }
128
+
113
129
  function ensureDir(d) {
114
130
  if (!existsSync(d)) mkdirSync(d, { recursive: true });
115
131
  }
@@ -1,5 +1,5 @@
1
1
  import { Command } from 'commander';
2
- import { fetchRegistryIndex, DEFAULT_REGISTRY_URL } from '../registry/fetcher.js';
2
+ import { getRegistryIndex, DEFAULT_REGISTRY_URL } from '../registry/fetcher.js';
3
3
  import { logger } from '../utils/logger.js';
4
4
 
5
5
  export const list = new Command()
@@ -9,7 +9,7 @@ export const list = new Command()
9
9
  .argument('[filter]', 'filter by substring')
10
10
  .option('--registry <url>', 'registry base URL', DEFAULT_REGISTRY_URL)
11
11
  .action(async (filter, opts) => {
12
- const items = await fetchRegistryIndex(opts.registry);
12
+ const items = await getRegistryIndex(opts.registry);
13
13
  const ui = items.filter((i) => i.type === 'registry:ui');
14
14
  const filtered = filter ? ui.filter((i) => i.name.includes(filter)) : ui;
15
15
  if (!filtered.length) {
@@ -1,14 +1,29 @@
1
1
  import { Command } from 'commander';
2
- import { fetchRegistryItem, DEFAULT_REGISTRY_URL } from '../registry/fetcher.js';
2
+ import { getRegistryItem, DEFAULT_REGISTRY_URL, isDefaultRegistry } from '../registry/fetcher.js';
3
+ import { uiComponent, renderComponentText } from '../registry/extract.js';
3
4
  import { logger } from '../utils/logger.js';
4
5
 
5
6
  export const view = new Command()
6
7
  .name('view')
7
- .description("Print a registry item's source to stdout")
8
+ .description("Print a registry item's source (and the paste-ready example) to stdout")
8
9
  .argument('<name>', 'component name')
9
10
  .option('--registry <url>', 'registry base URL', DEFAULT_REGISTRY_URL)
11
+ .option('--source', 'print only the raw source, not the projected view')
10
12
  .action(async (name, opts) => {
11
- const item = await fetchRegistryItem(name, opts.registry);
13
+ // For a local registry:ui component, lead with the projected view (helper
14
+ // signatures + the paste-ready @example + deps). This is the human /
15
+ // offline path to the example that `add` strips from the copied file, and
16
+ // it shares ONE projector with the MCP `ui` tool (`registry/extract.js`).
17
+ // The projection reads the LOCAL packaged registry, so it only applies to
18
+ // the default registry (a custom --registry gets the raw fetched source).
19
+ const projected = opts.source || !isDefaultRegistry(opts.registry) ? null : uiComponent(name);
20
+ if (projected) {
21
+ console.log(renderComponentText(projected));
22
+ console.log('');
23
+ logger.info(logger.dim('# --- full source ---'));
24
+ }
25
+
26
+ const item = await getRegistryItem(name, opts.registry);
12
27
  logger.info(logger.dim(`# ${item.name}: ${item.type}`));
13
28
  if (item.description) logger.info(logger.dim(`# ${item.description}`));
14
29
  for (const f of item.files || []) {