@sakaniui/react 0.8.10 → 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
@@ -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
@@ -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,25 @@
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
+
5
24
  ## 0.8.10
6
25
 
7
26
  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
 
@@ -170,6 +170,16 @@ function distance(a, b) {
170
170
  return d[a.length][b.length];
171
171
  }
172
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
+
173
183
  function closest(name) {
174
184
  const n = name.toLowerCase();
175
185
  return manifest.components
@@ -454,6 +464,7 @@ function checkCode(source) {
454
464
  errors.push(at(`${a.name}=${JSON.stringify(a.value)} is not allowed. Use one of: ${p.values.map((x) => JSON.stringify(x)).join(', ')}.`));
455
465
  }
456
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));
457
468
  if (p.type === 'IconComponent') {
458
469
  if (a.kind === 'string') errors.push(at(`"${a.name}" takes an icon component, not a string: ${a.name}={IconName}.`));
459
470
  else if (a.kind === 'element' || a.kind === 'identifier') {
@@ -470,6 +481,89 @@ function checkCode(source) {
470
481
  return out;
471
482
  }
472
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
+
473
567
  const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
474
568
 
475
569
  /* ---------- tools ---------- */
@@ -502,6 +596,8 @@ const tools = {
502
596
  const c = byName.get(n.value.toLowerCase());
503
597
  if (!c) return fail(`No component named "${n.value}". Did you mean: ${closest(n.value).join(', ') || '(none close)'}? Use search_components.`);
504
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.';
505
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.';
506
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.';
507
603
  if (inInstalled(c) === false) out.availableInInstalled = false;