@imenam/mcp-design 1.0.4 → 1.0.5

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.
Files changed (46) hide show
  1. package/README.md +61 -8
  2. package/dist/shared/icons/icon-data.d.ts +2 -0
  3. package/dist/shared/icons/icon-data.js +5 -0
  4. package/dist/shared/icons/icon-keywords.d.ts +1 -0
  5. package/dist/shared/icons/icon-keywords.js +4 -0
  6. package/dist/shared/icons/index.d.ts +47 -0
  7. package/dist/shared/icons/index.js +107 -0
  8. package/dist/shared/icons/search.d.ts +31 -0
  9. package/dist/shared/icons/search.js +104 -0
  10. package/dist/shared/instances.d.ts +39 -0
  11. package/dist/shared/instances.js +94 -0
  12. package/dist/shared/node-defaults.d.ts +2 -0
  13. package/dist/shared/node-defaults.js +32 -4
  14. package/dist/shared/node-fields.d.ts +2 -0
  15. package/dist/shared/node-fields.js +17 -4
  16. package/dist/shared/render.d.ts +31 -1
  17. package/dist/shared/render.js +65 -6
  18. package/dist/shared/schemas/design.schema.d.ts +19 -1
  19. package/dist/shared/schemas/design.schema.js +13 -1
  20. package/dist/src/core/clone.d.ts +23 -0
  21. package/dist/src/core/clone.js +34 -0
  22. package/dist/src/core/diff.js +2 -2
  23. package/dist/src/core/export.js +63 -3
  24. package/dist/src/core/hierarchy.d.ts +7 -0
  25. package/dist/src/core/hierarchy.js +75 -5
  26. package/dist/src/core/node-factory.js +55 -5
  27. package/dist/src/core/screenshot.d.ts +12 -0
  28. package/dist/src/core/screenshot.js +17 -6
  29. package/dist/src/index.js +97 -7
  30. package/dist/src/tools/add-nodes.js +7 -1
  31. package/dist/src/tools/delete-nodes.js +10 -1
  32. package/dist/src/tools/detach-instances.d.ts +14 -0
  33. package/dist/src/tools/detach-instances.js +61 -0
  34. package/dist/src/tools/duplicate-nodes.js +20 -28
  35. package/dist/src/tools/search-icons.d.ts +12 -0
  36. package/dist/src/tools/search-icons.js +87 -0
  37. package/dist/src/tools/ungroup-nodes.js +8 -0
  38. package/dist/src/version.d.ts +1 -1
  39. package/dist/src/version.js +1 -1
  40. package/gui/dist/assets/{index-C6fXmzfu.css → index-BIeiaHA2.css} +1 -1
  41. package/gui/dist/assets/index-Dpd_cZgh.js +10 -0
  42. package/gui/dist/assets/search--VtHhIew.js +1 -0
  43. package/gui/dist/index.html +2 -2
  44. package/package.json +4 -2
  45. package/scripts/generate-icons.js +85 -0
  46. package/gui/dist/assets/index-Cmo-X8Zs.js +0 -10
@@ -1,14 +1,50 @@
1
1
  import { DesignNodeSchema } from "../../shared/schemas/design.schema.js";
2
- import { GROUP_DERIVED_FIELDS, NODE_DEFAULTS, NODE_GEOMETRY_FIELDS, NODE_STYLE_FIELDS, clampNodeSize, defaultNodeName, } from "../../shared/node-defaults.js";
2
+ import { GROUP_DERIVED_FIELDS, NODE_DEFAULTS, NODE_GEOMETRY_FIELDS, NODE_STYLE_FIELDS, SIZE_DERIVED_KINDS, clampNodeSize, defaultNodeName, } from "../../shared/node-defaults.js";
3
+ import { CORE_ICONS, hasIcon, iconCount } from "../../shared/icons/index.js";
4
+ import { suggestIcons } from "../../shared/icons/search.js";
3
5
  import { shortId } from "./ids.js";
4
6
  export class DesignError extends Error {
5
7
  }
6
8
  const GEOMETRY = NODE_GEOMETRY_FIELDS;
7
9
  const ALLOWED = NODE_STYLE_FIELDS;
8
10
  const DERIVED = GROUP_DERIVED_FIELDS;
11
+ /**
12
+ * Valide le nom d'icone d'un noeud `icon`.
13
+ *
14
+ * Un nom refuse doit repartir avec une piste : sans cela l'agent retombe sur ce
15
+ * qu'on cherche precisement a eviter — reconstituer l'icone a coups d'ellipses.
16
+ * Les suggestions passent par les synonymes de la bibliotheque, ce qui rattrape
17
+ * aussi bien une faute qu'un renommage ("home" -> "house").
18
+ */
19
+ function requireIconName(value) {
20
+ const name = typeof value === "string" ? value.trim() : "";
21
+ if (name === "") {
22
+ throw new DesignError(`An "icon" node needs an "icon" field naming the icon to draw, e.g. "shopping-cart". ` +
23
+ `Call search_icons to find one among the ${iconCount()} available, or pick from: ${CORE_ICONS.slice(0, 24).join(", ")}...`);
24
+ }
25
+ if (hasIcon(name))
26
+ return name;
27
+ const suggestions = suggestIcons(name);
28
+ throw new DesignError(`Unknown icon "${name}".` +
29
+ (suggestions.length > 0 ? ` Closest matches: ${suggestions.join(", ")}.` : "") +
30
+ ` Call search_icons to search the ${iconCount()} available icons by description.`);
31
+ }
9
32
  /** Champs geometriques réellement modifiables pour un type donné. */
10
33
  function geometryFieldsFor(type) {
11
- return type === "group" ? GEOMETRY.filter((field) => !DERIVED.includes(field)) : GEOMETRY;
34
+ return SIZE_DERIVED_KINDS.includes(type) ? GEOMETRY.filter((field) => !DERIVED.includes(field)) : GEOMETRY;
35
+ }
36
+ /**
37
+ * Valide le lien d'un noeud `instance` vers son composant. L'existence du
38
+ * groupe cible, elle, est verifiee par `validateHierarchy` : elle depend de la
39
+ * planche entiere, pas du seul noeud.
40
+ */
41
+ function requireComponentId(value) {
42
+ const componentId = typeof value === "string" ? value.trim() : "";
43
+ if (componentId === "") {
44
+ throw new DesignError(`An "instance" node needs a "componentId" field naming the GROUP it mirrors, e.g. { type: "instance", componentId: "g_card" }. ` +
45
+ `Build the block once with group_nodes, then instantiate it — editing the group then updates every instance at once.`);
46
+ }
47
+ return componentId;
12
48
  }
13
49
  function readParentId(value) {
14
50
  if (value === null)
@@ -34,7 +70,7 @@ function requireFiniteNumber(value, field) {
34
70
  export function buildNode(input, existingIds, index) {
35
71
  const type = input.type;
36
72
  if (!type || !(type in NODE_DEFAULTS)) {
37
- throw new DesignError(`Unknown node type "${String(input.type)}". Expected one of: rect, ellipse, line, text.`);
73
+ throw new DesignError(`Unknown node type "${String(input.type)}". Expected one of: ${Object.keys(NODE_DEFAULTS).join(", ")}.`);
38
74
  }
39
75
  const id = typeof input.id === "string" && input.id.trim() !== "" ? input.id.trim() : shortId("n");
40
76
  if (existingIds.has(id))
@@ -58,6 +94,15 @@ export function buildNode(input, existingIds, index) {
58
94
  if (input[field] !== undefined)
59
95
  draft[field] = input[field];
60
96
  }
97
+ if (type === "instance")
98
+ draft.componentId = requireComponentId(draft.componentId);
99
+ if (type === "icon") {
100
+ draft.icon = requireIconName(draft.icon);
101
+ // Un calque nomme "Icône 7" ne dit rien au relecteur ; le nom de l'icone,
102
+ // lui, porte deja le sens. C'est le nom par defaut le plus utile.
103
+ if (!(typeof input.name === "string" && input.name.trim() !== ""))
104
+ draft.name = String(draft.icon);
105
+ }
61
106
  const size = clampNodeSize(type, Number(draft.width), Number(draft.height));
62
107
  draft.width = size.width;
63
108
  draft.height = size.height;
@@ -86,11 +131,16 @@ export function applyPatch(node, patch) {
86
131
  if (patch[field] !== undefined)
87
132
  next[field] = patch[field];
88
133
  }
134
+ if (node.type === "icon" && patch.icon !== undefined)
135
+ next.icon = requireIconName(patch.icon);
136
+ // Changer le lien echange l'instance de composant, comme un "swap instance".
137
+ if (node.type === "instance" && patch.componentId !== undefined)
138
+ next.componentId = requireComponentId(patch.componentId);
89
139
  const editable = ["name", "parentId", ...geometryFieldsFor(node.type), ...ALLOWED[node.type]];
90
140
  const unknownFields = Object.keys(patch).filter((key) => key !== "id" && !editable.includes(key));
91
141
  if (unknownFields.length > 0) {
92
- const derived = unknownFields.filter((key) => node.type === "group" && DERIVED.includes(key));
93
- const reason = derived.length > 0 ? ` The size of a group is derived from its content and cannot be set.` : "";
142
+ const derived = unknownFields.filter((key) => SIZE_DERIVED_KINDS.includes(node.type) && DERIVED.includes(key));
143
+ const reason = derived.length > 0 ? ` The size of a ${node.type} is derived from its content and cannot be set.` : "";
94
144
  throw new DesignError(`Field(s) [${unknownFields.join(", ")}] cannot be set on a "${node.type}" node.${reason} ` +
95
145
  `Allowed: ${editable.join(", ")}.`);
96
146
  }
@@ -28,4 +28,16 @@ export interface Screenshot {
28
28
  sourceHeight: number;
29
29
  scale: number;
30
30
  }
31
+ /**
32
+ * Rasterisation d'un SVG quelconque, avec la police de secours du systeme.
33
+ *
34
+ * Partagee par la capture d'un design et par la planche de vignettes de
35
+ * `search_icons` : une seule configuration de resvg, donc un seul endroit ou le
36
+ * rendu du texte peut diverger.
37
+ */
38
+ export declare function rasterize(svg: string, scale: number): {
39
+ png: Buffer;
40
+ width: number;
41
+ height: number;
42
+ };
31
43
  export declare function screenshotDesign(design: Design, options?: ScreenshotOptions): Screenshot;
@@ -25,6 +25,21 @@ function svgSize(svg) {
25
25
  const height = Number(/\bheight="([\d.]+)"/.exec(svg)?.[1] ?? 0);
26
26
  return { width, height };
27
27
  }
28
+ /**
29
+ * Rasterisation d'un SVG quelconque, avec la police de secours du systeme.
30
+ *
31
+ * Partagee par la capture d'un design et par la planche de vignettes de
32
+ * `search_icons` : une seule configuration de resvg, donc un seul endroit ou le
33
+ * rendu du texte peut diverger.
34
+ */
35
+ export function rasterize(svg, scale) {
36
+ const resvg = new Resvg(svg, {
37
+ fitTo: { mode: "zoom", value: scale },
38
+ font: { loadSystemFonts: true, defaultFontFamily: "Segoe UI", sansSerifFamily: "Segoe UI" },
39
+ });
40
+ const rendered = resvg.render();
41
+ return { png: Buffer.from(rendered.asPng()), width: rendered.width, height: rendered.height };
42
+ }
28
43
  export function screenshotDesign(design, options = {}) {
29
44
  const doc = toDocument(design);
30
45
  const padding = Math.max(0, options.padding ?? 0);
@@ -37,13 +52,9 @@ export function screenshotDesign(design, options = {}) {
37
52
  const requested = Math.max(0.05, Math.min(8, options.scale ?? 1));
38
53
  // L'écrêtage ne peut que réduire : un zoom demandé plus petit reste respecté.
39
54
  const scale = Math.min(requested, maxDimension / Math.max(source.width, source.height));
40
- const resvg = new Resvg(svg, {
41
- fitTo: { mode: "zoom", value: scale },
42
- font: { loadSystemFonts: true, defaultFontFamily: "Segoe UI", sansSerifFamily: "Segoe UI" },
43
- });
44
- const rendered = resvg.render();
55
+ const rendered = rasterize(svg, scale);
45
56
  return {
46
- png: Buffer.from(rendered.asPng()),
57
+ png: rendered.png,
47
58
  width: rendered.width,
48
59
  height: rendered.height,
49
60
  sourceWidth: source.width,
package/dist/src/index.js CHANGED
@@ -13,6 +13,7 @@ import { DesignStore } from "./core/design-store.js";
13
13
  import { NODE_DEFAULTS } from "./core/node-factory.js";
14
14
  import { EXPORT_FORMATS } from "./core/export.js";
15
15
  import { MIN_LINE_HEIGHT_PX } from "../shared/render.js";
16
+ import { CORE_ICONS, iconCount } from "../shared/icons/index.js";
16
17
  import { NODE_FIELD_DOCS } from "../shared/node-fields.js";
17
18
  import { REORDER_DIRECTIONS } from "../shared/tree.js";
18
19
  import { normalizeDesignGroup } from "../shared/schemas/design.schema.js";
@@ -28,11 +29,13 @@ import { duplicateNodes, MAX_DUPLICATE_COPIES } from "./tools/duplicate-nodes.js
28
29
  import { groupNodes } from "./tools/group-nodes.js";
29
30
  import { groupDesigns } from "./tools/group-designs.js";
30
31
  import { ungroupNodes } from "./tools/ungroup-nodes.js";
32
+ import { detachInstances } from "./tools/detach-instances.js";
31
33
  import { getChanges } from "./tools/get-changes.js";
32
34
  import { markSynced } from "./tools/mark-synced.js";
33
35
  import { exportDesignTool } from "./tools/export-design.js";
34
36
  import { screenshotDesignTool } from "./tools/screenshot-design.js";
35
37
  import { reorderNodesTool } from "./tools/reorder-nodes.js";
38
+ import { searchIconsTool, DEFAULT_ICON_RESULTS, MAX_ICON_RESULTS } from "./tools/search-icons.js";
36
39
  const __filename = fileURLToPath(import.meta.url);
37
40
  const __dirname = path.dirname(__filename);
38
41
  // Les flags CLI sont traités avant toute initialisation : ils s'exécutent dans le
@@ -319,8 +322,22 @@ const NODE_FIELDS_DOC = "Every length is in PIXELS (x, y, width, height, strokeW
319
322
  "height are derived from its content and cannot be set. " +
320
323
  "Fields accepted per type — rect: fill, stroke, strokeWidth, radius; ellipse: fill, stroke, strokeWidth; " +
321
324
  "line: stroke, strokeWidth (the line goes from (x, y) to (x + width, y + height)); " +
322
- "text: text, color, fontSize, fontFamily, fontWeight, fontStyle, textAlign, lineHeight. " +
325
+ "text: text, color, fontSize, fontFamily, fontWeight, fontStyle, textAlign, lineHeight; " +
326
+ "icon: icon (required — the name of the drawing), fill, stroke, strokeWidth; " +
327
+ "instance: componentId (required — the id of the group it mirrors); nothing else, its look comes from that group. " +
323
328
  "Any field left out falls back to a sensible default, so a node can be created from its type alone.";
329
+ /**
330
+ * Consigne sur les icones, repetee partout ou un noeud se cree.
331
+ *
332
+ * Sans elle, un agent qui n'a que des formes a sa disposition approxime une
333
+ * icone avec trois ellipses et deux traits : le rendu est mediocre, et la
334
+ * maquette relue plus tard ne dit plus ce que ce tas de formes representait.
335
+ */
336
+ const ICON_DOC = `DRAW ICONS WITH AN "icon" NODE, NEVER OUT OF SHAPES. ${iconCount()} icons (Lucide) are built in: give the icon's name in the "icon" field ` +
337
+ `and the drawing is resolved at render time — sharp at any size, restyled with a single field, and still readable to whoever opens the design later. ` +
338
+ `Assembling a cart, a bell or a chevron out of rects, ellipses and lines is the failure mode to avoid. ` +
339
+ `Common names, enough for most interfaces: ${CORE_ICONS.join(", ")}. ` +
340
+ `For anything else, call search_icons with a description — it also matches synonyms.`;
324
341
  /**
325
342
  * Propriétés d'un noeud exposées à l'agent. Les descriptions viennent de
326
343
  * shared/node-fields.ts, partagées avec le schéma zod : l'unité d'un champ n'est
@@ -340,6 +357,8 @@ const NODE_PROPERTIES = {
340
357
  stroke: { type: "string", description: NODE_FIELD_DOCS.stroke },
341
358
  strokeWidth: { type: "number", minimum: 0, description: NODE_FIELD_DOCS.strokeWidth },
342
359
  radius: { type: "number", minimum: 0, description: NODE_FIELD_DOCS.radius },
360
+ icon: { type: "string", description: NODE_FIELD_DOCS.icon },
361
+ componentId: { type: "string", description: NODE_FIELD_DOCS.componentId },
343
362
  text: { type: "string", description: NODE_FIELD_DOCS.text },
344
363
  color: { type: "string", description: NODE_FIELD_DOCS.color },
345
364
  fontSize: { type: "number", exclusiveMinimum: 0, description: NODE_FIELD_DOCS.fontSize },
@@ -362,6 +381,18 @@ const HIERARCHY_DOC = "STRUCTURE THE ARTBOARD AS A TREE OF NESTED GROUPS, NOT AS
362
381
  "Build the hierarchy as you go: create a \"group\" node and, in the SAME add_nodes call, give its children that group's id as parentId — a child's x/y are then " +
363
382
  "read from the group's origin, so the whole block moves as one. Name each group after what it is (\"Header\", \"Card / Pricing\", \"Nav item\"), never \"Groupe 1\": " +
364
383
  "those names are what the user reads in the layer panel and what an export turns into component names.";
384
+ /**
385
+ * Consigne sur les groupes reutilisables, repetee partout ou une repetition se
386
+ * cree. Sans elle, l'agent recopie le meme bloc N fois : le fichier gonfle, et
387
+ * la moindre retouche doit etre refaite autant de fois.
388
+ */
389
+ const INSTANCE_DOC = "REPEAT A BLOCK WITH INSTANCES, NOT WITH COPIES. When the same block appears more than once (cards, list rows, " +
390
+ "menu items, buttons), build it ONCE as a group, then add nodes of type \"instance\" pointing at that group with " +
391
+ "componentId. An instance stores no content of its own: editing the group updates every instance at once, and the " +
392
+ "repeated block stays a single thing to read and to change — the way a main component works in Figma. " +
393
+ "Nothing can be overridden inside an instance (that is what keeps them in sync): when one copy must differ, either " +
394
+ "make it a second group, or call detach_instances on it. Instances can be nested (a card instance holding a button " +
395
+ "instance), but a component can never contain itself.";
365
396
  const DESIGN_ID_DOC = "Id of the design, as returned by list_designs or create_design.";
366
397
  const GROUP_PATH_DOC = "Page group this design belongs to, as a path like \"Marketing\" or \"Marketing/Landing\" (the \"/\" nests sub-groups). Pages sharing a path are folded together in the sidebar, which is how a project with many pages stays readable. Omit or pass null to leave the page at the root.";
367
398
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
@@ -429,7 +460,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
429
460
  description: `Add one or more nodes on top of the design. Node types: ${Object.keys(NODE_DEFAULTS).join(", ")}. ` +
430
461
  NODE_FIELDS_DOC +
431
462
  " Nodes are drawn in array order, so the last one added sits on top. " +
432
- HIERARCHY_DOC,
463
+ HIERARCHY_DOC +
464
+ " " +
465
+ ICON_DOC +
466
+ " " +
467
+ INSTANCE_DOC,
433
468
  inputSchema: {
434
469
  type: "object",
435
470
  properties: {
@@ -440,7 +475,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
440
475
  items: {
441
476
  type: "object",
442
477
  properties: {
443
- type: { type: "string", enum: ["rect", "ellipse", "line", "text", "group"], description: NODE_FIELD_DOCS.type },
478
+ type: {
479
+ type: "string",
480
+ enum: ["rect", "ellipse", "line", "text", "icon", "group", "instance"],
481
+ description: NODE_FIELD_DOCS.type,
482
+ },
444
483
  ...NODE_PROPERTIES,
445
484
  },
446
485
  required: ["type"],
@@ -456,6 +495,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
456
495
  description: "Patch existing nodes. Each entry needs an id plus only the fields to change; everything else is preserved. " +
457
496
  "Changing parentId is how an existing element is moved into (or out of) a group, which is the way to deepen a structure that came out too flat. " +
458
497
  NODE_FIELDS_DOC +
498
+ " On an instance, only name, position, rotation, opacity, parentId and componentId apply — patch the nodes of its " +
499
+ "component to change how every instance looks. " +
459
500
  " The whole batch is validated before anything is written, so an invalid entry changes nothing.",
460
501
  inputSchema: {
461
502
  type: "object",
@@ -480,7 +521,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
480
521
  },
481
522
  {
482
523
  name: "delete_nodes",
483
- description: "Remove nodes from a design by id. Deleting a group also deletes everything inside it.",
524
+ description: "Remove nodes from a design by id. Deleting a group also deletes everything inside it. " +
525
+ "A group still mirrored by instances cannot be deleted — delete those instances too, or detach them first.",
484
526
  inputSchema: {
485
527
  type: "object",
486
528
  properties: {
@@ -493,7 +535,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
493
535
  {
494
536
  name: "duplicate_nodes",
495
537
  description: "Copy elements or whole groups. A group is copied with everything inside it, and every copy gets fresh ids. " +
496
- "This is the tool for repeated structures — a card, a list row, a section — build one carefully, then copy it and patch only what differs. " +
538
+ "Use it for a one-off copy that is MEANT TO DIVERGE. For a block repeated identically, do not copy it: " +
539
+ "instantiate it instead (add_nodes with type \"instance\"), so a single edit updates every occurrence. " +
497
540
  "Copies land next to the originals by default; use offset_x/offset_y to place them, count to lay out a series, and parent_id to drop them into another group.",
498
541
  inputSchema: {
499
542
  type: "object",
@@ -520,7 +563,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
520
563
  name: "group_nodes",
521
564
  description: "Wrap existing nodes into a new group, so they can be moved, hidden or reordered together. The nodes must currently share the same parent, and nothing moves on screen: their coordinates are rebased onto the new group. " +
522
565
  "Call it repeatedly to build depth: group the nodes of one card, then group the cards into a row, then the row with its heading into a section. " +
523
- HIERARCHY_DOC,
566
+ "A group is also what an instance mirrors: group a block once, then repeat it with instances. " +
567
+ HIERARCHY_DOC +
568
+ " " +
569
+ INSTANCE_DOC,
524
570
  inputSchema: {
525
571
  type: "object",
526
572
  properties: {
@@ -534,7 +580,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
534
580
  },
535
581
  {
536
582
  name: "ungroup_nodes",
537
- description: "Dissolve groups: their content moves up one level, keeping its position on screen. The groups themselves are removed.",
583
+ description: "Dissolve groups: their content moves up one level, keeping its position on screen. The groups themselves are removed. " +
584
+ "A group still mirrored by instances cannot be dissolved — detach those instances first.",
538
585
  inputSchema: {
539
586
  type: "object",
540
587
  properties: {
@@ -544,6 +591,21 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
544
591
  required: ["design_id", "group_ids"],
545
592
  },
546
593
  },
594
+ {
595
+ name: "detach_instances",
596
+ description: "Break the link between instances and their component: the content becomes ordinary nodes, editable one by one, " +
597
+ "and stops following the component. This is the way out when a single copy must differ — nothing can be overridden " +
598
+ "inside an instance. Each node keeps its id, name and position; it simply becomes a group. An instance nested " +
599
+ "inside the component stays an instance.",
600
+ inputSchema: {
601
+ type: "object",
602
+ properties: {
603
+ design_id: { type: "string", description: DESIGN_ID_DOC },
604
+ node_ids: { type: "array", items: { type: "string" }, description: "Ids of the instances to detach.", minItems: 1 },
605
+ },
606
+ required: ["design_id", "node_ids"],
607
+ },
608
+ },
547
609
  {
548
610
  name: "reorder_nodes",
549
611
  description: "Change the stacking order of nodes — which one is drawn on top of which. This is the layer panel, agent side. " +
@@ -654,6 +716,27 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
654
716
  required: ["design_id"],
655
717
  },
656
718
  },
719
+ {
720
+ name: "search_icons",
721
+ description: `Find an icon by description among the ${iconCount()} built into the canvas (Lucide), and get its name for the "icon" field of an "icon" node. ` +
722
+ `Search in English, one concept at a time ("cart", "warning", "user profile"); synonyms are matched too, so "basket" finds "shopping-cart". ` +
723
+ `The common icons are already listed in add_nodes — call this tool for anything they do not cover, not before every icon.`,
724
+ inputSchema: {
725
+ type: "object",
726
+ properties: {
727
+ query: { type: "string", description: 'What the icon should depict, e.g. "shopping cart", "download", "alert".' },
728
+ limit: {
729
+ type: "number",
730
+ description: `How many results to return (default ${DEFAULT_ICON_RESULTS}, max ${MAX_ICON_RESULTS}).`,
731
+ },
732
+ preview: {
733
+ type: "boolean",
734
+ description: "Return a thumbnail sheet of the results alongside their names (default true). Set false to save context.",
735
+ },
736
+ },
737
+ required: ["query"],
738
+ },
739
+ },
657
740
  {
658
741
  name: "reconnect_gui",
659
742
  description: "Relaunch the GUI worker and re-register it with the proxy. Use it when the canvas is unreachable, or after starting the proxy server.",
@@ -718,6 +801,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
718
801
  notifyDesignChanged(result.design.id);
719
802
  return result;
720
803
  }
804
+ case "detach_instances": {
805
+ const result = detachInstances(store, params);
806
+ notifyDesignChanged(result.design.id);
807
+ return result;
808
+ }
721
809
  case "reorder_nodes": {
722
810
  const result = reorderNodesTool(store, params);
723
811
  notifyDesignChanged(result.design.id);
@@ -739,6 +827,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
739
827
  return exportDesignTool(store, params);
740
828
  case "screenshot_design":
741
829
  return screenshotDesignTool(store, params);
830
+ case "search_icons":
831
+ return searchIconsTool(params);
742
832
  case "reconnect_gui":
743
833
  return await reconnectGui(params);
744
834
  default:
@@ -18,7 +18,13 @@ export function addNodes(store, args) {
18
18
  const design = store.mutate(id, "agent", (draft) => {
19
19
  draft.nodes.push(...created);
20
20
  });
21
- const lines = created.map((n) => `- ${n.id} — "${n.name}" (${n.type}) at ${Math.round(n.x)},${Math.round(n.y)} size ${Math.round(n.width)}x${Math.round(n.height)}`);
21
+ // Les tailles sont relues APRES l'écriture : celle d'un groupe — ou d'une
22
+ // instance — est déduite de son contenu, donc inconnue avant la mutation.
23
+ const byId = new Map(design.nodes.map((n) => [n.id, n]));
24
+ const lines = created.map((node) => {
25
+ const n = byId.get(node.id) ?? node;
26
+ return `- ${n.id} — "${n.name}" (${n.type}) at ${Math.round(n.x)},${Math.round(n.y)} size ${Math.round(n.width)}x${Math.round(n.height)}`;
27
+ });
22
28
  return {
23
29
  ...textResult([`Added ${created.length} node(s) to "${design.name}":`, ...lines].join("\n")),
24
30
  design,
@@ -1,5 +1,5 @@
1
1
  import { DesignError } from "../core/node-factory.js";
2
- import { withDescendants } from "../core/hierarchy.js";
2
+ import { survivingInstancesOf, withDescendants } from "../core/hierarchy.js";
3
3
  import { requireString, textResult } from "./types.js";
4
4
  export function deleteNodes(store, args) {
5
5
  const id = requireString(args, "design_id");
@@ -17,6 +17,15 @@ export function deleteNodes(store, args) {
17
17
  // des noeuds orphelins pointant vers un parent disparu.
18
18
  const toRemove = withDescendants(current.nodes, nodeIds.map((nodeId) => String(nodeId)));
19
19
  const nested = toRemove.size - nodeIds.length;
20
+ // Un composant encore instancie ne peut pas disparaitre : ses instances
21
+ // n'auraient plus rien a reproduire.
22
+ const orphaned = survivingInstancesOf(current.nodes, toRemove);
23
+ if (orphaned.length > 0) {
24
+ const groups = [...new Set(orphaned.map((instance) => instance.componentId))];
25
+ throw new DesignError(`Group(s) ${groups.join(", ")} are still mirrored by ${orphaned.length} instance(s) (${orphaned
26
+ .map((instance) => instance.id)
27
+ .join(", ")}). Delete those instances too, or run detach_instances on them first.`);
28
+ }
20
29
  const design = store.mutate(id, "agent", (draft) => {
21
30
  draft.nodes = draft.nodes.filter((n) => !toRemove.has(n.id));
22
31
  });
@@ -0,0 +1,14 @@
1
+ import type { DesignStore } from "../core/design-store.js";
2
+ import type { MutationResult } from "./add-nodes.js";
3
+ /**
4
+ * Rompt le lien d'une instance avec son composant : le contenu devient des
5
+ * noeuds ordinaires, editables un par un, et cesse de suivre le composant.
6
+ *
7
+ * C'est la soupape du modele : rien ne se surcharge dans une instance, donc une
8
+ * copie qui doit diverger se detache. Le noeud garde son id, son nom et sa
9
+ * position — il devient simplement un groupe, a la meme place dans la pile.
10
+ *
11
+ * Une instance imbriquee dans le composant reste, elle, une instance : detacher
12
+ * une carte ne detache pas les boutons qu'elle contient.
13
+ */
14
+ export declare function detachInstances(store: DesignStore, args: Record<string, unknown> | undefined): MutationResult;
@@ -0,0 +1,61 @@
1
+ import { childrenByParent } from "../../shared/tree.js";
2
+ import { cloneSubtrees } from "../core/clone.js";
3
+ import { DesignError } from "../core/node-factory.js";
4
+ import { requireString, textResult } from "./types.js";
5
+ /**
6
+ * Rompt le lien d'une instance avec son composant : le contenu devient des
7
+ * noeuds ordinaires, editables un par un, et cesse de suivre le composant.
8
+ *
9
+ * C'est la soupape du modele : rien ne se surcharge dans une instance, donc une
10
+ * copie qui doit diverger se detache. Le noeud garde son id, son nom et sa
11
+ * position — il devient simplement un groupe, a la meme place dans la pile.
12
+ *
13
+ * Une instance imbriquee dans le composant reste, elle, une instance : detacher
14
+ * une carte ne detache pas les boutons qu'elle contient.
15
+ */
16
+ export function detachInstances(store, args) {
17
+ const id = requireString(args, "design_id");
18
+ const nodeIds = args?.node_ids;
19
+ if (!Array.isArray(nodeIds) || nodeIds.length === 0) {
20
+ throw new Error(`"node_ids" is required and must be a non-empty array of instance ids.`);
21
+ }
22
+ const current = store.get(id);
23
+ const byId = new Map(current.nodes.map((node) => [node.id, node]));
24
+ const instances = nodeIds.map((raw) => {
25
+ const node = byId.get(String(raw));
26
+ if (!node)
27
+ throw new DesignError(`Node "${String(raw)}" not found in design "${id}".`);
28
+ if (node.type !== "instance") {
29
+ throw new DesignError(`Node "${node.id}" is a "${node.type}", not an instance — nothing to detach.`);
30
+ }
31
+ return node;
32
+ });
33
+ const children = childrenByParent(current.nodes);
34
+ let materialized = 0;
35
+ const design = store.mutate(id, "agent", (draft) => {
36
+ const result = [];
37
+ for (const node of draft.nodes) {
38
+ const instance = instances.find((candidate) => candidate.id === node.id);
39
+ if (!instance) {
40
+ result.push(node);
41
+ continue;
42
+ }
43
+ // L'instance prend la place du groupe qu'elle reproduisait, au meme rang
44
+ // dans la pile ; le contenu du composant est copie juste derriere.
45
+ result.push({ ...node, type: "group", componentId: undefined });
46
+ const sourceChildren = (children.get(instance.componentId ?? "") ?? []).map((child) => child.id);
47
+ const clone = cloneSubtrees(current.nodes, sourceChildren);
48
+ const rootSet = new Set(clone.rootIds);
49
+ for (const copy of clone.copies) {
50
+ result.push(rootSet.has(copy.id) ? { ...copy, parentId: node.id } : copy);
51
+ }
52
+ materialized += clone.copies.length;
53
+ }
54
+ draft.nodes = result;
55
+ });
56
+ return {
57
+ ...textResult(`Detached ${instances.length} instance(s) in "${design.name}": ${materialized} node(s) are now ordinary nodes ` +
58
+ `and no longer follow their component.`),
59
+ design,
60
+ };
61
+ }
@@ -1,6 +1,6 @@
1
- import { descendantIds, withoutRedundantDescendants } from "../../shared/tree.js";
1
+ import { withoutRedundantDescendants } from "../../shared/tree.js";
2
+ import { cloneSubtrees } from "../core/clone.js";
2
3
  import { DesignError } from "../core/node-factory.js";
3
- import { shortId } from "../core/ids.js";
4
4
  import { requireString, textResult } from "./types.js";
5
5
  /** Décalage par défaut d'une copie, en pixels : assez pour la voir, assez peu pour la retrouver. */
6
6
  const DEFAULT_OFFSET = 20;
@@ -56,36 +56,28 @@ export function duplicateNodes(store, args) {
56
56
  parentId = parent.id;
57
57
  }
58
58
  }
59
- // Le sous-arbre complet de chaque racine, dans l'ordre du document : l'ordre
60
- // d'empilement relatif des copies reste celui des originaux.
61
- const included = new Set(roots);
62
- for (const rootId of roots)
63
- for (const descendant of descendantIds(current.nodes, rootId))
64
- included.add(descendant);
65
- const source = current.nodes.filter((node) => included.has(node.id));
66
59
  const copies = [];
67
60
  const createdRootIds = [];
68
61
  for (let copyIndex = 1; copyIndex <= count; copyIndex++) {
69
- const idMap = new Map(source.map((node) => [node.id, shortId(node.type === "group" ? "g" : "n")]));
70
- for (const node of source) {
71
- const isRoot = roots.includes(node.id);
72
- const copy = {
73
- ...structuredClone(node),
74
- id: idMap.get(node.id),
75
- // Seules les racines sont décalées et reparentées : les descendants sont
76
- // exprimés dans l'espace de leur parent et suivent donc tout seuls.
77
- parentId: isRoot
78
- ? parentId === undefined
79
- ? node.parentId
80
- : parentId
81
- : (idMap.get(node.parentId ?? "") ?? node.parentId),
82
- x: node.x + (isRoot ? offsetX * copyIndex : 0),
83
- y: node.y + (isRoot ? offsetY * copyIndex : 0),
84
- };
85
- copies.push(copy);
86
- if (isRoot)
87
- createdRootIds.push(copy.id);
62
+ // Un sous-arbre complet par copie : les ids sont neufs et les liens
63
+ // parent/enfant recables (cf. src/core/clone.ts).
64
+ const clone = cloneSubtrees(current.nodes, roots);
65
+ const rootSet = new Set(clone.rootIds);
66
+ for (const copy of clone.copies) {
67
+ // Seules les racines sont decalees et reparentees : les descendants sont
68
+ // exprimes dans l'espace de leur parent et suivent donc tout seuls.
69
+ if (!rootSet.has(copy.id)) {
70
+ copies.push(copy);
71
+ continue;
72
+ }
73
+ copies.push({
74
+ ...copy,
75
+ parentId: parentId === undefined ? copy.parentId : parentId,
76
+ x: copy.x + offsetX * copyIndex,
77
+ y: copy.y + offsetY * copyIndex,
78
+ });
88
79
  }
80
+ createdRootIds.push(...clone.rootIds);
89
81
  }
90
82
  const design = store.mutate(id, "agent", (draft) => {
91
83
  draft.nodes.push(...copies);
@@ -0,0 +1,12 @@
1
+ import { type ImageToolResult } from "./types.js";
2
+ /** Au-dela, la planche coute plus de contexte qu'elle n'aide a choisir. */
3
+ export declare const MAX_ICON_RESULTS = 48;
4
+ export declare const DEFAULT_ICON_RESULTS = 24;
5
+ /**
6
+ * Recherche d'une icone par description.
7
+ *
8
+ * C'est la traine longue de la bibliotheque : les icones les plus courantes
9
+ * sont deja listees dans la description de `add_nodes`, donc un agent qui
10
+ * habille une interface ordinaire n'a pas besoin de passer par ici.
11
+ */
12
+ export declare function searchIconsTool(args: Record<string, unknown> | undefined): ImageToolResult;