@sakaniui/react 0.8.10 → 0.8.12

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
@@ -19,6 +19,11 @@ Editors can query the same data through the bundled MCP server
19
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
@@ -147,6 +152,12 @@ CSS of a component into your own file.
147
152
 
148
153
  Format: **Use for** · **Why it is built this way** · **Not for / instead**.
149
154
 
155
+ **Looking for a component by another name?** SakaniUI uses its own names. A *dialog* or *alert dialog* is
156
+ `Modal`; a *snackbar* is `Toast`; a *dropdown menu* is `Menu`; a *navbar* or *app bar* is `TopBar`; a
157
+ *date picker* is `Calendar`; a *loader* is `Spinner`; a *toggle* is `Switch`; a *banner* is `Announcement`;
158
+ *autocomplete* is `Combobox`. There is no `Dialog`, `AlertDialog`, `Snackbar` or `Navbar`. Each component
159
+ lists its other names under `aliases` in `manifest.json`, and `search_components` understands them.
160
+
150
161
  ### Actions
151
162
  - **Button** — Use for any action that does something. *Why:* 5 variants × 3 sizes mirror Figma 1:1;
152
163
  `loading` disables interaction so double-submits can't happen. *Not for* navigation → `Link`; icon-only →
@@ -206,7 +217,11 @@ Format: **Use for** · **Why it is built this way** · **Not for / instead**.
206
217
  ### Content and data
207
218
  - **Card** — a container surface for a unit of content, optionally with actions. Not a layout wrapper for
208
219
  everything.
209
- - **Table** — dense, comparable records. **StatCard** — one headline metric with trend. **ListItem** — a
220
+ - **Table** — dense, comparable records. `Table<T>` is generic: every column's `key` must be a field of
221
+ your row type `T` (`keyof T & string`), so a column that is not data (a delete button, a menu) still
222
+ needs a key: give the row type an optional `never` field (`actions?: never`) and render the control in
223
+ `render`. Inventing a key that is not on the row type does not compile (see `recipes` in
224
+ `get_component("Table")` for code that does). **StatCard** — one headline metric with trend. **ListItem** — a
210
225
  row in a list. **Charts** — pick by question: trend over time → Line/Area; compare categories → Bar;
211
226
  part of whole → Pie/Donut (few slices only); progress to a target → Radial; compare several measures →
212
227
  Radar; drop-off → Funnel; intensity across two dimensions → Heatmap.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,41 @@
2
2
 
3
3
  All notable changes to `@sakaniui/react` are documented here.
4
4
 
5
+ ## 0.8.12
6
+
7
+ Found while testing 0.8.11 in Claude Code: after writing correct code, the agent offered to wrap the
8
+ delete button in "SakaniUI's Dialog/AlertDialog". Neither exists; the component is `Modal`. It named it
9
+ from memory without looking it up.
10
+
11
+ - **`aliases` in `manifest.json`**: the other names people use for a component, 250+ across 61 components
12
+ (dialog, alert dialog -> `Modal`; snackbar -> `Toast`; navbar, app bar -> `TopBar`; date picker ->
13
+ `Calendar`; loader -> `Spinner`; toggle -> `Switch`; autocomplete -> `Combobox`; dropzone ->
14
+ `FileUpload`; pricing table -> `PricingTableBlock`; ...). Hand-written; the build checks every one
15
+ points at a real component and never shadows a real name.
16
+ - **MCP:** `search_components` ranks by alias ("dialog" finds `Modal` first; a plain exact name such as
17
+ "button" always wins). `get_component`, `check_usage` and `check_code` now say what the name is here:
18
+ *"Dialog is not exported by @sakaniui/react. In SakaniUI that is `Modal` (Confirmation dialog)."*
19
+ - `AGENTS.md` lists the most common renamings.
20
+
21
+ ## 0.8.11
22
+
23
+ Found by asking Claude Code, with the published 0.8.10 MCP server, to add a delete button to a table row:
24
+ every tool call was correct and `IconButton` was used properly, but it put the button in a new `Table`
25
+ column with `key: 'actions'`, which does not compile (`key` must be a field of the row type). The
26
+ contract only said `columns: TableColumn<T>[]`.
27
+
28
+ - **Object props now have a `shape`** in `manifest.json`: the fields of `TableColumn<T>`, `BreadcrumbItem`,
29
+ `PricingPlan`, `SelectOption`, chart data and 58 more (63 props), read from the shipped type
30
+ definitions, generics kept as declared (`key: keyof T & string`).
31
+ - **`recipes`**: code for patterns the types do not teach, compiled on every build. First one: a `Table`
32
+ column that is not data (a delete button, a menu). The build also checks the opposite, that a column
33
+ key which is not on the row type really does not compile.
34
+ - **MCP `check_code` checks inline object literals** against the shape: an unknown field (`title` for
35
+ `label`, `pct` for `percent`, with a suggestion), a missing required field (with the item index), and,
36
+ for a `Table`, a column `key` that is not a field of the `rows` written next to it. Spreads, identifiers
37
+ and anything it cannot read are left alone (0 errors on 685 real elements in three apps).
38
+ - `AGENTS.md` explains the generic `Table` rule and the new `shape` and `recipes`.
39
+
5
40
  ## 0.8.10
6
41
 
7
42
  Readiness release: everything below was found by testing the package the way a new user, an editor
package/README.md CHANGED
@@ -142,7 +142,7 @@ or `claude mcp add sakaniui -- cmd /c npx -y @sakaniui/mcp`). Needs Node.js 18 o
142
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.
143
143
 
144
144
  Tools: `search_components`, `get_component` (with a type-checked `example`), `search_icons`,
145
- `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`,
146
146
  `list_components`, `get_tokens`, `get_guide`, `check_compatibility` and `get_changelog`.
147
147
 
148
148
  - **Read-only.** It reads five fixed files from a `@sakaniui/react` install (package.json, the
@@ -81,7 +81,7 @@ function unknownProp(c, name) {
81
81
  }
82
82
  // close spellings, plus the usual synonyms (leftIcon -> leadingIcon, onChange vs onValueChange is left to the list)
83
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) => distance(p.toLowerCase(), name.toLowerCase()) <= 2 || canon(p) === canon(name));
84
+ const near = c.props.map((p) => p.name).filter((p) => similar(p, name) || canon(p) === canon(name));
85
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
86
  }
87
87
 
@@ -121,6 +121,22 @@ function propTooNew(c, p, value, installed = projectVersion) {
121
121
 
122
122
  /* ---------- helpers ---------- */
123
123
  const byName = new Map(manifest.components.map((c) => [c.name.toLowerCase(), c]));
124
+ // Common names for components ("Dialog" -> Modal). Only used when a name is not found, and to rank search results.
125
+ const norm = (x) => String(x).toLowerCase().replace(/[^a-z0-9]/g, '');
126
+ const aliasIndex = new Map(); // normalized alias -> components
127
+ for (const c of manifest.components) for (const a of c.aliases || []) { const k = norm(a); if (!aliasIndex.has(k)) aliasIndex.set(k, []); aliasIndex.get(k).push(c); }
128
+ /** The real component(s) for a name that does not exist: "Dialog" -> [Modal]. */
129
+ function byAlias(name) {
130
+ const k = norm(name);
131
+ const exact = aliasIndex.get(k) || aliasIndex.get(k.replace(/s$/, '')) || [];
132
+ return [...new Set(exact)].slice(0, 4);
133
+ }
134
+ const aliasHint = (name) => {
135
+ const hits = byAlias(name);
136
+ if (!hits.length) return '';
137
+ return ` ${hits.length === 1 ? `In SakaniUI that is \`${hits[0].name}\` (${hits[0].summary.replace(/\.$/, '')})` : `In SakaniUI that could be ${hits.map((h) => `\`${h.name}\``).join(' or ')}`}.`;
138
+ };
139
+
124
140
  const brief = (c) => {
125
141
  const out = { name: c.name, kind: c.kind, category: c.category, import: c.import, since: c.since, summary: c.summary, requiredProps: c.contract.requiredProps };
126
142
  if (inInstalled(c) === false) out.availableInInstalled = false;
@@ -150,9 +166,19 @@ const verArg = (args, key) => {
150
166
  };
151
167
  const firstError = (...rs) => rs.find((r) => r.err);
152
168
 
153
- function score(c, terms) {
169
+ function score(c, terms, phrase = '') {
154
170
  const hay = `${c.name} ${c.category ?? ''} ${c.summary} ${c.props.map((p) => p.name).join(' ')}`.toLowerCase();
155
171
  let s = 0;
172
+ // an alias is the name people use for this component: "dialog" finds Modal, "snackbar" finds Toast
173
+ const p = norm(phrase);
174
+ let aliasWords = 0;
175
+ for (const a of c.aliases || []) {
176
+ const na = norm(a);
177
+ if (na === p || na + 's' === p || na === p + 's') s += 60;
178
+ else if (na.length >= 4 && p.includes(na)) s += 25;
179
+ else if (!aliasWords && terms.length && a.toLowerCase().split(/[^a-z0-9]+/).some((w) => w.length >= 3 && terms.includes(w))) { s += 6; aliasWords = 1; } // loosely related: counts once
180
+ }
181
+ if (p && norm(c.name) === p) s += 100; // the real name always wins
156
182
  for (const t of terms) {
157
183
  if (c.name.toLowerCase() === t) s += 20;
158
184
  else if (c.name.toLowerCase().includes(t)) s += 8;
@@ -170,6 +196,16 @@ function distance(a, b) {
170
196
  return d[a.length][b.length];
171
197
  }
172
198
 
199
+ /** `pct` for `percent`, `desc` for `description`: same first letter, every letter in order. */
200
+ function isAbbreviation(short, full) {
201
+ const a = short.toLowerCase(), b = full.toLowerCase();
202
+ if (a.length < 2 || a.length >= b.length || a[0] !== b[0]) return false;
203
+ let i = 0;
204
+ for (const ch of b) if (ch === a[i]) i++;
205
+ return i === a.length;
206
+ }
207
+ const similar = (a, b) => distance(a.toLowerCase(), b.toLowerCase()) <= 2 || isAbbreviation(a, b) || isAbbreviation(b, a);
208
+
173
209
  function closest(name) {
174
210
  const n = name.toLowerCase();
175
211
  return manifest.components
@@ -424,7 +460,7 @@ function checkCode(source) {
424
460
  if (!c && !isUtility) {
425
461
  if (TYPE_LIKE.test(n.imported)) continue; // most likely a type export
426
462
  const near = closest(n.imported);
427
- errors.push({ line: imp.line, message: `${n.imported} is not exported by @sakaniui/react.${near.length ? ` Did you mean: ${near.join(', ')}?` : ''} Use search_components.` });
463
+ errors.push({ line: imp.line, message: `${n.imported} is not exported by @sakaniui/react.${aliasHint(n.imported)}${near.length ? ` Did you mean: ${near.join(', ')}?` : ''} Use search_components.` });
428
464
  }
429
465
  if (c && inInstalled(c) === false) errors.push({ line: imp.line, message: `${c.name} was added in ${c.since}; the project has ${projectVersion}.` });
430
466
  }
@@ -454,6 +490,7 @@ function checkCode(source) {
454
490
  errors.push(at(`${a.name}=${JSON.stringify(a.value)} is not allowed. Use one of: ${p.values.map((x) => JSON.stringify(x)).join(', ')}.`));
455
491
  }
456
492
  { const tooNew = propTooNew(c, p, a.kind === 'string' ? a.value : undefined); if (tooNew) errors.push(at(tooNew)); }
493
+ for (const m of checkShape(c, p, a, new Map(el.attrs.map((x) => [x.name, x])))) errors.push(at(m));
457
494
  if (p.type === 'IconComponent') {
458
495
  if (a.kind === 'string') errors.push(at(`"${a.name}" takes an icon component, not a string: ${a.name}={IconName}.`));
459
496
  else if (a.kind === 'element' || a.kind === 'identifier') {
@@ -470,6 +507,89 @@ function checkCode(source) {
470
507
  return out;
471
508
  }
472
509
 
510
+ /* ---------- literal shapes: field names inside inline object literals ---------- */
511
+ /** Splits `text` at top-level commas (ignores commas inside brackets, braces, parentheses and strings). */
512
+ function splitTopLevel(text) {
513
+ const parts = [];
514
+ let depth = 0, start = 0, quote = null;
515
+ for (let i = 0; i < text.length && parts.length < 2000; i++) {
516
+ const ch = text[i];
517
+ if (quote) { if (ch === '\\') i++; else if (ch === quote) quote = null; continue; }
518
+ if (ch === '"' || ch === "'" || ch === '`') { quote = ch; continue; }
519
+ if (ch === '{' || ch === '[' || ch === '(') depth++;
520
+ else if (ch === '}' || ch === ']' || ch === ')') depth--;
521
+ else if (ch === ',' && depth === 0) { parts.push(text.slice(start, i)); start = i + 1; }
522
+ }
523
+ parts.push(text.slice(start));
524
+ return parts.map((x) => x.trim()).filter(Boolean);
525
+ }
526
+
527
+ /** The keys written in an object literal `{ a: 1, 'b': 2, c, ...d }`. spread: it also contains `...x`. */
528
+ function objectKeys(literal) {
529
+ const inner = literal.trim().slice(1, -1);
530
+ const keys = []; let spread = false;
531
+ for (const entry of splitTopLevel(inner)) {
532
+ if (entry.startsWith('...')) { spread = true; continue; }
533
+ const m = /^(?:'([^']+)'|"([^"]+)"|([A-Za-z_$][\w$]*))\s*(?::|\(|$)/.exec(entry);
534
+ if (m) keys.push(m[1] || m[2] || m[3]);
535
+ else spread = true; // computed key or something we cannot read: do not judge this object
536
+ }
537
+ return { keys, spread };
538
+ }
539
+
540
+ /** The object literals in an inline `[ {...}, {...} ]` (or a single `{...}`). Null when it is not an inline literal. */
541
+ function inlineObjects(raw, collection) {
542
+ const t = raw.trim();
543
+ if (collection === 'array') {
544
+ if (!(t.startsWith('[') && t.endsWith(']'))) return null;
545
+ const items = splitTopLevel(t.slice(1, -1));
546
+ 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;
547
+ return items;
548
+ }
549
+ return t.startsWith('{') && t.endsWith('}') ? [t] : null;
550
+ }
551
+
552
+ /** Checks inline object literals against the prop's `shape`. Returns messages. */
553
+ function checkShape(c, prop, attr, siblings) {
554
+ const sh = prop.shape;
555
+ if (!sh || attr.kind !== 'expression' || !attr.raw) return [];
556
+ const objs = inlineObjects(attr.raw, sh.collection);
557
+ if (!objs || !objs.length) return [];
558
+ const out = [];
559
+ const known = new Map(sh.fields.map((f) => [f.name, f]));
560
+ const list = sh.fields.map((f) => f.name + (f.required ? '' : '?')).join(', ');
561
+ objs.forEach((lit, idx) => {
562
+ const { keys, spread } = objectKeys(lit);
563
+ const where = sh.collection === 'array' ? `${prop.name}[${idx}]` : prop.name;
564
+ for (const k of keys) {
565
+ if (!known.has(k)) {
566
+ const near = sh.fields.map((f) => f.name).filter((f) => similar(f, k));
567
+ out.push(`${where}: "${k}" is not a field of ${sh.type}${near.length ? ` (did you mean "${near[0]}"?)` : ''}. Fields: ${list}.`);
568
+ }
569
+ }
570
+ 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}.`);
571
+ });
572
+ // `key: keyof T`: the key must be a field of the rows given next to it (T[] sibling), when both are written inline
573
+ const keyField = sh.fields.find((f) => /^keyof T\b/.test(f.type));
574
+ if (keyField && sh.typeParameters && sh.typeParameters[0] === 'T') {
575
+ const rowsProp = c.props.find((p) => p.type === 'T[]');
576
+ const rowsAttr = rowsProp && siblings.get(rowsProp.name);
577
+ const rowObjs = rowsAttr && rowsAttr.kind === 'expression' ? inlineObjects(rowsAttr.raw || '', 'array') : null;
578
+ if (rowObjs && rowObjs.length) {
579
+ const rowKeys = new Set();
580
+ let rowsOpen = false;
581
+ for (const r of rowObjs) { const o = objectKeys(r); o.keys.forEach((k) => rowKeys.add(k)); if (o.spread) rowsOpen = true; }
582
+ if (!rowsOpen) {
583
+ for (const lit of objs) {
584
+ const kv = new RegExp(`(?:^|[{,\\s])${keyField.name}\\s*:\\s*['"]([^'"]+)['"]`).exec(lit);
585
+ 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}).`);
586
+ }
587
+ }
588
+ }
589
+ }
590
+ return out;
591
+ }
592
+
473
593
  const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
474
594
 
475
595
  /* ---------- tools ---------- */
@@ -484,7 +604,7 @@ const tools = {
484
604
  const terms = q.value.toLowerCase().split(/\s+/).filter(Boolean);
485
605
  const hits = manifest.components
486
606
  .filter((c) => !k.value || c.kind === k.value)
487
- .map((c) => [score(c, terms), c])
607
+ .map((c) => [score(c, terms, q.value), c])
488
608
  .filter(([s]) => s > 0)
489
609
  .sort((a, b) => b[0] - a[0])
490
610
  .slice(0, limit)
@@ -500,8 +620,10 @@ const tools = {
500
620
  run(args) {
501
621
  const n = strArg(args, 'name', { required: true }); if (n.err) return fail(n.err);
502
622
  const c = byName.get(n.value.toLowerCase());
503
- if (!c) return fail(`No component named "${n.value}". Did you mean: ${closest(n.value).join(', ') || '(none close)'}? Use search_components.`);
623
+ if (!c) return fail(`No component named "${n.value}".${aliasHint(n.value)} ${closest(n.value).length ? `Did you mean: ${closest(n.value).join(', ')}? ` : ''}Use search_components.`.replace(/\s+/g, ' '));
504
624
  const out = { ...c, note: 'Native HTML attributes (className, style, aria-*, event handlers) are also accepted on the root element.' };
625
+ 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.';
626
+ if (c.recipes && c.recipes.length) out.recipeNote = 'See `recipes`: patterns the types alone do not teach, compiled against this release.';
505
627
  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.';
506
628
  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.';
507
629
  if (inInstalled(c) === false) out.availableInInstalled = false;
@@ -564,7 +686,7 @@ const tools = {
564
686
  if (typeof props !== 'object' || Array.isArray(props)) return fail('"props" must be an object of prop name -> value.');
565
687
  if (Object.keys(props).length > 200) return fail('"props" has too many entries (max 200).');
566
688
  const c = byName.get(n.value.toLowerCase());
567
- if (!c) return fail(`No component named "${n.value}". Did you mean: ${closest(n.value).join(', ') || '(none close)'}?`);
689
+ if (!c) return fail(`No component named "${n.value}".${aliasHint(n.value)} ${closest(n.value).length ? `Did you mean: ${closest(n.value).join(', ')}?` : ''}`.replace(/\s+/g, ' ').trim());
568
690
  const known = new Map(c.props.map((p) => [p.name, p]));
569
691
  const errors = [];
570
692
  const warnings = [];
@@ -688,7 +810,7 @@ function handle(msg) {
688
810
  protocolVersion: asked && PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
689
811
  capabilities: { tools: { listChanged: false } },
690
812
  serverInfo: { name: 'sakaniui', title: 'SakaniUI', version: refVersion },
691
- 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}` : ''}`,
813
+ 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); search by the name you would normally use (dialog, snackbar, navbar, date picker) and it maps to the real component; 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}` : ''}`,
692
814
  } });
693
815
  }
694
816
  case 'ping':