@sakaniui/react 0.8.6 → 0.8.7
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/AGENTS.md +15 -0
- package/README.md +24 -0
- package/bin/sakaniui-mcp.mjs +191 -0
- package/dist/components/IconButton/IconButton.d.ts +1 -1
- package/dist/manifest.json +8532 -0
- package/package.json +8 -3
package/AGENTS.md
CHANGED
|
@@ -7,6 +7,20 @@ first, then open a component's docs page for its props.
|
|
|
7
7
|
|
|
8
8
|
Full machine-readable index: https://www.sakaniui.com/llms.txt (and `/llms-full.txt`).
|
|
9
9
|
|
|
10
|
+
**The contract:** `@sakaniui/react/manifest.json` (also https://www.sakaniui.com/manifest.json) lists
|
|
11
|
+
every exported component and block with its import path, props, types, enum values, defaults, which
|
|
12
|
+
props are *required*, and every design token. It is generated from the TypeScript source on each
|
|
13
|
+
build, so it is always in step with the installed version. Prefer it over guessing a prop name:
|
|
14
|
+
```ts
|
|
15
|
+
import manifest from '@sakaniui/react/manifest.json';
|
|
16
|
+
const button = manifest.components.find((c) => c.name === 'Button'); // props[], contract.requiredProps
|
|
17
|
+
```
|
|
18
|
+
Editors can query the same data through the bundled MCP server
|
|
19
|
+
(`npx -y -p @sakaniui/react sakaniui-mcp`): `get_component`, `check_usage`, `get_tokens`, `get_guide`.
|
|
20
|
+
If it is connected, call `check_usage` on every component you write.
|
|
21
|
+
If a prop, value or token is not in the manifest, it does not exist. If `contract.requiresAccessibleName`
|
|
22
|
+
is true, the required `aria-label` / alt prop is the only thing that names the control. Do not drop it.
|
|
23
|
+
|
|
10
24
|
---
|
|
11
25
|
|
|
12
26
|
## 1. Setup you must get right
|
|
@@ -232,6 +246,7 @@ and **tell the user when you are composing** rather than implying a dedicated co
|
|
|
232
246
|
- [ ] Both CSS files imported once; no hex/px literals where a token exists.
|
|
233
247
|
- [ ] Exactly one `primary` Button per view; no hand-styled `<button>`.
|
|
234
248
|
- [ ] Every icon-only control has an accessible name.
|
|
249
|
+
- [ ] Every prop, value and token you used exists in `manifest.json`; every `contract.requiredProps` entry is set.
|
|
235
250
|
- [ ] Looks right inside `className="dark"`.
|
|
236
251
|
- [ ] If you used glass/liquid: acceptable in the frosted fallback; data surfaces stay `solid`.
|
|
237
252
|
- [ ] You said so wherever you composed something the system doesn't provide.
|
package/README.md
CHANGED
|
@@ -95,6 +95,30 @@ export const Dashboard = () => (
|
|
|
95
95
|
</div>
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
+
## Use it with AI (agents, Cursor, Claude Code)
|
|
99
|
+
|
|
100
|
+
SakaniUI ships a machine-readable contract and an MCP server, so an AI assistant looks components
|
|
101
|
+
up instead of guessing prop names.
|
|
102
|
+
|
|
103
|
+
- `@sakaniui/react/manifest.json` -- every component and block with props, types, allowed values,
|
|
104
|
+
defaults, required props and all design tokens, generated from the source on every build.
|
|
105
|
+
- `AGENTS.md` -- the "why" guide (principles, when nothing fits, glass, icons, checklist).
|
|
106
|
+
- **MCP server** -- add it to your editor's MCP config (Claude Code, Cursor, Windsurf, ...):
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{
|
|
110
|
+
"mcpServers": {
|
|
111
|
+
"sakaniui": { "command": "npx", "args": ["-y", "-p", "@sakaniui/react", "sakaniui-mcp"] }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Claude Code: `claude mcp add sakaniui -- npx -y -p @sakaniui/react sakaniui-mcp`
|
|
117
|
+
|
|
118
|
+
Tools: `search_components`, `get_component`, `list_components`, `get_tokens`, `check_usage`
|
|
119
|
+
(validates props against the contract) and `get_guide`. It runs locally, has no dependencies and
|
|
120
|
+
answers from the installed version.
|
|
121
|
+
|
|
98
122
|
## Glass & liquid glass
|
|
99
123
|
|
|
100
124
|
Glass is built into the theme system, not added component by component. Next to light/dark, SakaniUI has a second axis — **Surface** — with three modes: **Solid** (the default), **Glass** (frosted translucency) and **Liquid** (Apple-style liquid glass: the backdrop *bends* at the edges like a lens, with a faint color fringe, rim light and depth). Components read a small set of `--surface-*` tokens, so switching an area from Solid to Glass or Liquid re-themes everything inside it at once — in the Figma file (variable modes and effect styles) and in code. Solid stays pixel-identical to before.
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SakaniUI MCP server -- lets an AI editor/agent look up the design system instead of guessing.
|
|
4
|
+
*
|
|
5
|
+
* npx -y -p @sakaniui/react sakaniui-mcp
|
|
6
|
+
*
|
|
7
|
+
* Zero dependencies: speaks newline-delimited JSON-RPC (MCP stdio transport) and answers from the
|
|
8
|
+
* files shipped in this package -- dist/manifest.json (generated from the source on every build)
|
|
9
|
+
* and AGENTS.md -- so it always matches the installed version.
|
|
10
|
+
*
|
|
11
|
+
* Tools: search_components, get_component, list_components, get_tokens, check_usage, get_guide.
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import readline from 'node:readline';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
|
+
const readPkgFile = (p) => fs.readFileSync(path.join(pkgRoot, p), 'utf8');
|
|
20
|
+
|
|
21
|
+
let manifest;
|
|
22
|
+
try {
|
|
23
|
+
manifest = JSON.parse(readPkgFile('dist/manifest.json'));
|
|
24
|
+
} catch {
|
|
25
|
+
process.stderr.write('sakaniui-mcp: dist/manifest.json not found. Run `npm run build:lib` (or install a published version).\n');
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
const guide = (() => { try { return readPkgFile('AGENTS.md'); } catch { return ''; } })();
|
|
29
|
+
const byName = new Map(manifest.components.map((c) => [c.name.toLowerCase(), c]));
|
|
30
|
+
|
|
31
|
+
/* ---------- helpers ---------- */
|
|
32
|
+
const brief = (c) => ({ name: c.name, kind: c.kind, category: c.category, import: c.import, summary: c.summary, requiredProps: c.contract.requiredProps });
|
|
33
|
+
const text = (obj) => ({ content: [{ type: 'text', text: typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2) }] });
|
|
34
|
+
const fail = (msg) => ({ isError: true, content: [{ type: 'text', text: msg }] });
|
|
35
|
+
|
|
36
|
+
function score(c, terms) {
|
|
37
|
+
const hay = `${c.name} ${c.category ?? ''} ${c.summary} ${c.props.map((p) => p.name).join(' ')}`.toLowerCase();
|
|
38
|
+
let s = 0;
|
|
39
|
+
for (const t of terms) {
|
|
40
|
+
if (c.name.toLowerCase() === t) s += 20;
|
|
41
|
+
else if (c.name.toLowerCase().includes(t)) s += 8;
|
|
42
|
+
if ((c.category ?? '').toLowerCase().includes(t)) s += 3;
|
|
43
|
+
if (c.summary.toLowerCase().includes(t)) s += 3;
|
|
44
|
+
else if (hay.includes(t)) s += 1;
|
|
45
|
+
}
|
|
46
|
+
return s;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function distance(a, b) {
|
|
50
|
+
const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
51
|
+
for (let j = 1; j <= b.length; j++) d[0][j] = j;
|
|
52
|
+
for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
53
|
+
return d[a.length][b.length];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function closest(name) {
|
|
57
|
+
const n = name.toLowerCase();
|
|
58
|
+
return manifest.components
|
|
59
|
+
.map((c) => [c.name.toLowerCase().includes(n) || n.includes(c.name.toLowerCase()) ? 0 : distance(n, c.name.toLowerCase()), c.name])
|
|
60
|
+
.filter(([d]) => d <= 2)
|
|
61
|
+
.sort((a, b) => a[0] - b[0])
|
|
62
|
+
.slice(0, 5)
|
|
63
|
+
.map(([, c]) => c);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const NATIVE = /^(className|style|id|ref|key|children|role|tabIndex|title|hidden|lang|dir|slot|on[A-Z].*|aria-.*|data-.*)$/;
|
|
67
|
+
|
|
68
|
+
/* ---------- tools ---------- */
|
|
69
|
+
const tools = {
|
|
70
|
+
search_components: {
|
|
71
|
+
description: 'Find SakaniUI components/blocks by what you need (e.g. "date picker", "sidebar navigation", "pricing"). Returns the best matches with import path and required props.',
|
|
72
|
+
inputSchema: { type: 'object', properties: { query: { type: 'string' }, kind: { type: 'string', enum: ['component', 'block'] }, limit: { type: 'number' } }, required: ['query'] },
|
|
73
|
+
run({ query, kind, limit = 8 }) {
|
|
74
|
+
const terms = String(query).toLowerCase().split(/\s+/).filter(Boolean);
|
|
75
|
+
const hits = manifest.components
|
|
76
|
+
.filter((c) => !kind || c.kind === kind)
|
|
77
|
+
.map((c) => [score(c, terms), c])
|
|
78
|
+
.filter(([s]) => s > 0)
|
|
79
|
+
.sort((a, b) => b[0] - a[0])
|
|
80
|
+
.slice(0, limit)
|
|
81
|
+
.map(([, c]) => brief(c));
|
|
82
|
+
return hits.length ? text(hits) : text(`No match for "${query}". If nothing fits, follow AGENTS.md section 3 ("When nothing fits"): compose from tokens and say so. Try list_components to browse.`);
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
get_component: {
|
|
86
|
+
description: 'Full contract for one component or block: import path, every prop with type / allowed values / default / required, and the accessible-name rule. Use this before writing JSX.',
|
|
87
|
+
inputSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
|
|
88
|
+
run({ name }) {
|
|
89
|
+
const c = byName.get(String(name).toLowerCase());
|
|
90
|
+
if (!c) return fail(`No component named "${name}". Did you mean: ${closest(String(name)).join(', ') || '(none close)'}? Use search_components.`);
|
|
91
|
+
return text({ ...c, note: 'Native HTML attributes (className, style, aria-*, event handlers) are also accepted on the root element.' });
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
list_components: {
|
|
95
|
+
description: 'List all components and blocks (name + one-line summary), optionally filtered by kind or category (e.g. Forms, Core, Marketing).',
|
|
96
|
+
inputSchema: { type: 'object', properties: { kind: { type: 'string', enum: ['component', 'block'] }, category: { type: 'string' } } },
|
|
97
|
+
run({ kind, category }) {
|
|
98
|
+
const list = manifest.components
|
|
99
|
+
.filter((c) => (!kind || c.kind === kind) && (!category || (c.category ?? '').toLowerCase().includes(String(category).toLowerCase())))
|
|
100
|
+
.map((c) => ({ name: c.name, kind: c.kind, category: c.category, summary: c.summary }));
|
|
101
|
+
const categories = [...new Set(manifest.components.map((c) => c.category).filter(Boolean))].sort();
|
|
102
|
+
return text({ count: list.length, categories, components: list });
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
get_tokens: {
|
|
106
|
+
description: 'Design tokens (CSS custom properties). Pass a group (color, space, radius, shadow, font, motion, ...) and/or a substring to filter. Only these tokens exist.',
|
|
107
|
+
inputSchema: { type: 'object', properties: { group: { type: 'string' }, query: { type: 'string' } } },
|
|
108
|
+
run({ group, query }) {
|
|
109
|
+
const groups = manifest.tokens.groups;
|
|
110
|
+
if (group && !groups[group]) return fail(`Unknown token group "${group}". Groups: ${Object.keys(groups).join(', ')}`);
|
|
111
|
+
const out = {};
|
|
112
|
+
for (const [g, toks] of Object.entries(groups)) {
|
|
113
|
+
if (group && g !== group) continue;
|
|
114
|
+
const sel = Object.entries(toks).filter(([k, v]) => !query || k.includes(String(query).toLowerCase()) || String(v).toLowerCase().includes(String(query).toLowerCase()));
|
|
115
|
+
if (sel.length) out[g] = Object.fromEntries(sel);
|
|
116
|
+
}
|
|
117
|
+
return text(Object.keys(out).length ? out : `No tokens match. Groups: ${Object.keys(groups).join(', ')}`);
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
check_usage: {
|
|
121
|
+
description: 'Validate props you are about to use against the contract: reports missing required props, values outside an enum, and props that do not exist. Run it on each component you write.',
|
|
122
|
+
inputSchema: { type: 'object', properties: { component: { type: 'string' }, props: { type: 'object', description: 'prop name -> value (use true/false/strings; for non-literal values pass any placeholder)' } }, required: ['component'] },
|
|
123
|
+
run({ component, props = {} }) {
|
|
124
|
+
const c = byName.get(String(component).toLowerCase());
|
|
125
|
+
if (!c) return fail(`No component named "${component}". Did you mean: ${closest(String(component)).join(', ') || '(none close)'}?`);
|
|
126
|
+
const known = new Map(c.props.map((p) => [p.name, p]));
|
|
127
|
+
const errors = [];
|
|
128
|
+
const warnings = [];
|
|
129
|
+
for (const r of c.contract.requiredProps) if (!(r in props)) errors.push(`Missing required prop "${r}".`);
|
|
130
|
+
for (const [k, v] of Object.entries(props)) {
|
|
131
|
+
const p = known.get(k);
|
|
132
|
+
if (!p) { if (!NATIVE.test(k)) warnings.push(`"${k}" is not a ${c.name} prop in the manifest (it would only work if it is a native attribute).`); continue; }
|
|
133
|
+
if (p.values && typeof v === 'string' && !p.values.includes(v)) errors.push(`"${k}" = "${v}" is not allowed. Use one of: ${p.values.join(', ')}.`);
|
|
134
|
+
}
|
|
135
|
+
if (c.contract.requiresAccessibleName && !errors.length) warnings.push('This control has no visible text of its own: keep its accessible-name prop set.');
|
|
136
|
+
return text({ component: c.name, ok: errors.length === 0, errors, warnings });
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
get_guide: {
|
|
140
|
+
description: 'The AGENTS.md guide (principles, token rules, when nothing fits, glass, icons, per-component "why / when not to use", checklist). Pass a section number or heading text to get just that section; omit for the section list.',
|
|
141
|
+
inputSchema: { type: 'object', properties: { section: { type: 'string' } } },
|
|
142
|
+
run({ section }) {
|
|
143
|
+
if (!guide) return fail('AGENTS.md is not available in this install.');
|
|
144
|
+
const parts = guide.split(/^(?=## )/m);
|
|
145
|
+
const heads = parts.filter((p) => p.startsWith('## ')).map((p) => p.split('\n')[0].replace(/^## /, ''));
|
|
146
|
+
if (!section) return text({ sections: heads, hint: 'Call again with a number (e.g. "5") or heading text.' });
|
|
147
|
+
const s = String(section).toLowerCase().trim();
|
|
148
|
+
const hit = parts.filter((p) => p.startsWith('## ')).filter((p) => { const h = p.split('\n')[0].replace(/^## /, '').toLowerCase(); return h === s || h.startsWith(s + '.') || h.startsWith(s + ' ') || h.includes(s); });
|
|
149
|
+
return hit.length ? text(hit.join('\n')) : fail(`No section "${section}". Sections: ${heads.join(' | ')}`);
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/* ---------- JSON-RPC over stdio ---------- */
|
|
155
|
+
const send = (msg) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...msg }) + '\n');
|
|
156
|
+
|
|
157
|
+
function handle(msg) {
|
|
158
|
+
const { id, method, params } = msg;
|
|
159
|
+
const isRequest = id !== undefined && id !== null;
|
|
160
|
+
try {
|
|
161
|
+
switch (method) {
|
|
162
|
+
case 'initialize':
|
|
163
|
+
return send({ id, result: {
|
|
164
|
+
protocolVersion: params?.protocolVersion || '2025-06-18',
|
|
165
|
+
capabilities: { tools: {} },
|
|
166
|
+
serverInfo: { name: 'sakaniui', version: manifest.version },
|
|
167
|
+
instructions: `SakaniUI ${manifest.version}. Look components up with search_components / get_component before writing JSX, validate with check_usage, use only tokens from get_tokens, and read get_guide when nothing fits.`,
|
|
168
|
+
} });
|
|
169
|
+
case 'ping':
|
|
170
|
+
return send({ id, result: {} });
|
|
171
|
+
case 'tools/list':
|
|
172
|
+
return send({ id, result: { tools: Object.entries(tools).map(([name, t]) => ({ name, description: t.description, inputSchema: t.inputSchema })) } });
|
|
173
|
+
case 'tools/call': {
|
|
174
|
+
const t = tools[params?.name];
|
|
175
|
+
if (!t) return send({ id, error: { code: -32602, message: `Unknown tool: ${params?.name}` } });
|
|
176
|
+
return send({ id, result: t.run(params.arguments || {}) });
|
|
177
|
+
}
|
|
178
|
+
default:
|
|
179
|
+
if (isRequest) send({ id, error: { code: -32601, message: `Method not found: ${method}` } });
|
|
180
|
+
}
|
|
181
|
+
} catch (e) {
|
|
182
|
+
if (isRequest) send({ id, error: { code: -32603, message: String(e?.message || e) } });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
readline.createInterface({ input: process.stdin }).on('line', (line) => {
|
|
187
|
+
if (!line.trim()) return;
|
|
188
|
+
let msg;
|
|
189
|
+
try { msg = JSON.parse(line); } catch { return send({ id: null, error: { code: -32700, message: 'Parse error' } }); }
|
|
190
|
+
handle(msg);
|
|
191
|
+
});
|
|
@@ -3,7 +3,7 @@ import { IconComponent } from '../../icons/types';
|
|
|
3
3
|
export type IconButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive';
|
|
4
4
|
export type IconButtonSize = 'sm' | 'md' | 'lg';
|
|
5
5
|
export interface IconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
6
|
-
/** Lucide
|
|
6
|
+
/** Icon component (Tabler or Lucide), passed as the component itself, e.g. icon={IconSettings}. */
|
|
7
7
|
icon: IconComponent;
|
|
8
8
|
variant?: IconButtonVariant;
|
|
9
9
|
size?: IconButtonSize;
|