@dreamtree-org/twreact-ui 1.1.49 → 1.1.51

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/dist/index.esm.js CHANGED
@@ -17228,6 +17228,14 @@ var _excluded$8 = ["isOpen", "onClose", "title", "children", "size", "dismissibl
17228
17228
  _excluded4$1 = ["children", "className"];
17229
17229
  function ownKeys$c(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
17230
17230
  function _objectSpread$c(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys$c(Object(t), true).forEach(function (r) { _defineProperty$4(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys$c(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
17231
+ // Elements that can receive keyboard focus. Used to drive the focus trap and to
17232
+ // pick the first element to focus when the dialog opens (WAI-ARIA APG dialog).
17233
+ var FOCUSABLE_SELECTOR = ['a[href]', 'button:not([disabled])', 'textarea:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', '[tabindex]:not([tabindex="-1"])'].join(',');
17234
+ var getFocusable = function getFocusable(root) {
17235
+ return root ? Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).filter(function (el) {
17236
+ return el.getAttribute('aria-hidden') !== 'true';
17237
+ }) : [];
17238
+ };
17231
17239
  var Dialog = function Dialog(_ref) {
17232
17240
  var isOpen = _ref.isOpen,
17233
17241
  onClose = _ref.onClose,
@@ -17244,33 +17252,63 @@ var Dialog = function Dialog(_ref) {
17244
17252
  onOpen = _ref.onOpen,
17245
17253
  props = _objectWithoutProperties$1(_ref, _excluded$8);
17246
17254
  var dialogRef = useRef(null);
17255
+ // The element that had focus when the dialog opened, so we can restore it on close.
17256
+ var lastFocusedRef = useRef(null);
17247
17257
  useEffect(function () {
17248
- if (isOpen) {
17249
- var _dialogRef$current;
17250
- document.body.style.overflow = 'hidden';
17251
- (_dialogRef$current = dialogRef.current) === null || _dialogRef$current === void 0 || _dialogRef$current.focus();
17252
- onOpen === null || onOpen === void 0 || onOpen({
17253
- dialogRef: dialogRef.current
17254
- });
17255
- } else {
17256
- document.body.style.overflow = 'unset';
17257
- }
17258
+ var _ref2, _focusables$;
17259
+ if (!isOpen) return undefined;
17260
+ // Remember the opener so focus can return to it when the dialog closes.
17261
+ lastFocusedRef.current = document.activeElement;
17262
+ document.body.style.overflow = 'hidden';
17263
+ // Move focus into the panel: first focusable descendant, else the panel itself.
17264
+ var panel = dialogRef.current;
17265
+ var focusables = getFocusable(panel);
17266
+ (_ref2 = (_focusables$ = focusables[0]) !== null && _focusables$ !== void 0 ? _focusables$ : panel) === null || _ref2 === void 0 || _ref2.focus();
17267
+ onOpen === null || onOpen === void 0 || onOpen({
17268
+ dialogRef: panel
17269
+ });
17258
17270
  return function () {
17271
+ var _lastFocusedRef$curre, _lastFocusedRef$curre2;
17259
17272
  document.body.style.overflow = 'unset';
17273
+ // Restore focus to whatever opened the dialog (APG focus-restore requirement).
17274
+ (_lastFocusedRef$curre = lastFocusedRef.current) === null || _lastFocusedRef$curre === void 0 || (_lastFocusedRef$curre2 = _lastFocusedRef$curre.focus) === null || _lastFocusedRef$curre2 === void 0 || _lastFocusedRef$curre2.call(_lastFocusedRef$curre);
17260
17275
  };
17261
17276
  }, [isOpen]);
17277
+ // Escape-to-close + Tab focus trap. An aria-modal element must keep keyboard
17278
+ // focus inside the panel; without this, Tab walks into the page behind it.
17262
17279
  useEffect(function () {
17263
- var handleEscape = function handleEscape(e) {
17264
- if (e.key === 'Escape' && dismissible) {
17265
- onClose === null || onClose === void 0 || onClose();
17280
+ if (!isOpen) return undefined;
17281
+ var handleKeyDown = function handleKeyDown(e) {
17282
+ if (e.key === 'Escape') {
17283
+ if (dismissible) onClose === null || onClose === void 0 || onClose();
17284
+ return;
17285
+ }
17286
+ if (e.key !== 'Tab') return;
17287
+ var panel = dialogRef.current;
17288
+ if (!panel) return;
17289
+ var focusables = getFocusable(panel);
17290
+ if (focusables.length === 0) {
17291
+ // Nothing focusable inside — pin focus to the panel.
17292
+ e.preventDefault();
17293
+ panel.focus();
17294
+ return;
17295
+ }
17296
+ var first = focusables[0];
17297
+ var last = focusables[focusables.length - 1];
17298
+ var active = document.activeElement;
17299
+ var outside = !panel.contains(active) || active === panel;
17300
+ if (e.shiftKey && (active === first || outside)) {
17301
+ e.preventDefault();
17302
+ last.focus();
17303
+ } else if (!e.shiftKey && (active === last || outside)) {
17304
+ e.preventDefault();
17305
+ first.focus();
17266
17306
  }
17267
17307
  };
17268
- if (isOpen) {
17269
- document.addEventListener('keydown', handleEscape);
17270
- return function () {
17271
- return document.removeEventListener('keydown', handleEscape);
17272
- };
17273
- }
17308
+ document.addEventListener('keydown', handleKeyDown);
17309
+ return function () {
17310
+ return document.removeEventListener('keydown', handleKeyDown);
17311
+ };
17274
17312
  }, [isOpen, dismissible, onClose]);
17275
17313
  if (!isOpen) return null;
17276
17314
  var sizeClasses = {
@@ -17290,7 +17328,7 @@ var Dialog = function Dialog(_ref) {
17290
17328
  onClick: handleBackdropClick,
17291
17329
  children: jsxs("div", _objectSpread$c(_objectSpread$c({
17292
17330
  ref: dialogRef,
17293
- className: cn$1('w-full rounded-lg bg-white shadow-xl animate-scale-in', sizeClasses[size], className),
17331
+ className: cn$1('w-full rounded-lg bg-white shadow-xl animate-scale-in motion-reduce:animate-none', sizeClasses[size], className),
17294
17332
  tabIndex: -1,
17295
17333
  role: "dialog",
17296
17334
  "aria-modal": "true",
@@ -17322,30 +17360,30 @@ var Dialog = function Dialog(_ref) {
17322
17360
  }), document.body);
17323
17361
  };
17324
17362
  // Dialog components for composition
17325
- Dialog.Header = function (_ref2) {
17326
- var children = _ref2.children,
17327
- className = _ref2.className,
17328
- props = _objectWithoutProperties$1(_ref2, _excluded2$1);
17363
+ Dialog.Header = function (_ref3) {
17364
+ var children = _ref3.children,
17365
+ className = _ref3.className,
17366
+ props = _objectWithoutProperties$1(_ref3, _excluded2$1);
17329
17367
  return jsx("div", _objectSpread$c(_objectSpread$c({
17330
17368
  className: cn$1('border-b border-gray-200 px-6 py-4', className)
17331
17369
  }, props), {}, {
17332
17370
  children: children
17333
17371
  }));
17334
17372
  };
17335
- Dialog.Body = function (_ref3) {
17336
- var children = _ref3.children,
17337
- className = _ref3.className,
17338
- props = _objectWithoutProperties$1(_ref3, _excluded3$1);
17373
+ Dialog.Body = function (_ref4) {
17374
+ var children = _ref4.children,
17375
+ className = _ref4.className,
17376
+ props = _objectWithoutProperties$1(_ref4, _excluded3$1);
17339
17377
  return jsx("div", _objectSpread$c(_objectSpread$c({
17340
17378
  className: cn$1('px-6 py-4', className)
17341
17379
  }, props), {}, {
17342
17380
  children: children
17343
17381
  }));
17344
17382
  };
17345
- Dialog.Footer = function (_ref4) {
17346
- var children = _ref4.children,
17347
- className = _ref4.className,
17348
- props = _objectWithoutProperties$1(_ref4, _excluded4$1);
17383
+ Dialog.Footer = function (_ref5) {
17384
+ var children = _ref5.children,
17385
+ className = _ref5.className,
17386
+ props = _objectWithoutProperties$1(_ref5, _excluded4$1);
17349
17387
  return jsx("div", _objectSpread$c(_objectSpread$c({
17350
17388
  className: cn$1('flex items-center justify-end gap-3 border-t border-gray-200 px-6 py-4', className)
17351
17389
  }, props), {}, {
package/dist/index.js CHANGED
@@ -17248,6 +17248,14 @@ var _excluded$8 = ["isOpen", "onClose", "title", "children", "size", "dismissibl
17248
17248
  _excluded4$1 = ["children", "className"];
17249
17249
  function ownKeys$c(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
17250
17250
  function _objectSpread$c(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys$c(Object(t), true).forEach(function (r) { _defineProperty$4(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys$c(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
17251
+ // Elements that can receive keyboard focus. Used to drive the focus trap and to
17252
+ // pick the first element to focus when the dialog opens (WAI-ARIA APG dialog).
17253
+ var FOCUSABLE_SELECTOR = ['a[href]', 'button:not([disabled])', 'textarea:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', '[tabindex]:not([tabindex="-1"])'].join(',');
17254
+ var getFocusable = function getFocusable(root) {
17255
+ return root ? Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).filter(function (el) {
17256
+ return el.getAttribute('aria-hidden') !== 'true';
17257
+ }) : [];
17258
+ };
17251
17259
  var Dialog = function Dialog(_ref) {
17252
17260
  var isOpen = _ref.isOpen,
17253
17261
  onClose = _ref.onClose,
@@ -17264,33 +17272,63 @@ var Dialog = function Dialog(_ref) {
17264
17272
  onOpen = _ref.onOpen,
17265
17273
  props = _objectWithoutProperties$1(_ref, _excluded$8);
17266
17274
  var dialogRef = React.useRef(null);
17275
+ // The element that had focus when the dialog opened, so we can restore it on close.
17276
+ var lastFocusedRef = React.useRef(null);
17267
17277
  React.useEffect(function () {
17268
- if (isOpen) {
17269
- var _dialogRef$current;
17270
- document.body.style.overflow = 'hidden';
17271
- (_dialogRef$current = dialogRef.current) === null || _dialogRef$current === void 0 || _dialogRef$current.focus();
17272
- onOpen === null || onOpen === void 0 || onOpen({
17273
- dialogRef: dialogRef.current
17274
- });
17275
- } else {
17276
- document.body.style.overflow = 'unset';
17277
- }
17278
+ var _ref2, _focusables$;
17279
+ if (!isOpen) return undefined;
17280
+ // Remember the opener so focus can return to it when the dialog closes.
17281
+ lastFocusedRef.current = document.activeElement;
17282
+ document.body.style.overflow = 'hidden';
17283
+ // Move focus into the panel: first focusable descendant, else the panel itself.
17284
+ var panel = dialogRef.current;
17285
+ var focusables = getFocusable(panel);
17286
+ (_ref2 = (_focusables$ = focusables[0]) !== null && _focusables$ !== void 0 ? _focusables$ : panel) === null || _ref2 === void 0 || _ref2.focus();
17287
+ onOpen === null || onOpen === void 0 || onOpen({
17288
+ dialogRef: panel
17289
+ });
17278
17290
  return function () {
17291
+ var _lastFocusedRef$curre, _lastFocusedRef$curre2;
17279
17292
  document.body.style.overflow = 'unset';
17293
+ // Restore focus to whatever opened the dialog (APG focus-restore requirement).
17294
+ (_lastFocusedRef$curre = lastFocusedRef.current) === null || _lastFocusedRef$curre === void 0 || (_lastFocusedRef$curre2 = _lastFocusedRef$curre.focus) === null || _lastFocusedRef$curre2 === void 0 || _lastFocusedRef$curre2.call(_lastFocusedRef$curre);
17280
17295
  };
17281
17296
  }, [isOpen]);
17297
+ // Escape-to-close + Tab focus trap. An aria-modal element must keep keyboard
17298
+ // focus inside the panel; without this, Tab walks into the page behind it.
17282
17299
  React.useEffect(function () {
17283
- var handleEscape = function handleEscape(e) {
17284
- if (e.key === 'Escape' && dismissible) {
17285
- onClose === null || onClose === void 0 || onClose();
17300
+ if (!isOpen) return undefined;
17301
+ var handleKeyDown = function handleKeyDown(e) {
17302
+ if (e.key === 'Escape') {
17303
+ if (dismissible) onClose === null || onClose === void 0 || onClose();
17304
+ return;
17305
+ }
17306
+ if (e.key !== 'Tab') return;
17307
+ var panel = dialogRef.current;
17308
+ if (!panel) return;
17309
+ var focusables = getFocusable(panel);
17310
+ if (focusables.length === 0) {
17311
+ // Nothing focusable inside — pin focus to the panel.
17312
+ e.preventDefault();
17313
+ panel.focus();
17314
+ return;
17315
+ }
17316
+ var first = focusables[0];
17317
+ var last = focusables[focusables.length - 1];
17318
+ var active = document.activeElement;
17319
+ var outside = !panel.contains(active) || active === panel;
17320
+ if (e.shiftKey && (active === first || outside)) {
17321
+ e.preventDefault();
17322
+ last.focus();
17323
+ } else if (!e.shiftKey && (active === last || outside)) {
17324
+ e.preventDefault();
17325
+ first.focus();
17286
17326
  }
17287
17327
  };
17288
- if (isOpen) {
17289
- document.addEventListener('keydown', handleEscape);
17290
- return function () {
17291
- return document.removeEventListener('keydown', handleEscape);
17292
- };
17293
- }
17328
+ document.addEventListener('keydown', handleKeyDown);
17329
+ return function () {
17330
+ return document.removeEventListener('keydown', handleKeyDown);
17331
+ };
17294
17332
  }, [isOpen, dismissible, onClose]);
17295
17333
  if (!isOpen) return null;
17296
17334
  var sizeClasses = {
@@ -17310,7 +17348,7 @@ var Dialog = function Dialog(_ref) {
17310
17348
  onClick: handleBackdropClick,
17311
17349
  children: jsxRuntime.jsxs("div", _objectSpread$c(_objectSpread$c({
17312
17350
  ref: dialogRef,
17313
- className: cn$1('w-full rounded-lg bg-white shadow-xl animate-scale-in', sizeClasses[size], className),
17351
+ className: cn$1('w-full rounded-lg bg-white shadow-xl animate-scale-in motion-reduce:animate-none', sizeClasses[size], className),
17314
17352
  tabIndex: -1,
17315
17353
  role: "dialog",
17316
17354
  "aria-modal": "true",
@@ -17342,30 +17380,30 @@ var Dialog = function Dialog(_ref) {
17342
17380
  }), document.body);
17343
17381
  };
17344
17382
  // Dialog components for composition
17345
- Dialog.Header = function (_ref2) {
17346
- var children = _ref2.children,
17347
- className = _ref2.className,
17348
- props = _objectWithoutProperties$1(_ref2, _excluded2$1);
17383
+ Dialog.Header = function (_ref3) {
17384
+ var children = _ref3.children,
17385
+ className = _ref3.className,
17386
+ props = _objectWithoutProperties$1(_ref3, _excluded2$1);
17349
17387
  return jsxRuntime.jsx("div", _objectSpread$c(_objectSpread$c({
17350
17388
  className: cn$1('border-b border-gray-200 px-6 py-4', className)
17351
17389
  }, props), {}, {
17352
17390
  children: children
17353
17391
  }));
17354
17392
  };
17355
- Dialog.Body = function (_ref3) {
17356
- var children = _ref3.children,
17357
- className = _ref3.className,
17358
- props = _objectWithoutProperties$1(_ref3, _excluded3$1);
17393
+ Dialog.Body = function (_ref4) {
17394
+ var children = _ref4.children,
17395
+ className = _ref4.className,
17396
+ props = _objectWithoutProperties$1(_ref4, _excluded3$1);
17359
17397
  return jsxRuntime.jsx("div", _objectSpread$c(_objectSpread$c({
17360
17398
  className: cn$1('px-6 py-4', className)
17361
17399
  }, props), {}, {
17362
17400
  children: children
17363
17401
  }));
17364
17402
  };
17365
- Dialog.Footer = function (_ref4) {
17366
- var children = _ref4.children,
17367
- className = _ref4.className,
17368
- props = _objectWithoutProperties$1(_ref4, _excluded4$1);
17403
+ Dialog.Footer = function (_ref5) {
17404
+ var children = _ref5.children,
17405
+ className = _ref5.className,
17406
+ props = _objectWithoutProperties$1(_ref5, _excluded4$1);
17369
17407
  return jsxRuntime.jsx("div", _objectSpread$c(_objectSpread$c({
17370
17408
  className: cn$1('flex items-center justify-end gap-3 border-t border-gray-200 px-6 py-4', className)
17371
17409
  }, props), {}, {
package/mcp/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # dreamtree-ui MCP server
2
+
3
+ A [Model Context Protocol](https://modelcontextprotocol.io) server that exposes
4
+ `@dreamtree-org/twreact-ui` to AI agents: a coding agent can **discover the
5
+ catalog, fetch authoritative prop contracts, search by capability, and read the
6
+ docs — live**, instead of guessing prop names or copy-pasting from memory.
7
+
8
+ This is the concrete delivery of the project's **AI-native moat** (roadmap issue
9
+ [#61](https://github.com/DreamtreeTech/dreamtree-ui/issues/61)): something a
10
+ copy-paste generator like shadcn/ui structurally cannot offer.
11
+
12
+ ## What it serves
13
+
14
+ **Tools**
15
+ | Tool | Args | Returns |
16
+ | --- | --- | --- |
17
+ | `list_components` | — | The full grouped catalog (core / feedback / navigation / utility + hooks + utils) with descriptions. |
18
+ | `get_component` | `name` | One component's spec: import line, props (name/type/default/description), variants, sizes, examples, family exports, a11y + convention notes. Accepts a family-export name (`useToast` → `Toast`). |
19
+ | `search_components` | `query` | Ranked matches across names, family exports, descriptions, prop names, and examples. |
20
+
21
+ **Resources**
22
+ - `dreamtree://skill` — the consumer AI usage guide (`ai-skills/dreamtree-ui.md`).
23
+ - `dreamtree://docs/<Component>` — the per-component manual (`doc/<Component>.md`), one per documented component.
24
+
25
+ **Prompt**
26
+ - `compose_ui` (optional `task` arg) — primes an agent to build UI with the library correctly (vocabulary, providers, the get_component-before-use rule).
27
+
28
+ ## Single source of truth
29
+
30
+ The catalog (`catalog.mjs`) is **derived, not hand-maintained**: it reads the
31
+ public exports from `src/index.js` (names + group via import path) and the
32
+ props/examples from `doc/<Component>.md`. Keep those accurate and the MCP server
33
+ stays truthful automatically. There is no duplicate component list to drift.
34
+
35
+ ## Run it
36
+
37
+ ```bash
38
+ # from the repo root
39
+ npm run mcp # node mcp/server.mjs (stdio transport)
40
+ npm run mcp:smoke # end-to-end check: boots the server, exercises every tool/resource/prompt
41
+ ```
42
+
43
+ It speaks MCP over **stdio** — logs go to stderr, the protocol uses stdout.
44
+
45
+ ## Use it from Claude Code (this repo)
46
+
47
+ Already wired in [`.mcp.json`](../.mcp.json):
48
+
49
+ ```json
50
+ {
51
+ "mcpServers": {
52
+ "dreamtree-ui": { "command": "node", "args": ["mcp/server.mjs"] }
53
+ }
54
+ }
55
+ ```
56
+
57
+ Open this repo in Claude Code and the `dreamtree-ui` tools/resources are
58
+ available. The companion Claude Code skill at
59
+ `.claude/skills/dreamtree-ui/SKILL.md` tells the agent to query these tools
60
+ before composing UI.
61
+
62
+ ## Use it from another MCP client
63
+
64
+ Point any MCP client at `node /path/to/dreamtree-ui/mcp/server.mjs`. It needs
65
+ this repo present (the catalog reads `src/index.js` + `doc/`).
66
+
67
+ ## Repo-local vs shipped — both in this package
68
+
69
+ - **Live server (`mcp/server.mjs`)** — reads `src/index.js` + `doc/*.md` live
70
+ via `mcp/catalog.mjs`. It's what `.mcp.json` and `npm run mcp` run.
71
+ - **Shipped server** — consumers run `npx -y @dreamtree-org/twreact-ui mcp`
72
+ (the `mcp` subcommand of the library's bin, `bin/cli.mjs`). Since `src/` and
73
+ `doc/` aren't in the published tarball, it reads a frozen build-time
74
+ **snapshot** (`mcp/catalog.snapshot.json`) via `mcp/catalog-snapshot.mjs`.
75
+
76
+ Both import the same handlers (`server-core.mjs`) and pure queries
77
+ (`catalog-core.mjs`) — single source of truth, nothing copied.
78
+ `npm run mcp:snapshot` regenerates the one generated artifact,
79
+ `mcp/catalog.snapshot.json`. The MCP SDK is a runtime `dependency` of the
80
+ library, lazy-imported only by the `mcp` subcommand (never in a consumer's app
81
+ bundle). See
82
+ [`docs/agents/11-mcp-sync.md §7`](../docs/agents/11-mcp-sync.md#7-consumer-distribution--bundled-in-the-main-package)
83
+ (delivered under #74 / the moat in #61).
@@ -0,0 +1,44 @@
1
+ // Pure catalog queries — no filesystem, no data source.
2
+ // ---------------------------------------------------------------------------
3
+ // These operate on an already-built catalog object (the shape returned by
4
+ // buildCatalog()), so they are identical whether the catalog came from the
5
+ // live source (mcp/catalog.mjs, reads src/ + doc/) or the frozen snapshot
6
+ // (mcp/catalog-snapshot.mjs, reads mcp/catalog.snapshot.json). Both servers
7
+ // import this file directly — no copying — which is what lets the repo-local
8
+ // `mcp/server.mjs` and the shipped `twreact-ui mcp` subcommand share one
9
+ // implementation.
10
+
11
+ function allEntries(catalog) {
12
+ return [...catalog.components, ...catalog.hooks, ...catalog.utils, ...catalog.store];
13
+ }
14
+
15
+ // Resolve a name to a single catalog entry: exact match, then family-export
16
+ // (e.g. "useToast" → "Toast"), then case-insensitive. Returns null if none.
17
+ export function findComponentIn(catalog, name) {
18
+ const all = allEntries(catalog);
19
+ const lower = String(name || "").toLowerCase();
20
+ return (
21
+ all.find((x) => x.name === name) ||
22
+ all.find((x) => x.familyExports.includes(name)) ||
23
+ all.find((x) => x.name.toLowerCase() === lower) ||
24
+ null
25
+ );
26
+ }
27
+
28
+ // Ranked keyword search across names, family exports, descriptions, prop
29
+ // names, and examples. Returns entries with a `_score`, highest first.
30
+ export function searchCatalogIn(catalog, query) {
31
+ const q = String(query || "").toLowerCase().trim();
32
+ if (!q) return [];
33
+ const scored = [];
34
+ for (const x of allEntries(catalog)) {
35
+ let score = 0;
36
+ if (x.name.toLowerCase().includes(q)) score += 10;
37
+ if (x.familyExports.some((f) => f.toLowerCase().includes(q))) score += 6;
38
+ if (x.description.toLowerCase().includes(q)) score += 3;
39
+ if (x.props.some((p) => p.prop.toLowerCase().includes(q))) score += 2;
40
+ if (x.examples.toLowerCase().includes(q)) score += 1;
41
+ if (score > 0) scored.push({ ...x, _score: score });
42
+ }
43
+ return scored.sort((a, b) => b._score - a._score);
44
+ }
@@ -0,0 +1,42 @@
1
+ // Snapshot-backed catalog — what the SHIPPED `twreact-ui mcp` server reads.
2
+ // ---------------------------------------------------------------------------
3
+ // Exposes the SAME surface as the live mcp/catalog.mjs
4
+ // buildCatalog, findComponent, searchCatalog, listDocNames, readDoc, readSkill
5
+ // so the shared server core (mcp/server-core.mjs) is agnostic to the source.
6
+ // Here the data is a frozen snapshot (mcp/catalog.snapshot.json, generated by
7
+ // scripts/gen-mcp-snapshot.mjs) — no src/ or doc/ needed at runtime, so it
8
+ // works after a plain `npm install @dreamtree-org/twreact-ui`. The pure query
9
+ // helpers are shared verbatim with the live server (./catalog-core.mjs); no
10
+ // copying — both the live and shipped servers import the same files in this
11
+ // package.
12
+ import { readFileSync } from "node:fs";
13
+ import { fileURLToPath } from "node:url";
14
+ import { dirname, join } from "node:path";
15
+ import { findComponentIn, searchCatalogIn } from "./catalog-core.mjs";
16
+
17
+ const __dirname = dirname(fileURLToPath(import.meta.url));
18
+ const snapshot = JSON.parse(readFileSync(join(__dirname, "catalog.snapshot.json"), "utf8"));
19
+
20
+ export function buildCatalog() {
21
+ return snapshot.catalog;
22
+ }
23
+
24
+ export function findComponent(name) {
25
+ return findComponentIn(snapshot.catalog, name);
26
+ }
27
+
28
+ export function searchCatalog(query) {
29
+ return searchCatalogIn(snapshot.catalog, query);
30
+ }
31
+
32
+ export function listDocNames() {
33
+ return Object.keys(snapshot.docs);
34
+ }
35
+
36
+ export function readDoc(name) {
37
+ return snapshot.docs[name] ?? null;
38
+ }
39
+
40
+ export function readSkill() {
41
+ return snapshot.skill ?? null;
42
+ }