@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/README.md +11 -11
- package/ai-skills/dreamtree-ui.md +6 -6
- package/bin/cli.mjs +32 -10
- package/dist/components/feedback/Dialog.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.esm.css +1 -1
- package/dist/index.esm.js +70 -32
- package/dist/index.js +70 -32
- package/mcp/README.md +83 -0
- package/mcp/catalog-core.mjs +44 -0
- package/mcp/catalog-snapshot.mjs +42 -0
- package/mcp/catalog.snapshot.json +3142 -0
- package/mcp/server-core.mjs +234 -0
- package/package.json +8 -6
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
|
-
|
|
17249
|
-
|
|
17250
|
-
|
|
17251
|
-
|
|
17252
|
-
|
|
17253
|
-
|
|
17254
|
-
|
|
17255
|
-
|
|
17256
|
-
|
|
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
|
-
|
|
17264
|
-
|
|
17265
|
-
|
|
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
|
-
|
|
17269
|
-
|
|
17270
|
-
return
|
|
17271
|
-
|
|
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 (
|
|
17326
|
-
var children =
|
|
17327
|
-
className =
|
|
17328
|
-
props = _objectWithoutProperties$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 (
|
|
17336
|
-
var children =
|
|
17337
|
-
className =
|
|
17338
|
-
props = _objectWithoutProperties$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 (
|
|
17346
|
-
var children =
|
|
17347
|
-
className =
|
|
17348
|
-
props = _objectWithoutProperties$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
|
-
|
|
17269
|
-
|
|
17270
|
-
|
|
17271
|
-
|
|
17272
|
-
|
|
17273
|
-
|
|
17274
|
-
|
|
17275
|
-
|
|
17276
|
-
|
|
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
|
-
|
|
17284
|
-
|
|
17285
|
-
|
|
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
|
-
|
|
17289
|
-
|
|
17290
|
-
return
|
|
17291
|
-
|
|
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 (
|
|
17346
|
-
var children =
|
|
17347
|
-
className =
|
|
17348
|
-
props = _objectWithoutProperties$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 (
|
|
17356
|
-
var children =
|
|
17357
|
-
className =
|
|
17358
|
-
props = _objectWithoutProperties$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 (
|
|
17366
|
-
var children =
|
|
17367
|
-
className =
|
|
17368
|
-
props = _objectWithoutProperties$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
|
+
}
|