@sakaniui/react 0.8.9 → 0.8.11

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,9 +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`): `search_components`, `get_component`, `search_icons`,
19
+ (`npx -y @sakaniui/mcp`): `search_components`, `get_component`, `search_icons`,
20
20
  `check_code`, `check_usage`, `get_tokens`, `get_guide`, `check_compatibility`, `get_changelog`.
21
21
 
22
+ **Object props have a shape.** A prop such as `columns`, `items`, `options`, `plans` or `data` takes an array
23
+ of objects with exactly the fields listed under the prop's `shape` (required ones must be present, and
24
+ `keyof T` means "a field of your own data type"). `check_code` verifies the field names of inline literals.
25
+ Components with a pattern the types do not teach carry `recipes`: code that compiles.
26
+
22
27
  **Start from the example, check before you answer.** Every component has `example`: its imports and the
23
28
  element with each required prop filled in, type-checked against the release. Start from it instead of
24
29
  writing the element from memory. Never guess an icon name: find it with `search_icons` (icons are
@@ -206,7 +211,11 @@ Format: **Use for** · **Why it is built this way** · **Not for / instead**.
206
211
  ### Content and data
207
212
  - **Card** — a container surface for a unit of content, optionally with actions. Not a layout wrapper for
208
213
  everything.
209
- - **Table** — dense, comparable records. **StatCard** — one headline metric with trend. **ListItem** — a
214
+ - **Table** — dense, comparable records. `Table<T>` is generic: every column's `key` must be a field of
215
+ your row type `T` (`keyof T & string`), so a column that is not data (a delete button, a menu) still
216
+ needs a key: give the row type an optional `never` field (`actions?: never`) and render the control in
217
+ `render`. Inventing a key that is not on the row type does not compile (see `recipes` in
218
+ `get_component("Table")` for code that does). **StatCard** — one headline metric with trend. **ListItem** — a
210
219
  row in a list. **Charts** — pick by question: trend over time → Line/Area; compare categories → Bar;
211
220
  part of whole → Pie/Donut (few slices only); progress to a target → Radial; compare several measures →
212
221
  Radar; drop-off → Funnel; intensity across two dimensions → Heatmap.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,66 @@
2
2
 
3
3
  All notable changes to `@sakaniui/react` are documented here.
4
4
 
5
+ ## 0.8.11
6
+
7
+ Found by asking Claude Code, with the published 0.8.10 MCP server, to add a delete button to a table row:
8
+ every tool call was correct and `IconButton` was used properly, but it put the button in a new `Table`
9
+ column with `key: 'actions'`, which does not compile (`key` must be a field of the row type). The
10
+ contract only said `columns: TableColumn<T>[]`.
11
+
12
+ - **Object props now have a `shape`** in `manifest.json`: the fields of `TableColumn<T>`, `BreadcrumbItem`,
13
+ `PricingPlan`, `SelectOption`, chart data and 58 more (63 props), read from the shipped type
14
+ definitions, generics kept as declared (`key: keyof T & string`).
15
+ - **`recipes`**: code for patterns the types do not teach, compiled on every build. First one: a `Table`
16
+ column that is not data (a delete button, a menu). The build also checks the opposite, that a column
17
+ key which is not on the row type really does not compile.
18
+ - **MCP `check_code` checks inline object literals** against the shape: an unknown field (`title` for
19
+ `label`, `pct` for `percent`, with a suggestion), a missing required field (with the item index), and,
20
+ for a `Table`, a column `key` that is not a field of the `rows` written next to it. Spreads, identifiers
21
+ and anything it cannot read are left alone (0 errors on 685 real elements in three apps).
22
+ - `AGENTS.md` explains the generic `Table` rule and the new `shape` and `recipes`.
23
+
24
+ ## 0.8.10
25
+
26
+ Readiness release: everything below was found by testing the package the way a new user, an editor
27
+ and an AI agent would, and every fix is now covered by a check that fails the build.
28
+
29
+ **Installing the library**
30
+ - **Fixed: installing `@sakaniui/react` installed Next.js.** The `geist` and `@fontsource-variable/geist`
31
+ font packages were runtime dependencies, though the library never loads them, and `geist` requires
32
+ Next.js as a peer, so every app (Vite included) got Next.js 16. Both are now dev-only; the only runtime
33
+ dependency is `recharts`. Load Geist yourself if you want it (`@fontsource-variable/geist`); otherwise
34
+ the system font is used, as before.
35
+ - **Fixed: missing type definitions.** `dist/lib/` was left out of the generated `.d.ts` files, so
36
+ `LiquidGlass`, `LiquidBackdrop`, `HoverGroup`, `useHoverHighlight`, `useLiquidGlass`, `getContrastColor`
37
+ and `iconStrokeWidth` had no types: `<LiquidBackdrop />` compiled without its required `src`. The build
38
+ now fails if any relative import in a shipped `.d.ts` does not resolve.
39
+ - **Fixed docs: `style.css` is required.** The README and the website's Installation page only imported
40
+ `tokens.css`, which leaves components unstyled. Both now import both stylesheets.
41
+
42
+ **The MCP server**
43
+ - **New package `@sakaniui/mcp`** (about 190 kB, no dependencies): `npx -y @sakaniui/mcp` starts in
44
+ about 2 s on a first run. Through `@sakaniui/react` it took ~30 s and ~675 MB, long enough for an editor
45
+ to give up. The copy in `@sakaniui/react` still works.
46
+ - **`check_code`: no false positives** on 685 real elements across three apps (it reported 77). Aliased
47
+ and default imports, code inside template strings, native attributes and the non-icon exports of
48
+ `@sakaniui/react/icons` are handled; it still catches every deliberate mistake in the test set.
49
+ - **Versions per prop and per value.** Each prop added after its component has its own `since` (for
50
+ example `Switch.variant`, 0.8.4), and values added later are listed in `valuesSince`. `since` now means
51
+ "first release you could import it", taken from the package's public exports at every release (the
52
+ social icons are 0.8.6, not 0.3.0). `check_code`, `check_usage` and `get_component` flag props and
53
+ values the project's installed version does not have.
54
+ - **Invented props are errors.** A prop that is neither the component's own nor an HTML attribute (React's
55
+ own attribute list now ships in `manifest.json` as `nativeAttributes`) fails `check_code` and
56
+ `check_usage`, with the real prop list and a suggestion (`leftIcon` -> "did you mean leadingIcon?").
57
+ Real HTML attributes (`placeholder`, `href`, `name`...) pass silently; `color` gets a note that it does
58
+ not style the component.
59
+ - **Protocol:** negotiates a supported protocol version, answers `resources/list`, `prompts/list` and
60
+ batches, exits when the editor disconnects (flushing replies first), and survives a closed pipe.
61
+ `get_guide` accepts `query`. Tested on Node 18, 20, 22 and 26, with the official MCP SDK client and the
62
+ MCP Inspector, and documented for Windows (`cmd /c npx ...`).
63
+ - Removed an unresolvable `$schema` link from `manifest.json`.
64
+
5
65
  ## 0.8.9
6
66
 
7
67
  Stops assistants from inventing code. Found by testing the MCP server in Claude Code: it validated a
package/README.md CHANGED
@@ -43,12 +43,33 @@ accepts Lucide or Tabler React icons. See `THIRD_PARTY_NOTICES.md` for the MIT l
43
43
  npm install @sakaniui/react
44
44
  ```
45
45
 
46
- Then import the tokens once at your app's entry point, and any component from the package root:
46
+ Then import the two stylesheets once, at your app's entry point (both are required: `tokens.css` defines
47
+ the design tokens, `style.css` styles the components), and use any component from the package root:
47
48
 
48
49
  ```tsx
49
50
  import '@sakaniui/react/tokens.css';
51
+ import '@sakaniui/react/style.css';
52
+
53
+ import { Button } from '@sakaniui/react';
54
+
55
+ export function Example() {
56
+ return <Button variant="primary">Get started</Button>;
57
+ }
58
+ ```
59
+
60
+ Requires React 19. Typography is set in **Geist**; load the font yourself if you want it (otherwise the
61
+ system font is used):
62
+
63
+ ```bash
64
+ npm install @fontsource-variable/geist
50
65
  ```
51
66
 
67
+ ```tsx
68
+ import '@fontsource-variable/geist';
69
+ ```
70
+
71
+ Charts (`AreaChart`, `BarChart`, ...) use `recharts`, which is installed with the package.
72
+
52
73
  > **Building with AI?** `AGENTS.md` (shipped in this package) explains *why* components behave as they do and what to do when nothing fits. Index for agents: https://www.sakaniui.com/llms.txt
53
74
 
54
75
  ## Quick start (contributing / browsing the source)
@@ -108,15 +129,20 @@ up instead of guessing prop names.
108
129
  ```json
109
130
  {
110
131
  "mcpServers": {
111
- "sakaniui": { "command": "npx", "args": ["-y", "-p", "@sakaniui/react", "sakaniui-mcp"] }
132
+ "sakaniui": { "command": "npx", "args": ["-y", "@sakaniui/mcp"] }
112
133
  }
113
134
  }
114
135
  ```
115
136
 
116
- Claude Code: `claude mcp add sakaniui -- npx -y -p @sakaniui/react sakaniui-mcp`
137
+ Claude Code: `claude mcp add sakaniui -- npx -y @sakaniui/mcp`
138
+
139
+ On Windows, launch `npx` through `cmd /c` (`"command": "cmd", "args": ["/c", "npx", "-y", "@sakaniui/mcp"]`,
140
+ or `claude mcp add sakaniui -- cmd /c npx -y @sakaniui/mcp`). Needs Node.js 18 or later.
141
+
142
+ The server is its own tiny package, [`@sakaniui/mcp`](https://www.npmjs.com/package/@sakaniui/mcp) (about 1 MB, starts in a couple of seconds; it does not install the component library). The copy bundled in `@sakaniui/react` (`npx -y -p @sakaniui/react sakaniui-mcp`) still works but downloads far more.
117
143
 
118
144
  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`,
145
+ `check_code` (checks a whole snippet: imports, required props, values, icon names, and the field names of object props such as table columns), `check_usage`,
120
146
  `list_components`, `get_tokens`, `get_guide`, `check_compatibility` and `get_changelog`.
121
147
 
122
148
  - **Read-only.** It reads five fixed files from a `@sakaniui/react` install (package.json, the
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * SakaniUI MCP server -- lets an AI editor/agent look up the design system instead of guessing.
4
4
  *
5
- * npx -y -p @sakaniui/react sakaniui-mcp [--project <dir>]
5
+ * npx -y @sakaniui/mcp [--project <dir>] (or: npx -y -p @sakaniui/react sakaniui-mcp)
6
6
  *
7
7
  * Zero dependencies: speaks newline-delimited JSON-RPC (MCP stdio transport).
8
8
  *
@@ -47,7 +47,8 @@ function findProjectInstall() {
47
47
  return null;
48
48
  }
49
49
 
50
- const bundled = loadInstall(ownRoot);
50
+ // inside @sakaniui/react the data sits next to bin/; inside @sakaniui/mcp it is copied into data/
51
+ const bundled = loadInstall(fs.existsSync(path.join(ownRoot, 'dist', 'manifest.json')) ? ownRoot : path.join(ownRoot, 'data'));
51
52
  if (!bundled || !bundled.manifest) {
52
53
  process.stderr.write('sakaniui-mcp: dist/manifest.json not found. Run `npm run build:lib` (or install a published version).\n');
53
54
  process.exit(1);
@@ -68,6 +69,22 @@ const changelog = active.changelog || bundled.changelog || '';
68
69
  const iconIndex = (active.icons && active.icons.icons) || (bundled.icons && bundled.icons.icons) || [];
69
70
  const projectVersion = project ? project.version : null;
70
71
 
72
+ // The HTML attributes React accepts (shipped in the manifest since 0.8.10; older manifests use the fallback list).
73
+ const nativeAttrs = new Set(manifest.nativeAttributes || []);
74
+ const NATIVE = { test: (n) => /^(aria-|data-)/.test(n) || /^on[A-Z]/.test(n) || n === 'key' || n === 'ref' || n === 'children' || (nativeAttrs.size ? nativeAttrs.has(n) : NATIVE_FALLBACK.test(n)) };
75
+ /** What to say about a prop the component does not declare: an error when nothing accepts it, else a note. */
76
+ function unknownProp(c, name) {
77
+ if (NATIVE.test(name)) {
78
+ // a real HTML attribute (placeholder, href, name, defaultChecked...): passed to the element, and fine.
79
+ // `color` is the one people mistake for a styling prop: it does not colour the component.
80
+ return name === 'color' ? { level: 'warning', message: `"color" is not a ${c.name} prop: it is the old HTML colour attribute and does not change the component's colours. Use the component's own props, or design tokens (get_tokens).` } : null;
81
+ }
82
+ // close spellings, plus the usual synonyms (leftIcon -> leadingIcon, onChange vs onValueChange is left to the list)
83
+ const canon = (x) => x.toLowerCase().replace(/left|start|before|prefix/g, 'leading').replace(/right|end|after|suffix/g, 'trailing').replace(/colou?r|tone|kind|type/g, 'variant');
84
+ const near = c.props.map((p) => p.name).filter((p) => similar(p, name) || canon(p) === canon(name));
85
+ return { level: 'error', message: `"${name}" is not a ${c.name} prop${near.length ? ` (did you mean ${near.map((x) => `"${x}"`).join(', ')}?)` : ''}. ${c.name} accepts: ${c.props.map((p) => p.name).join(', ') || 'no props of its own'}.` };
86
+ }
87
+
71
88
  /* ---------- versions ---------- */
72
89
  const SEMVER = /^\d+\.\d+\.\d+/;
73
90
  const parse = (v) => String(v).split('-')[0].split('.').map(Number);
@@ -93,6 +110,15 @@ function inInstalled(c, installed = projectVersion) {
93
110
  return cmp(c.since, installed) <= 0;
94
111
  }
95
112
 
113
+ /** Is this prop (or this value of it) in the project's install? Returns a message when it is not. */
114
+ function propTooNew(c, p, value, installed = projectVersion) {
115
+ if (!installed) return null;
116
+ if (p.since && cmp(p.since, installed) > 0) return `"${p.name}" was added to ${c.name} in ${p.since}; the project has ${installed}, where it does not exist. Upgrade (npm install @sakaniui/react@latest) or leave it out.`;
117
+ const vs = p.valuesSince && typeof value === 'string' ? p.valuesSince[value] : null;
118
+ if (vs && cmp(vs, installed) > 0) return `${p.name}=${JSON.stringify(value)} was added in ${vs}; the project has ${installed}, where it does not exist.`;
119
+ return null;
120
+ }
121
+
96
122
  /* ---------- helpers ---------- */
97
123
  const byName = new Map(manifest.components.map((c) => [c.name.toLowerCase(), c]));
98
124
  const brief = (c) => {
@@ -144,6 +170,16 @@ function distance(a, b) {
144
170
  return d[a.length][b.length];
145
171
  }
146
172
 
173
+ /** `pct` for `percent`, `desc` for `description`: same first letter, every letter in order. */
174
+ function isAbbreviation(short, full) {
175
+ const a = short.toLowerCase(), b = full.toLowerCase();
176
+ if (a.length < 2 || a.length >= b.length || a[0] !== b[0]) return false;
177
+ let i = 0;
178
+ for (const ch of b) if (ch === a[i]) i++;
179
+ return i === a.length;
180
+ }
181
+ const similar = (a, b) => distance(a.toLowerCase(), b.toLowerCase()) <= 2 || isAbbreviation(a, b) || isAbbreviation(b, a);
182
+
147
183
  function closest(name) {
148
184
  const n = name.toLowerCase();
149
185
  return manifest.components
@@ -157,6 +193,7 @@ function closest(name) {
157
193
  /* ---------- icons ---------- */
158
194
  const iconNames = new Set();
159
195
  for (const i of iconIndex) { iconNames.add(i.name); if (i.filled) iconNames.add(i.filled); }
196
+ const iconOtherExports = new Set(((active.icons && active.icons.other) || (bundled.icons && bundled.icons.other)) || []);
160
197
  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
198
 
162
199
  // The conventional icon for common UI intents, ranked first (tags alone rank e.g. IconHttpDelete above IconTrash for "delete").
@@ -207,7 +244,7 @@ function searchIcons(query, limit = 12) {
207
244
  }
208
245
 
209
246
  /** Checks a value given for an icon prop. Returns an error string, or null. */
210
- function iconProblem(propName, raw, importedElsewhere = new Set()) {
247
+ function iconProblem(propName, raw, importedElsewhere = new Set(), localNames = new Set()) {
211
248
  const v = String(raw).trim().replace(/^\{|\}$/g, '').trim();
212
249
  if (/^</.test(v)) {
213
250
  const tag = (/^<\s*([A-Za-z][\w.]*)/.exec(v) || [])[1] || 'Icon';
@@ -216,12 +253,15 @@ function iconProblem(propName, raw, importedElsewhere = new Set()) {
216
253
  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
254
  }
218
255
  if (!/^[A-Za-z_$][\w$]*$/.test(v)) return null; // an expression we cannot judge
219
- if (iconNames.has(v) || importedElsewhere.has(v)) return null;
256
+ if (iconNames.has(v) || importedElsewhere.has(v) || localNames.has(v)) return null;
257
+ // a local name (alias, parameter, constant): the import line was already checked, and we cannot see the rest
258
+ if (!/^Icon[A-Z0-9]/.test(v)) return null;
220
259
  const hint = searchIcons(v, 3).map((i) => i.name);
221
260
  return `"${v}" is not a SakaniUI icon.${hint.length ? ` Did you mean: ${hint.join(', ')}?` : ''} Use search_icons to find the real name.`;
222
261
  }
223
262
 
224
- const NATIVE = /^(className|style|id|ref|key|children|role|tabIndex|title|hidden|lang|dir|slot|on[A-Z].*|aria-.*|data-.*)$/;
263
+ // Attributes every component forwards to its root element. A prop outside this list and the contract is worth a warning.
264
+ const NATIVE_FALLBACK = /^(className|style|id|ref|key|children|role|tabIndex|title|hidden|lang|dir|slot|on[A-Z].*|aria-.*|data-.*|href|target|rel|download|name|type|value|defaultValue|checked|defaultChecked|disabled|required|readOnly|placeholder|htmlFor|form|autoFocus|autoComplete|autoCapitalize|spellCheck|inputMode|pattern|min|max|step|minLength|maxLength|rows|cols|multiple|accept|src|srcSet|alt|width|height|loading|draggable|tabindex|ariaLabel)$/;
225
265
 
226
266
  /** Release notes: [{version, body}] newest first, from the CHANGELOG that matches the answering install. */
227
267
  function releases() {
@@ -333,14 +373,38 @@ function classify(expr) {
333
373
 
334
374
  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
375
 
336
- function checkCode(code) {
376
+ /** Replaces the contents of template literals with spaces (newlines kept): code shown inside a string is not code. */
377
+ function blankTemplates(code) {
378
+ let out = '';
379
+ for (let i = 0; i < code.length; i++) {
380
+ const ch = code[i];
381
+ if (ch === '`') {
382
+ let j = i + 1, depth = 0;
383
+ for (; j < code.length; j++) {
384
+ if (code[j] === '\\') { j++; continue; }
385
+ if (code[j] === '$' && code[j + 1] === '{') { depth++; j++; continue; }
386
+ if (code[j] === '}' && depth > 0) { depth--; continue; }
387
+ if (code[j] === '`' && depth === 0) break;
388
+ }
389
+ out += '`' + code.slice(i + 1, j).replace(/[^\n]/g, ' ') + '`';
390
+ i = j;
391
+ } else out += ch;
392
+ }
393
+ return out;
394
+ }
395
+
396
+ function checkCode(source) {
397
+ const code = blankTemplates(source);
337
398
  lineAt = ((idx) => (_c, pos) => idx(pos))(lineIndex(code));
338
399
  const errors = [];
339
400
  const warnings = [];
340
401
  const imports = parseImports(code);
341
402
  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);
403
+ for (const imp of imports) { for (const n of imp.names) importedFrom.set(n.local, imp.from); if (imp.def) importedFrom.set(imp.def, imp.from); }
343
404
  const importedElsewhere = new Set([...importedFrom].filter(([, from]) => !from.startsWith('@sakaniui/react')).map(([n]) => n));
405
+ // anything the snippet declares itself (const Icon = ..., function X, class Y) or aliases: not ours to judge
406
+ const localNames = new Set([...code.matchAll(/\b(?:const|let|var|function|class)\s+([A-Za-z_$][\w$]*)/g)].map((m) => m[1]));
407
+ for (const imp of imports) for (const n of imp.names) if (n.local !== n.imported) localNames.add(n.local);
344
408
 
345
409
  for (const imp of imports) {
346
410
  if (!imp.from.startsWith('@sakaniui/react')) continue;
@@ -355,7 +419,7 @@ function checkCode(code) {
355
419
  const c = byName.get(n.imported.toLowerCase());
356
420
  const isUtility = manifest.utilities?.some((u) => u.name === n.imported);
357
421
  if (entry === '@sakaniui/react/icons') {
358
- if (!iconNames.has(n.imported)) {
422
+ if (!iconNames.has(n.imported) && !iconOtherExports.has(n.imported)) {
359
423
  const hint = searchIcons(n.imported, 3).map((i) => i.name);
360
424
  errors.push({ line: imp.line, message: `${n.imported} is not exported by @sakaniui/react/icons.${hint.length ? ` Did you mean: ${hint.join(', ')}?` : ''}` });
361
425
  }
@@ -383,6 +447,7 @@ function checkCode(code) {
383
447
  const c = byName.get(el.tag.toLowerCase());
384
448
  if (!c) continue;
385
449
  const at = (message) => ({ line: el.line, message: `<${el.tag}>: ${message}` });
450
+ if (inInstalled(c) === false && !importedFrom.has(el.tag)) errors.push(at(`${c.name} was added in ${c.since}; the project has ${projectVersion}, where it does not exist.`));
386
451
  if (!importedFrom.has(el.tag)) warnings.push(at(`not imported. Add: import { ${c.name} } from '${c.import}';`));
387
452
  const given = new Map(el.attrs.map((a) => [a.name, a]));
388
453
  if (!el.selfClosing) given.set('children', { kind: 'expression' });
@@ -394,16 +459,18 @@ function checkCode(code) {
394
459
  const known = new Map(c.props.map((p) => [p.name, p]));
395
460
  for (const a of el.attrs) {
396
461
  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; }
462
+ if (!p) { const u = unknownProp(c, a.name); if (u) (u.level === 'error' ? errors : warnings).push(at(u.message)); continue; }
398
463
  if (p.values && ['string', 'number', 'boolean'].includes(a.kind) && !p.values.includes(a.value)) {
399
464
  errors.push(at(`${a.name}=${JSON.stringify(a.value)} is not allowed. Use one of: ${p.values.map((x) => JSON.stringify(x)).join(', ')}.`));
400
465
  }
466
+ { const tooNew = propTooNew(c, p, a.kind === 'string' ? a.value : undefined); if (tooNew) errors.push(at(tooNew)); }
467
+ for (const m of checkShape(c, p, a, new Map(el.attrs.map((x) => [x.name, x])))) errors.push(at(m));
401
468
  if (p.type === 'IconComponent') {
402
469
  if (a.kind === 'string') errors.push(at(`"${a.name}" takes an icon component, not a string: ${a.name}={IconName}.`));
403
470
  else if (a.kind === 'element' || a.kind === 'identifier') {
404
- const e = iconProblem(a.name, a.raw, importedElsewhere);
471
+ const e = iconProblem(a.name, a.raw, importedElsewhere, localNames);
405
472
  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';`));
473
+ else if (a.kind === 'identifier' && iconNames.has(a.value) && !importedFrom.has(a.value) && !localNames.has(a.value)) warnings.push(at(`${a.value} is not imported. Add: import { ${a.value} } from '@sakaniui/react/icons';`));
407
474
  }
408
475
  }
409
476
  }
@@ -414,6 +481,89 @@ function checkCode(code) {
414
481
  return out;
415
482
  }
416
483
 
484
+ /* ---------- literal shapes: field names inside inline object literals ---------- */
485
+ /** Splits `text` at top-level commas (ignores commas inside brackets, braces, parentheses and strings). */
486
+ function splitTopLevel(text) {
487
+ const parts = [];
488
+ let depth = 0, start = 0, quote = null;
489
+ for (let i = 0; i < text.length && parts.length < 2000; i++) {
490
+ const ch = text[i];
491
+ if (quote) { if (ch === '\\') i++; else if (ch === quote) quote = null; continue; }
492
+ if (ch === '"' || ch === "'" || ch === '`') { quote = ch; continue; }
493
+ if (ch === '{' || ch === '[' || ch === '(') depth++;
494
+ else if (ch === '}' || ch === ']' || ch === ')') depth--;
495
+ else if (ch === ',' && depth === 0) { parts.push(text.slice(start, i)); start = i + 1; }
496
+ }
497
+ parts.push(text.slice(start));
498
+ return parts.map((x) => x.trim()).filter(Boolean);
499
+ }
500
+
501
+ /** The keys written in an object literal `{ a: 1, 'b': 2, c, ...d }`. spread: it also contains `...x`. */
502
+ function objectKeys(literal) {
503
+ const inner = literal.trim().slice(1, -1);
504
+ const keys = []; let spread = false;
505
+ for (const entry of splitTopLevel(inner)) {
506
+ if (entry.startsWith('...')) { spread = true; continue; }
507
+ const m = /^(?:'([^']+)'|"([^"]+)"|([A-Za-z_$][\w$]*))\s*(?::|\(|$)/.exec(entry);
508
+ if (m) keys.push(m[1] || m[2] || m[3]);
509
+ else spread = true; // computed key or something we cannot read: do not judge this object
510
+ }
511
+ return { keys, spread };
512
+ }
513
+
514
+ /** The object literals in an inline `[ {...}, {...} ]` (or a single `{...}`). Null when it is not an inline literal. */
515
+ function inlineObjects(raw, collection) {
516
+ const t = raw.trim();
517
+ if (collection === 'array') {
518
+ if (!(t.startsWith('[') && t.endsWith(']'))) return null;
519
+ const items = splitTopLevel(t.slice(1, -1));
520
+ if (items.some((x) => !x.startsWith('{') || !x.endsWith('}'))) return items.filter((x) => x.startsWith('{') && x.endsWith('}')).length ? items.filter((x) => x.startsWith('{') && x.endsWith('}')) : null;
521
+ return items;
522
+ }
523
+ return t.startsWith('{') && t.endsWith('}') ? [t] : null;
524
+ }
525
+
526
+ /** Checks inline object literals against the prop's `shape`. Returns messages. */
527
+ function checkShape(c, prop, attr, siblings) {
528
+ const sh = prop.shape;
529
+ if (!sh || attr.kind !== 'expression' || !attr.raw) return [];
530
+ const objs = inlineObjects(attr.raw, sh.collection);
531
+ if (!objs || !objs.length) return [];
532
+ const out = [];
533
+ const known = new Map(sh.fields.map((f) => [f.name, f]));
534
+ const list = sh.fields.map((f) => f.name + (f.required ? '' : '?')).join(', ');
535
+ objs.forEach((lit, idx) => {
536
+ const { keys, spread } = objectKeys(lit);
537
+ const where = sh.collection === 'array' ? `${prop.name}[${idx}]` : prop.name;
538
+ for (const k of keys) {
539
+ if (!known.has(k)) {
540
+ const near = sh.fields.map((f) => f.name).filter((f) => similar(f, k));
541
+ out.push(`${where}: "${k}" is not a field of ${sh.type}${near.length ? ` (did you mean "${near[0]}"?)` : ''}. Fields: ${list}.`);
542
+ }
543
+ }
544
+ if (!spread) for (const f of sh.fields) if (f.required && !keys.includes(f.name)) out.push(`${where}: missing required field "${f.name}" of ${sh.type}. Fields: ${list}.`);
545
+ });
546
+ // `key: keyof T`: the key must be a field of the rows given next to it (T[] sibling), when both are written inline
547
+ const keyField = sh.fields.find((f) => /^keyof T\b/.test(f.type));
548
+ if (keyField && sh.typeParameters && sh.typeParameters[0] === 'T') {
549
+ const rowsProp = c.props.find((p) => p.type === 'T[]');
550
+ const rowsAttr = rowsProp && siblings.get(rowsProp.name);
551
+ const rowObjs = rowsAttr && rowsAttr.kind === 'expression' ? inlineObjects(rowsAttr.raw || '', 'array') : null;
552
+ if (rowObjs && rowObjs.length) {
553
+ const rowKeys = new Set();
554
+ let rowsOpen = false;
555
+ for (const r of rowObjs) { const o = objectKeys(r); o.keys.forEach((k) => rowKeys.add(k)); if (o.spread) rowsOpen = true; }
556
+ if (!rowsOpen) {
557
+ for (const lit of objs) {
558
+ const kv = new RegExp(`(?:^|[{,\\s])${keyField.name}\\s*:\\s*['"]([^'"]+)['"]`).exec(lit);
559
+ if (kv && !rowKeys.has(kv[1])) out.push(`${prop.name}: ${keyField.name} "${kv[1]}" is not a field of the ${rowsProp.name} (${[...rowKeys].join(', ')}). It must be a key of the row type; for a column that is not data, add an optional field to the row type (see the recipe in get_component ${c.name}).`);
560
+ }
561
+ }
562
+ }
563
+ }
564
+ return out;
565
+ }
566
+
417
567
  const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
418
568
 
419
569
  /* ---------- tools ---------- */
@@ -446,9 +596,22 @@ const tools = {
446
596
  const c = byName.get(n.value.toLowerCase());
447
597
  if (!c) return fail(`No component named "${n.value}". Did you mean: ${closest(n.value).join(', ') || '(none close)'}? Use search_components.`);
448
598
  const out = { ...c, note: 'Native HTML attributes (className, style, aria-*, event handlers) are also accepted on the root element.' };
599
+ if (c.props.some((p) => p.shape)) out.shapeNote = 'A prop with a `shape` takes an object (or an array of objects) with exactly those fields; `required` fields must be present. In a generic shape, `keyof T & string` means the value must be a field of your own data type.';
600
+ if (c.recipes && c.recipes.length) out.recipeNote = 'See `recipes`: patterns the types alone do not teach, compiled against this release.';
449
601
  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
602
  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.';
451
603
  if (inInstalled(c) === false) out.availableInInstalled = false;
604
+ if (projectVersion) {
605
+ out.props = c.props.map((p) => {
606
+ const tooNew = propTooNew(c, p);
607
+ const newerValues = p.valuesSince ? Object.entries(p.valuesSince).filter(([, v]) => cmp(v, projectVersion) > 0).map(([k]) => k) : [];
608
+ if (!tooNew && !newerValues.length) return p;
609
+ const q = { ...p };
610
+ if (tooNew) q.availableInInstalled = false;
611
+ if (newerValues.length) q.valuesNotInInstalled = newerValues;
612
+ return q;
613
+ });
614
+ }
452
615
  const note = versionNote();
453
616
  if (note) out.versionNote = note;
454
617
  if (inInstalled(c) === false) out.warning = `${c.name} was added in ${c.since} but the project has ${projectVersion}. Do not use it without upgrading (npm install @sakaniui/react@latest).`;
@@ -505,11 +668,12 @@ const tools = {
505
668
  for (const r of c.contract.requiredProps) if (!Object.hasOwn(props, r)) errors.push(`Missing required prop "${r}".`);
506
669
  for (const [k, v] of Object.entries(props)) {
507
670
  const p = known.get(k);
508
- 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; }
671
+ if (!p) { const u = unknownProp(c, k); if (u) (u.level === 'error' ? errors : warnings).push(u.message); continue; }
509
672
  if (p.values && (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') && !p.values.includes(v)) {
510
673
  errors.push(`"${k}" = ${JSON.stringify(v)} is not allowed. Use one of: ${p.values.map((x) => JSON.stringify(x)).join(', ')}.`);
511
674
  }
512
675
  if (p.type === 'IconComponent' && typeof v === 'string') { const e = iconProblem(k, v); if (e) errors.push(e); }
676
+ const tooNew = propTooNew(c, p, v); if (tooNew) errors.push(tooNew);
513
677
  }
514
678
  if (c.contract.requiresAccessibleName && !errors.length) warnings.push('This control has no visible text of its own: keep its accessible-name prop set.');
515
679
  const out = { component: c.name, ok: errors.length === 0, errors, warnings };
@@ -580,9 +744,10 @@ const tools = {
580
744
  },
581
745
  get_guide: {
582
746
  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.',
583
- inputSchema: { type: 'object', properties: { section: { type: 'string' } } },
747
+ inputSchema: { type: 'object', properties: { section: { type: 'string', description: 'A section number ("3") or words from its heading ("icons").' } } },
584
748
  run(args) {
585
- const s0 = strArg(args, 'section'); if (s0.err) return fail(s0.err);
749
+ // agents sometimes call it with `query`; treat that as the section
750
+ const s0 = strArg(args.section === undefined && args.query !== undefined ? { section: args.query } : args, 'section'); if (s0.err) return fail(s0.err);
586
751
  if (!guide) return fail('AGENTS.md is not available in this install.');
587
752
  const parts = guide.split(/^(?=## )/m).filter((p) => p.startsWith('## '));
588
753
  const heads = parts.map((p) => p.split('\n')[0].replace(/^## /, ''));
@@ -595,45 +760,71 @@ const tools = {
595
760
  };
596
761
 
597
762
  /* ---------- JSON-RPC over stdio ---------- */
598
- const send = (msg) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...msg }) + '\n');
763
+ // Protocol versions this server implements; the client's is used when it is one of them.
764
+ const PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
599
765
 
766
+ let stdoutClosed = false;
767
+ process.stdout.on('error', () => { stdoutClosed = true; process.exit(0); }); // the client went away (EPIPE)
768
+ const write = (payload) => { if (!stdoutClosed) process.stdout.write(JSON.stringify(payload) + '\n'); };
769
+ const reply = (id, body) => ({ jsonrpc: '2.0', id, ...body });
770
+
771
+ /** Handles one message. Returns the response, or null for notifications. */
600
772
  function handle(msg) {
601
- if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return send({ id: null, error: { code: -32600, message: 'Invalid request' } });
773
+ if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return reply(null, { error: { code: -32600, message: 'Invalid request' } });
602
774
  const { id, method, params } = msg;
603
775
  const isRequest = id !== undefined && id !== null;
776
+ if (typeof method !== 'string') return isRequest ? reply(id, { error: { code: -32600, message: 'Invalid request: method must be a string' } }) : null;
777
+ if (!isRequest) return null; // notifications (initialized, cancelled, ...) need no answer
604
778
  try {
605
779
  switch (method) {
606
780
  case 'initialize': {
607
781
  const note = versionNote();
608
- return send({ id, result: {
609
- protocolVersion: params?.protocolVersion || '2025-06-18',
610
- capabilities: { tools: {} },
611
- serverInfo: { name: 'sakaniui', version: refVersion },
612
- instructions: `SakaniUI ${refVersion}${projectVersion ? ` (project has ${projectVersion})` : ''}. Read-only reference. 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.${note ? ` ${note}` : ''}`,
782
+ const asked = params && typeof params.protocolVersion === 'string' ? params.protocolVersion : null;
783
+ return reply(id, { result: {
784
+ protocolVersion: asked && PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
785
+ capabilities: { tools: { listChanged: false } },
786
+ serverInfo: { name: 'sakaniui', title: 'SakaniUI', version: refVersion },
787
+ instructions: `SakaniUI ${refVersion}${projectVersion ? ` (project has ${projectVersion})` : ''}. Read-only reference. Look components up with search_components / get_component and start from their \`example\`; find icon names with search_icons (never guess them); run check_code on every snippet before you answer; style anything beyond props with the design tokens from get_tokens (CSS variables), not hard-coded colours or utility classes the project may not have; read get_guide when nothing fits.${note ? ` ${note}` : ''}`,
613
788
  } });
614
789
  }
615
790
  case 'ping':
616
- return send({ id, result: {} });
791
+ return reply(id, { result: {} });
617
792
  case 'tools/list':
618
- return send({ id, result: { tools: Object.entries(tools).map(([name, t]) => ({ name, description: t.description, inputSchema: t.inputSchema, annotations: READ_ONLY })) } });
793
+ return reply(id, { result: { tools: Object.entries(tools).map(([name, t]) => ({ name, description: t.description, inputSchema: t.inputSchema, annotations: READ_ONLY })) } });
619
794
  case 'tools/call': {
620
795
  const t = typeof params?.name === 'string' && Object.hasOwn(tools, params.name) ? tools[params.name] : null;
621
- if (!t) return send({ id, error: { code: -32602, message: `Unknown tool: ${String(params?.name).slice(0, 60)}` } });
796
+ if (!t) return reply(id, { error: { code: -32602, message: `Unknown tool: ${String(params?.name).slice(0, 60)}` } });
622
797
  const args = params.arguments;
623
- if (args !== undefined && args !== null && (typeof args !== 'object' || Array.isArray(args))) return send({ id, result: fail('Tool arguments must be an object.') });
624
- return send({ id, result: t.run(args || {}) });
798
+ if (args !== undefined && args !== null && (typeof args !== 'object' || Array.isArray(args))) return reply(id, { result: fail('Tool arguments must be an object.') });
799
+ return reply(id, { result: t.run(args || {}) });
625
800
  }
801
+ // Some clients ask for these even when only tools are offered; an empty list is the polite answer.
802
+ case 'resources/list': return reply(id, { result: { resources: [] } });
803
+ case 'resources/templates/list': return reply(id, { result: { resourceTemplates: [] } });
804
+ case 'prompts/list': return reply(id, { result: { prompts: [] } });
805
+ case 'logging/setLevel': return reply(id, { result: {} });
626
806
  default:
627
- if (isRequest) send({ id, error: { code: -32601, message: `Method not found: ${String(method).slice(0, 60)}` } });
807
+ return reply(id, { error: { code: -32601, message: `Method not found: ${method.slice(0, 60)}` } });
628
808
  }
629
809
  } catch (e) {
630
- if (isRequest) send({ id, error: { code: -32603, message: String(e?.message || e) } });
810
+ return reply(id, { error: { code: -32603, message: String(e?.message || e) } });
631
811
  }
632
812
  }
633
813
 
634
- readline.createInterface({ input: process.stdin }).on('line', (line) => {
814
+ const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
815
+ rl.on('line', (line) => {
635
816
  if (!line.trim()) return;
636
817
  let msg;
637
- try { msg = JSON.parse(line); } catch { return send({ id: null, error: { code: -32700, message: 'Parse error' } }); }
638
- handle(msg);
818
+ try { msg = JSON.parse(line); } catch { return write(reply(null, { error: { code: -32700, message: 'Parse error' } })); }
819
+ if (Array.isArray(msg)) { // a batch (older protocol versions): answer with one array
820
+ if (!msg.length) return write(reply(null, { error: { code: -32600, message: 'Invalid request: empty batch' } }));
821
+ const out = msg.map(handle).filter(Boolean);
822
+ if (out.length) write(out);
823
+ return;
824
+ }
825
+ const r = handle(msg);
826
+ if (r) write(r);
639
827
  });
828
+ // the editor closed the connection: exit instead of lingering as an orphan process
829
+ // (flush first: on macOS and Windows, writes to a pipe are asynchronous)
830
+ rl.on('close', () => { if (stdoutClosed) process.exit(0); else process.stdout.write('', () => process.exit(0)); });