@sakaniui/react 0.8.8 → 0.8.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.
package/AGENTS.md CHANGED
@@ -16,8 +16,14 @@ import manifest from '@sakaniui/react/manifest.json';
16
16
  const button = manifest.components.find((c) => c.name === 'Button'); // props[], contract.requiredProps
17
17
  ```
18
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
- `check_compatibility`, `get_changelog`. If it is connected, call `check_usage` on every component you write.
19
+ (`npx -y -p @sakaniui/react sakaniui-mcp`): `search_components`, `get_component`, `search_icons`,
20
+ `check_code`, `check_usage`, `get_tokens`, `get_guide`, `check_compatibility`, `get_changelog`.
21
+
22
+ **Start from the example, check before you answer.** Every component has `example`: its imports and the
23
+ element with each required prop filled in, type-checked against the release. Start from it instead of
24
+ writing the element from memory. Never guess an icon name: find it with `search_icons` (icons are
25
+ `IconTrash`, `IconSettings`, ... from `@sakaniui/react/icons`, passed as `icon={IconTrash}`, not
26
+ `icon={<IconTrash />}`). If the server is connected, run `check_code` on every snippet you write.
21
27
 
22
28
  **Versions.** Every component has `since`, the first release that contained it. The project may have an
23
29
  older `@sakaniui/react` installed than the one the manifest describes: if an entry is marked
package/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  All notable changes to `@sakaniui/react` are documented here.
4
4
 
5
+ ## 0.8.9
6
+
7
+ Stops assistants from inventing code. Found by testing the MCP server in Claude Code: it validated a
8
+ snippet correctly, then wrote its own example as `icon={<TrashIcon />}` -- an icon that does not exist,
9
+ passed as an element instead of a component.
10
+
11
+ - **Examples.** Every component and block in `manifest.json` has `example`: the import lines and the
12
+ element with each required prop filled in with a value of the right shape (an array of `BreadcrumbItem`
13
+ gets one real item). Prop types are read from the shipped `.d.ts`, and every example is type-checked
14
+ against the built package; the build fails if one does not compile.
15
+ - **Icon index.** `@sakaniui/react/icon-index.json` lists all 5,184 icons (and their 1,054 filled
16
+ variants) with Tabler's category and search tags. The build fails if it differs from the exports.
17
+ - **MCP: `search_icons`.** Finds real icon names by meaning ("delete" -> `IconTrash`); common UI intents
18
+ rank the conventional icon first.
19
+ - **MCP: `check_code`.** Checks a whole snippet as text (it is never run): imports must exist and come
20
+ from the right entry point, every SakaniUI element needs its required props, values must be allowed,
21
+ and icon props must be real icon components passed as `icon={IconTrash}`. Errors carry line numbers
22
+ and suggestions ("TrashIcon is not exported... Did you mean: IconTrash?").
23
+ - **MCP: `check_usage`** now validates icon props too, and `get_component` points at the example and
24
+ at `search_icons`.
25
+ - Fixed a type error in `KanbanBoardBlock` (drag attributes when dragging is disabled) that was printed
26
+ during every build.
27
+
5
28
  ## 0.8.8
6
29
 
7
30
  Audit of the agent contract and MCP server (accuracy, security, versioning).
package/README.md CHANGED
@@ -115,18 +115,19 @@ up instead of guessing prop names.
115
115
 
116
116
  Claude Code: `claude mcp add sakaniui -- npx -y -p @sakaniui/react sakaniui-mcp`
117
117
 
118
- Tools: `search_components`, `get_component`, `list_components`, `get_tokens`, `check_usage`
119
- (validates props against the contract), `get_guide`, `check_compatibility` and `get_changelog`.
118
+ Tools: `search_components`, `get_component` (with a type-checked `example`), `search_icons`,
119
+ `check_code` (checks a whole snippet: imports, required props, values, icon names), `check_usage`,
120
+ `list_components`, `get_tokens`, `get_guide`, `check_compatibility` and `get_changelog`.
120
121
 
121
- - **Read-only.** It reads four fixed files from a `@sakaniui/react` install (package.json, the
122
- manifest, AGENTS.md, CHANGELOG.md). It writes nothing, runs nothing, opens no network connection and
122
+ - **Read-only.** It reads five fixed files from a `@sakaniui/react` install (package.json, the
123
+ manifest, the icon index, AGENTS.md, CHANGELOG.md). Code given to `check_code` is read as text, never run. It writes nothing, runs nothing, opens no network connection and
123
124
  takes no file paths. Every tool is marked read-only.
124
125
  - **Matches your version.** It finds the `@sakaniui/react` installed in your project (working directory,
125
126
  or `--project <dir>`) and flags components your version does not have yet, with the release notes for
126
127
  the versions in between (`get_changelog`).
127
128
  - **Accurate by construction.** `npm run build:lib` fails if the manifest disagrees with the real build:
128
- an export that is not described, a prop that is not declared, a token that is not defined, or anything
129
- that looks like a private reference.
129
+ an export that is not described, a prop that is not declared, an example that does not type-check, an
130
+ icon name that does not exist, a token that is not defined, or anything that looks like a private reference.
130
131
 
131
132
  ## Glass & liquid glass
132
133
 
@@ -6,9 +6,10 @@
6
6
  *
7
7
  * Zero dependencies: speaks newline-delimited JSON-RPC (MCP stdio transport).
8
8
  *
9
- * Read-only by construction. It reads four fixed files (package.json, dist/manifest.json,
10
- * AGENTS.md, CHANGELOG.md) from a @sakaniui/react install and writes nothing, runs nothing and
11
- * opens no network connection. No tool takes a path; every tool argument is validated.
9
+ * Read-only by construction. It reads five fixed files (package.json, dist/manifest.json,
10
+ * dist/icon-index.json, AGENTS.md, CHANGELOG.md) from a @sakaniui/react install and writes nothing,
11
+ * runs nothing and opens no network connection. No tool takes a path; every argument is validated.
12
+ * Code passed to check_code is only read as text, never executed.
12
13
  *
13
14
  * Version-aware. `npx` may fetch a newer release than the project uses, so the server looks for
14
15
  * the project's own installed @sakaniui/react (walking up from --project or the working
@@ -30,7 +31,7 @@ const readJson = (root, rel) => { try { return JSON.parse(readText(root, rel));
30
31
  function loadInstall(root) {
31
32
  const pkg = readJson(root, 'package.json');
32
33
  if (!pkg || pkg.name !== '@sakaniui/react') return null;
33
- return { root, version: pkg.version, manifest: readJson(root, 'dist/manifest.json'), guide: readText(root, 'AGENTS.md'), changelog: readText(root, 'CHANGELOG.md') };
34
+ return { root, version: pkg.version, manifest: readJson(root, 'dist/manifest.json'), icons: readJson(root, 'dist/icon-index.json'), guide: readText(root, 'AGENTS.md'), changelog: readText(root, 'CHANGELOG.md') };
34
35
  }
35
36
 
36
37
  function findProjectInstall() {
@@ -64,6 +65,7 @@ const active = project && project.manifest && schemaOf(project) >= schemaOf(bund
64
65
  const manifest = active.manifest;
65
66
  const guide = active.guide || bundled.guide || '';
66
67
  const changelog = active.changelog || bundled.changelog || '';
68
+ const iconIndex = (active.icons && active.icons.icons) || (bundled.icons && bundled.icons.icons) || [];
67
69
  const projectVersion = project ? project.version : null;
68
70
 
69
71
  /* ---------- versions ---------- */
@@ -152,6 +154,73 @@ function closest(name) {
152
154
  .map(([, c]) => c);
153
155
  }
154
156
 
157
+ /* ---------- icons ---------- */
158
+ const iconNames = new Set();
159
+ for (const i of iconIndex) { iconNames.add(i.name); if (i.filled) iconNames.add(i.filled); }
160
+ const iconWords = (name) => name.replace(/^Icon/, '').replace(/Filled$/, '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/([A-Za-z])(\d)/g, '$1 $2').toLowerCase();
161
+
162
+ // The conventional icon for common UI intents, ranked first (tags alone rank e.g. IconHttpDelete above IconTrash for "delete").
163
+ const COMMON = {
164
+ delete: ['IconTrash'], remove: ['IconTrash', 'IconX'], trash: ['IconTrash'], bin: ['IconTrash'], edit: ['IconPencil', 'IconEdit'], rename: ['IconPencil'],
165
+ close: ['IconX'], dismiss: ['IconX'], cancel: ['IconX'], add: ['IconPlus'], new: ['IconPlus'], create: ['IconPlus'], plus: ['IconPlus'],
166
+ search: ['IconSearch'], find: ['IconSearch'], settings: ['IconSettings', 'IconAdjustments'], preferences: ['IconSettings'], menu: ['IconMenu2'],
167
+ more: ['IconDots', 'IconDotsVertical'], options: ['IconDots', 'IconDotsVertical'], user: ['IconUser'], profile: ['IconUser', 'IconUserCircle'], account: ['IconUserCircle'],
168
+ home: ['IconHome'], back: ['IconArrowLeft', 'IconChevronLeft'], next: ['IconArrowRight', 'IconChevronRight'], forward: ['IconArrowRight'], previous: ['IconArrowLeft', 'IconChevronLeft'],
169
+ expand: ['IconChevronDown'], collapse: ['IconChevronUp'], dropdown: ['IconChevronDown'], upload: ['IconUpload'], download: ['IconDownload'], share: ['IconShare'],
170
+ copy: ['IconCopy'], duplicate: ['IconCopy'], save: ['IconDeviceFloppy'], filter: ['IconFilter'], sort: ['IconArrowsSort'], calendar: ['IconCalendar'], date: ['IconCalendar'],
171
+ notification: ['IconBell'], notifications: ['IconBell'], bell: ['IconBell'], mail: ['IconMail'], email: ['IconMail'], message: ['IconMessage'], chat: ['IconMessageCircle'],
172
+ phone: ['IconPhone'], call: ['IconPhone'], logout: ['IconLogout'], signout: ['IconLogout'], login: ['IconLogin'], help: ['IconHelpCircle', 'IconHelp'], info: ['IconInfoCircle'],
173
+ warning: ['IconAlertTriangle'], error: ['IconAlertCircle'], success: ['IconCircleCheck'], check: ['IconCheck'], done: ['IconCheck'], favorite: ['IconHeart', 'IconStar'],
174
+ like: ['IconHeart', 'IconThumbUp'], star: ['IconStar'], rating: ['IconStar'], cart: ['IconShoppingCart'], refresh: ['IconRefresh'], reload: ['IconRefresh'], link: ['IconLink'],
175
+ attach: ['IconPaperclip'], attachment: ['IconPaperclip'], lock: ['IconLock'], password: ['IconLock'], view: ['IconEye'], show: ['IconEye'], hide: ['IconEyeOff'],
176
+ play: ['IconPlayerPlay'], pause: ['IconPlayerPause'], stop: ['IconPlayerStop'], theme: ['IconSun', 'IconMoon'], dark: ['IconMoon'], light: ['IconSun'],
177
+ external: ['IconExternalLink'], drag: ['IconGripVertical'], sidebar: ['IconLayoutSidebar'], dashboard: ['IconLayoutDashboard'], analytics: ['IconChartBar'], chart: ['IconChartBar'],
178
+ money: ['IconCurrencyDollar'], payment: ['IconCreditCard'], card: ['IconCreditCard'], location: ['IconMapPin'], time: ['IconClock'], clock: ['IconClock'], file: ['IconFile'],
179
+ folder: ['IconFolder'], image: ['IconPhoto'], photo: ['IconPhoto'], video: ['IconVideo'],
180
+ };
181
+
182
+ function searchIcons(query, limit = 12) {
183
+ const terms = String(query).toLowerCase().replace(/icon/g, ' ').split(/[^a-z0-9]+/).filter(Boolean);
184
+ if (!terms.length) return [];
185
+ const boosted = new Map();
186
+ for (const t of terms) (COMMON[t] || []).forEach((n, k) => boosted.set(n, Math.max(boosted.get(n) || 0, 40 - k * 5)));
187
+ return iconIndex
188
+ .map((i) => {
189
+ const words = iconWords(i.name).split(' ');
190
+ const tags = (i.tags || []).map((t) => t.toLowerCase());
191
+ let s = boosted.get(i.name) || 0;
192
+ for (const t of terms) {
193
+ if (words.join(' ') === t) s += 30;
194
+ else if (words[0] === t) s += 12;
195
+ else if (words.includes(t)) s += 8;
196
+ else if (words.some((w) => w.startsWith(t))) s += 4;
197
+ if (tags.includes(t)) s += 6;
198
+ else if (tags.some((g) => g.includes(t))) s += 2;
199
+ if ((i.category || '').toLowerCase() === t) s += 2;
200
+ }
201
+ return [s - words.length * 0.1, i];
202
+ })
203
+ .filter(([s]) => s > 0.5)
204
+ .sort((a, b) => b[0] - a[0])
205
+ .slice(0, limit)
206
+ .map(([, i]) => i);
207
+ }
208
+
209
+ /** Checks a value given for an icon prop. Returns an error string, or null. */
210
+ function iconProblem(propName, raw, importedElsewhere = new Set()) {
211
+ const v = String(raw).trim().replace(/^\{|\}$/g, '').trim();
212
+ if (/^</.test(v)) {
213
+ const tag = (/^<\s*([A-Za-z][\w.]*)/.exec(v) || [])[1] || 'Icon';
214
+ const real = iconNames.has(tag) || importedElsewhere.has(tag) ? tag : (searchIcons(tag, 1)[0] || {}).name;
215
+ const unknown = real && real !== tag ? ` ${tag} is not a SakaniUI icon either.` : '';
216
+ return `"${propName}" takes the icon component itself, not an element: write ${propName}={${real || 'IconName'}}, not ${propName}={<${tag} />}.${unknown}${!real ? ' Use search_icons to find the real name.' : ''}`;
217
+ }
218
+ if (!/^[A-Za-z_$][\w$]*$/.test(v)) return null; // an expression we cannot judge
219
+ if (iconNames.has(v) || importedElsewhere.has(v)) return null;
220
+ const hint = searchIcons(v, 3).map((i) => i.name);
221
+ return `"${v}" is not a SakaniUI icon.${hint.length ? ` Did you mean: ${hint.join(', ')}?` : ''} Use search_icons to find the real name.`;
222
+ }
223
+
155
224
  const NATIVE = /^(className|style|id|ref|key|children|role|tabIndex|title|hidden|lang|dir|slot|on[A-Z].*|aria-.*|data-.*)$/;
156
225
 
157
226
  /** Release notes: [{version, body}] newest first, from the CHANGELOG that matches the answering install. */
@@ -162,6 +231,189 @@ function releases() {
162
231
  return out.sort((a, b) => cmp(b.version, a.version));
163
232
  }
164
233
 
234
+ /* ---------- check_code: a small, read-only JSX scanner (the code is never executed) ---------- */
235
+ /** Line number lookup in O(log n): a precomputed index of line starts per code string. */
236
+ function lineIndex(code) {
237
+ const starts = [0];
238
+ for (let i = 0; i < code.length; i++) if (code.charCodeAt(i) === 10) starts.push(i + 1);
239
+ return (pos) => { let lo = 0, hi = starts.length - 1; while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (starts[mid] <= pos) lo = mid; else hi = mid - 1; } return lo + 1; };
240
+ }
241
+ let lineAt = (code, i) => code.slice(0, i).split('\n').length;
242
+
243
+ function parseImports(code) {
244
+ const out = [];
245
+ const re = /import\s+(type\s+)?(?:([A-Za-z_$][\w$]*)\s*,?\s*)?(?:\{([^}]*)\})?\s*from\s*['"]([^'"]+)['"]/g;
246
+ let m;
247
+ while ((m = re.exec(code))) {
248
+ const names = (m[3] || '').split(',').map((x) => x.trim()).filter(Boolean).map((x) => {
249
+ const typeOnly = /^type\s+/.test(x);
250
+ const [imported, local] = x.replace(/^type\s+/, '').split(/\s+as\s+/).map((y) => y.trim());
251
+ return { imported, local: local || imported, typeOnly: typeOnly || !!m[1] };
252
+ });
253
+ out.push({ from: m[4], names, line: lineAt(code, m.index), def: m[2] || null });
254
+ }
255
+ return out;
256
+ }
257
+
258
+ /** Reads a balanced {...} starting at code[i] === '{'. Returns the index after the closing brace. */
259
+ function skipBraces(code, i) {
260
+ let depth = 0;
261
+ for (; i < code.length; i++) {
262
+ const ch = code[i];
263
+ if (ch === '"' || ch === "'" || ch === '`') {
264
+ const q = ch;
265
+ for (i++; i < code.length && code[i] !== q; i++) if (code[i] === '\\') i++;
266
+ continue;
267
+ }
268
+ if (ch === '{') depth++;
269
+ else if (ch === '}') { depth--; if (depth === 0) return i + 1; }
270
+ }
271
+ return code.length;
272
+ }
273
+
274
+ const ATTR_NAME = /[A-Za-z_][\w:.-]*/y;
275
+
276
+ function parseElements(code, wanted) {
277
+ const els = [];
278
+ const re = /<([A-Z][\w]*(?:\.[A-Z][\w]*)?)(?=[\s/>])/g;
279
+ let m;
280
+ while ((m = re.exec(code))) {
281
+ const tag = m[1];
282
+ if (!wanted(tag)) continue;
283
+ let i = m.index + m[0].length;
284
+ const attrs = [];
285
+ let spread = false;
286
+ let selfClosing = false;
287
+ // every pass must move forward; an unterminated attribute ends the element instead of looping
288
+ for (let guard = 0; i < code.length && guard < 5000; guard++) {
289
+ while (/\s/.test(code[i])) i++;
290
+ if (code[i] === '/' && code[i + 1] === '>') { selfClosing = true; i += 2; break; }
291
+ if (code[i] === '>') { i++; break; }
292
+ if (code[i] === '<') break; // a new tag before this one closed: malformed, stop here
293
+ if (code[i] === '{') { const end = skipBraces(code, i); if (/^\{\s*\.\.\./.test(code.slice(i, end))) spread = true; i = end; continue; }
294
+ ATTR_NAME.lastIndex = i;
295
+ const nm = ATTR_NAME.exec(code);
296
+ if (!nm) { i++; continue; }
297
+ const name = nm[0];
298
+ i += name.length;
299
+ while (/\s/.test(code[i])) i++;
300
+ if (code[i] !== '=') { attrs.push({ name, kind: 'boolean', value: true }); continue; }
301
+ i++;
302
+ while (/\s/.test(code[i])) i++;
303
+ if (code[i] === '"' || code[i] === "'") {
304
+ const q = code[i];
305
+ const end = code.indexOf(q, i + 1);
306
+ if (end === -1) { attrs.push({ name, kind: 'string', value: code.slice(i + 1) }); i = code.length; break; }
307
+ attrs.push({ name, kind: 'string', value: code.slice(i + 1, end) });
308
+ i = end + 1;
309
+ } else if (code[i] === '{') {
310
+ const end = skipBraces(code, i);
311
+ const expr = code.slice(i + 1, end - 1).trim();
312
+ attrs.push({ name, ...classify(expr) });
313
+ i = end;
314
+ }
315
+ }
316
+ els.push({ tag, attrs, spread, selfClosing, line: lineAt(code, m.index) });
317
+ if (els.length >= 5000) break;
318
+ re.lastIndex = Math.max(re.lastIndex, i);
319
+ }
320
+ return els;
321
+ }
322
+
323
+ function classify(expr) {
324
+ let m;
325
+ if ((m = /^(['"])([\s\S]*)\1$/.exec(expr))) return { kind: 'string', value: m[2], raw: expr };
326
+ if ((m = /^`([^`$]*)`$/.exec(expr))) return { kind: 'string', value: m[1], raw: expr };
327
+ if (/^-?\d+(\.\d+)?$/.test(expr)) return { kind: 'number', value: Number(expr), raw: expr };
328
+ if (expr === 'true' || expr === 'false') return { kind: 'boolean', value: expr === 'true', raw: expr };
329
+ if (/^</.test(expr)) return { kind: 'element', raw: expr };
330
+ if (/^[A-Za-z_$][\w$]*$/.test(expr)) return { kind: 'identifier', value: expr, raw: expr };
331
+ return { kind: 'expression', raw: expr };
332
+ }
333
+
334
+ const TYPE_LIKE = /(Props|Type|Variant|Size|Item|Option|Datum|Column|Link|Plan|Post|Action|Step|Image|Author|Change|Data|Tab|Color|Category|Job|Member|Testimonial|Transaction)$/;
335
+
336
+ function checkCode(code) {
337
+ lineAt = ((idx) => (_c, pos) => idx(pos))(lineIndex(code));
338
+ const errors = [];
339
+ const warnings = [];
340
+ const imports = parseImports(code);
341
+ const importedFrom = new Map(); // local name -> module
342
+ for (const imp of imports) for (const n of imp.names) importedFrom.set(n.local, imp.from);
343
+ const importedElsewhere = new Set([...importedFrom].filter(([, from]) => !from.startsWith('@sakaniui/react')).map(([n]) => n));
344
+
345
+ for (const imp of imports) {
346
+ if (!imp.from.startsWith('@sakaniui/react')) continue;
347
+ const entry = imp.from;
348
+ if (!['@sakaniui/react', '@sakaniui/react/blocks', '@sakaniui/react/icons'].includes(entry)) {
349
+ if (imp.names.length) errors.push({ line: imp.line, message: `"${entry}" has no named exports. Import components from @sakaniui/react, blocks from @sakaniui/react/blocks and icons from @sakaniui/react/icons.` });
350
+ continue;
351
+ }
352
+ if (imp.def) errors.push({ line: imp.line, message: `${entry} has no default export; use a named import: import { ${imp.def} } from '${entry}';` });
353
+ for (const n of imp.names) {
354
+ if (n.typeOnly) continue;
355
+ const c = byName.get(n.imported.toLowerCase());
356
+ const isUtility = manifest.utilities?.some((u) => u.name === n.imported);
357
+ if (entry === '@sakaniui/react/icons') {
358
+ if (!iconNames.has(n.imported)) {
359
+ const hint = searchIcons(n.imported, 3).map((i) => i.name);
360
+ errors.push({ line: imp.line, message: `${n.imported} is not exported by @sakaniui/react/icons.${hint.length ? ` Did you mean: ${hint.join(', ')}?` : ''}` });
361
+ }
362
+ continue;
363
+ }
364
+ if (c && c.name !== n.imported) { errors.push({ line: imp.line, message: `${n.imported} has the wrong casing; the export is ${c.name}.` }); continue; }
365
+ if (entry === '@sakaniui/react/blocks') {
366
+ if (!c || c.kind !== 'block') errors.push({ line: imp.line, message: c ? `${n.imported} is a component: import it from @sakaniui/react.` : `${n.imported} is not a SakaniUI block.` });
367
+ continue;
368
+ }
369
+ if (c && c.kind === 'block') { errors.push({ line: imp.line, message: `${n.imported} is a block: import it from @sakaniui/react/blocks.` }); continue; }
370
+ if (!c && !isUtility) {
371
+ if (TYPE_LIKE.test(n.imported)) continue; // most likely a type export
372
+ const near = closest(n.imported);
373
+ errors.push({ line: imp.line, message: `${n.imported} is not exported by @sakaniui/react.${near.length ? ` Did you mean: ${near.join(', ')}?` : ''} Use search_components.` });
374
+ }
375
+ if (c && inInstalled(c) === false) errors.push({ line: imp.line, message: `${c.name} was added in ${c.since}; the project has ${projectVersion}.` });
376
+ }
377
+ }
378
+
379
+ // elements: anything imported from @sakaniui, plus manifest names used without an import
380
+ const isSakani = (tag) => importedFrom.get(tag)?.startsWith('@sakaniui/react') || (!importedFrom.has(tag) && byName.has(tag.toLowerCase()) && byName.get(tag.toLowerCase()).name === tag);
381
+ const elements = parseElements(code, isSakani);
382
+ for (const el of elements) {
383
+ const c = byName.get(el.tag.toLowerCase());
384
+ if (!c) continue;
385
+ const at = (message) => ({ line: el.line, message: `<${el.tag}>: ${message}` });
386
+ if (!importedFrom.has(el.tag)) warnings.push(at(`not imported. Add: import { ${c.name} } from '${c.import}';`));
387
+ const given = new Map(el.attrs.map((a) => [a.name, a]));
388
+ if (!el.selfClosing) given.set('children', { kind: 'expression' });
389
+ for (const r of c.contract.requiredProps) {
390
+ if (given.has(r)) continue;
391
+ if (el.spread) warnings.push(at(`required prop "${r}" is not written out (it may come from the spread).`));
392
+ else errors.push(at(`missing required prop "${r}".`));
393
+ }
394
+ const known = new Map(c.props.map((p) => [p.name, p]));
395
+ for (const a of el.attrs) {
396
+ const p = known.get(a.name);
397
+ if (!p) { if (!NATIVE.test(a.name) && a.name !== 'key' && a.name !== 'ref') warnings.push(at(`"${a.name}" is not a ${c.name} prop (it only works if it is a native attribute).`)); continue; }
398
+ if (p.values && ['string', 'number', 'boolean'].includes(a.kind) && !p.values.includes(a.value)) {
399
+ errors.push(at(`${a.name}=${JSON.stringify(a.value)} is not allowed. Use one of: ${p.values.map((x) => JSON.stringify(x)).join(', ')}.`));
400
+ }
401
+ if (p.type === 'IconComponent') {
402
+ if (a.kind === 'string') errors.push(at(`"${a.name}" takes an icon component, not a string: ${a.name}={IconName}.`));
403
+ else if (a.kind === 'element' || a.kind === 'identifier') {
404
+ const e = iconProblem(a.name, a.raw, importedElsewhere);
405
+ if (e) errors.push(at(e));
406
+ else if (a.kind === 'identifier' && iconNames.has(a.value) && importedFrom.get(a.value) !== '@sakaniui/react/icons') warnings.push(at(`${a.value} is not imported. Add: import { ${a.value} } from '@sakaniui/react/icons';`));
407
+ }
408
+ }
409
+ }
410
+ }
411
+ if (!elements.length && !imports.some((i) => i.from.startsWith('@sakaniui/react'))) warnings.push({ line: 1, message: 'No SakaniUI imports or elements found in this code.' });
412
+ const out = { ok: errors.length === 0, checkedElements: elements.length, errors, warnings };
413
+ const note = versionNote(); if (note) out.versionNote = note;
414
+ return out;
415
+ }
416
+
165
417
  const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
166
418
 
167
419
  /* ---------- tools ---------- */
@@ -194,6 +446,8 @@ const tools = {
194
446
  const c = byName.get(n.value.toLowerCase());
195
447
  if (!c) return fail(`No component named "${n.value}". Did you mean: ${closest(n.value).join(', ') || '(none close)'}? Use search_components.`);
196
448
  const out = { ...c, note: 'Native HTML attributes (className, style, aria-*, event handlers) are also accepted on the root element.' };
449
+ if (c.example) out.exampleNote = 'Start from `example`: it is type-checked against this release. Replace the placeholder text and values; keep the prop names and shapes.';
450
+ if (c.props.some((p) => p.type === 'IconComponent')) out.iconNote = 'Icon props take the component itself (icon={IconTrash}), not an element. The example uses IconStar as a placeholder: find the real icon with search_icons.';
197
451
  if (inInstalled(c) === false) out.availableInInstalled = false;
198
452
  const note = versionNote();
199
453
  if (note) out.versionNote = note;
@@ -255,6 +509,7 @@ const tools = {
255
509
  if (p.values && (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') && !p.values.includes(v)) {
256
510
  errors.push(`"${k}" = ${JSON.stringify(v)} is not allowed. Use one of: ${p.values.map((x) => JSON.stringify(x)).join(', ')}.`);
257
511
  }
512
+ if (p.type === 'IconComponent' && typeof v === 'string') { const e = iconProblem(k, v); if (e) errors.push(e); }
258
513
  }
259
514
  if (c.contract.requiresAccessibleName && !errors.length) warnings.push('This control has no visible text of its own: keep its accessible-name prop set.');
260
515
  const out = { component: c.name, ok: errors.length === 0, errors, warnings };
@@ -301,6 +556,28 @@ const tools = {
301
556
  return text(pick.map((r) => `## ${r.version}\n\n${r.body}`).join('\n\n'));
302
557
  },
303
558
  },
559
+ search_icons: {
560
+ description: 'Find real icon names (Tabler set, exported from @sakaniui/react/icons) by meaning, e.g. "delete", "settings", "arrow right". Never guess an icon name: only names returned here exist.',
561
+ inputSchema: { type: 'object', properties: { query: { type: 'string' }, limit: { type: 'number' } }, required: ['query'] },
562
+ run(args) {
563
+ const q = strArg(args, 'query', { required: true }); if (q.err) return fail(q.err);
564
+ if (!iconIndex.length) return fail('The icon index is not available in this install.');
565
+ const limit = Number.isInteger(args.limit) ? Math.min(Math.max(args.limit, 1), 30) : 12;
566
+ const hits = searchIcons(q.value, limit).map((i) => { const o = { name: i.name }; if (i.filled) o.filled = i.filled; if (i.category) o.category = i.category; if (i.tags) o.tags = i.tags.slice(0, 5); return o; });
567
+ if (!hits.length) return text(`No icon matches "${q.value}". Try a simpler word (e.g. "delete" rather than "remove item").`);
568
+ return text({ import: "import { IconName } from '@sakaniui/react/icons';", usage: 'icon={IconName} (the component, not <IconName />)', icons: hits });
569
+ },
570
+ },
571
+ check_code: {
572
+ description: 'Check a JSX/TSX snippet you wrote: imports from @sakaniui/react (and /blocks, /icons) must exist, every SakaniUI element must have its required props, prop values must be allowed, and icon props must be real icon components passed as components. The code is only read, never run. Run it on every snippet before you answer.',
573
+ inputSchema: { type: 'object', properties: { code: { type: 'string', description: 'The snippet, including its import lines.' } }, required: ['code'] },
574
+ run(args) {
575
+ const code = args.code;
576
+ if (typeof code !== 'string' || !code.trim()) return fail('"code" must be a non-empty string.');
577
+ if (code.length > 50000) return fail('"code" is too long (max 50,000 characters).');
578
+ return text(checkCode(code));
579
+ },
580
+ },
304
581
  get_guide: {
305
582
  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.',
306
583
  inputSchema: { type: 'object', properties: { section: { type: 'string' } } },