@imenam/mcp-design 1.0.1 → 1.0.3

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.
@@ -11,7 +11,9 @@ export const NODE_FIELD_DOCS = {
11
11
  type: "Kind of node: rect, ellipse, line, text or group.",
12
12
  name: "Layer name shown in the GUI. Purely descriptive.",
13
13
  parentId: "Id of the containing group, or null for the root level of the artboard. " +
14
- "Changing it reparents the node — its x/y are then read in the new parent's space, so it moves on screen.",
14
+ "Changing it reparents the node — its x/y are then read in the new parent's space, so it moves on screen. " +
15
+ "This is the field that builds the structure: nest sections, then blocks, then the nodes of a block, rather than dropping " +
16
+ "everything into one group. Only a handful of top-level sections should sit at the root.",
15
17
  x: "Horizontal position of the bounding box's left edge, in pixels, in the PARENT's coordinate space " +
16
18
  "(the artboard's top-left corner when parentId is null, the group's origin otherwise). " +
17
19
  "On a line, this is the start point.",
@@ -115,9 +115,20 @@ export declare const BaselineSchema: z.ZodObject<{
115
115
  }, z.core.$strip>>;
116
116
  }, z.core.$strip>;
117
117
  export type Baseline = z.infer<typeof BaselineSchema>;
118
+ /**
119
+ * Chemin du groupe de pages auquel appartient un design, ou null a la racine.
120
+ *
121
+ * C'est un simple chemin separe par des "/" plutot qu'une entite a part : un
122
+ * groupe n'existe que tant qu'une page s'y trouve, donc rien a creer, rien a
123
+ * nettoyer, et le classement reste lisible dans le JSON.
124
+ */
125
+ export declare const DESIGN_GROUP_SEPARATOR = "/";
126
+ /** " Marketing / Landing " -> "Marketing/Landing" ; "" ou null -> null. */
127
+ export declare function normalizeDesignGroup(value: unknown): string | null;
118
128
  export declare const DesignSchema: z.ZodObject<{
119
129
  id: z.ZodString;
120
130
  name: z.ZodString;
131
+ group: z.ZodDefault<z.ZodNullable<z.ZodString>>;
121
132
  width: z.ZodNumber;
122
133
  height: z.ZodNumber;
123
134
  background: z.ZodString;
@@ -211,6 +222,8 @@ export type Design = z.infer<typeof DesignSchema>;
211
222
  export interface DesignSummary {
212
223
  id: string;
213
224
  name: string;
225
+ /** Groupe de pages, chemin "Parent/Enfant", ou null a la racine. */
226
+ group: string | null;
214
227
  width: number;
215
228
  height: number;
216
229
  nodeCount: number;
@@ -73,9 +73,29 @@ export const BaselineSchema = z.object({
73
73
  at: z.string(),
74
74
  nodes: z.array(DesignNodeSchema),
75
75
  });
76
+ /**
77
+ * Chemin du groupe de pages auquel appartient un design, ou null a la racine.
78
+ *
79
+ * C'est un simple chemin separe par des "/" plutot qu'une entite a part : un
80
+ * groupe n'existe que tant qu'une page s'y trouve, donc rien a creer, rien a
81
+ * nettoyer, et le classement reste lisible dans le JSON.
82
+ */
83
+ export const DESIGN_GROUP_SEPARATOR = "/";
84
+ /** " Marketing / Landing " -> "Marketing/Landing" ; "" ou null -> null. */
85
+ export function normalizeDesignGroup(value) {
86
+ if (value === null || value === undefined)
87
+ return null;
88
+ const segments = String(value)
89
+ .split(DESIGN_GROUP_SEPARATOR)
90
+ .map((segment) => segment.trim())
91
+ .filter((segment) => segment !== "");
92
+ return segments.length === 0 ? null : segments.join(DESIGN_GROUP_SEPARATOR);
93
+ }
76
94
  export const DesignSchema = z.object({
77
95
  id: z.string(),
78
96
  name: z.string(),
97
+ /** Groupe de pages, chemin "Parent/Enfant", ou null a la racine. */
98
+ group: z.string().nullable().default(null),
79
99
  width: z.number().positive(),
80
100
  height: z.number().positive(),
81
101
  background: z.string(),
@@ -4,6 +4,8 @@ export interface CreateDesignInput {
4
4
  width?: number;
5
5
  height?: number;
6
6
  background?: string;
7
+ /** Groupe de pages, chemin "Parent/Enfant". Null ou omis : a la racine. */
8
+ group?: string | null;
7
9
  }
8
10
  export interface MutateOptions {
9
11
  /**
@@ -1,6 +1,6 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
- import { DesignSchema, } from "../../shared/schemas/design.schema.js";
3
+ import { DesignSchema, normalizeDesignGroup, } from "../../shared/schemas/design.schema.js";
4
4
  import { DesignError } from "./node-factory.js";
5
5
  import { normalizeHierarchy } from "./hierarchy.js";
6
6
  import { migrateDesign } from "./migrate.js";
@@ -151,6 +151,7 @@ export class DesignStore {
151
151
  summaries.push({
152
152
  id: design.id,
153
153
  name: design.name,
154
+ group: design.group,
154
155
  width: design.width,
155
156
  height: design.height,
156
157
  nodeCount: design.nodes.length,
@@ -225,6 +226,7 @@ export class DesignStore {
225
226
  const design = {
226
227
  id: this.allocateId(name),
227
228
  name,
229
+ group: normalizeDesignGroup(input.group),
228
230
  width,
229
231
  height,
230
232
  background: input.background ?? "#ffffff",
@@ -227,6 +227,19 @@ app.put("/api/designs/:id", async (c) => {
227
227
  return c.json({ error: result.error }, 400);
228
228
  return c.json(result.data);
229
229
  });
230
+ app.put("/api/designs/:id/group", async (c) => {
231
+ let body = {};
232
+ try {
233
+ body = await c.req.json();
234
+ }
235
+ catch {
236
+ return c.json({ error: "Invalid JSON body" }, 400);
237
+ }
238
+ const result = await ipcRequest("SET_DESIGN_GROUP", { id: c.req.param("id"), group: body?.group ?? null }, 10000);
239
+ if (result.error)
240
+ return c.json({ error: result.error }, 400);
241
+ return c.json(result.data);
242
+ });
230
243
  app.delete("/api/designs/:id", async (c) => {
231
244
  const result = await ipcRequest("DELETE_DESIGN", { id: c.req.param("id") }, 10000);
232
245
  if (result.error)
package/dist/src/index.js CHANGED
@@ -15,6 +15,7 @@ import { EXPORT_FORMATS } from "./core/export.js";
15
15
  import { MIN_LINE_HEIGHT_PX } from "../shared/render.js";
16
16
  import { NODE_FIELD_DOCS } from "../shared/node-fields.js";
17
17
  import { REORDER_DIRECTIONS } from "../shared/tree.js";
18
+ import { normalizeDesignGroup } from "../shared/schemas/design.schema.js";
18
19
  import { listDesigns } from "./tools/list-designs.js";
19
20
  import { createDesign } from "./tools/create-design.js";
20
21
  import { getDesign } from "./tools/get-design.js";
@@ -23,7 +24,9 @@ import { deleteDesign } from "./tools/delete-design.js";
23
24
  import { addNodes } from "./tools/add-nodes.js";
24
25
  import { updateNodes } from "./tools/update-nodes.js";
25
26
  import { deleteNodes } from "./tools/delete-nodes.js";
27
+ import { duplicateNodes, MAX_DUPLICATE_COPIES } from "./tools/duplicate-nodes.js";
26
28
  import { groupNodes } from "./tools/group-nodes.js";
29
+ import { groupDesigns } from "./tools/group-designs.js";
27
30
  import { ungroupNodes } from "./tools/ungroup-nodes.js";
28
31
  import { getChanges } from "./tools/get-changes.js";
29
32
  import { markSynced } from "./tools/mark-synced.js";
@@ -131,6 +134,7 @@ function handleIPCMessage(msg) {
131
134
  width: msg.data?.width === undefined ? undefined : Number(msg.data.width),
132
135
  height: msg.data?.height === undefined ? undefined : Number(msg.data.height),
133
136
  background: msg.data?.background,
137
+ group: msg.data?.group ?? null,
134
138
  });
135
139
  reply(msg, "CREATE_DESIGN_RESPONSE", { design, designs: store.list() });
136
140
  }
@@ -155,6 +159,17 @@ function handleIPCMessage(msg) {
155
159
  replyError(msg, "DELETE_DESIGN_RESPONSE", err.message);
156
160
  }
157
161
  return;
162
+ case "SET_DESIGN_GROUP":
163
+ try {
164
+ store.mutate(String(msg.data?.id), "user", (draft) => {
165
+ draft.group = normalizeDesignGroup(msg.data?.group);
166
+ });
167
+ reply(msg, "SET_DESIGN_GROUP_RESPONSE", { designs: store.list() });
168
+ }
169
+ catch (err) {
170
+ replyError(msg, "SET_DESIGN_GROUP_RESPONSE", err.message);
171
+ }
172
+ return;
158
173
  case "GET_CONFIG":
159
174
  reply(msg, "GET_CONFIG_RESPONSE", { dataDir: store.getDataDir(), version: APP_VERSION });
160
175
  return;
@@ -334,7 +349,21 @@ const NODE_PROPERTIES = {
334
349
  textAlign: { type: "string", enum: ["left", "center", "right"], description: NODE_FIELD_DOCS.textAlign },
335
350
  lineHeight: { type: "number", minimum: MIN_LINE_HEIGHT_PX, description: NODE_FIELD_DOCS.lineHeight },
336
351
  };
352
+ /**
353
+ * Consigne de structure, repetee dans les outils qui creent ou reorganisent des
354
+ * noeuds. Sans elle, un agent produit spontanement un seul groupe contenant
355
+ * quarante elements a plat : le design devient impossible a selectionner, a
356
+ * deplacer ou a exporter en composants.
357
+ */
358
+ const HIERARCHY_DOC = "STRUCTURE THE ARTBOARD AS A TREE OF NESTED GROUPS, NOT AS ONE FLAT GROUP. " +
359
+ "A single group holding every element is the failure mode to avoid: nothing can then be moved, hidden, restyled or exported on its own. " +
360
+ "Mirror the structure real code would have — page > section (header, hero, feature list, footer) > repeated block (a card, a list row, a form field) > " +
361
+ "the few nodes of that block (icon, title, body, button). Three to four levels of nesting is normal, and any element that repeats deserves its own group. " +
362
+ "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
+ "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
+ "those names are what the user reads in the layer panel and what an export turns into component names.";
337
365
  const DESIGN_ID_DOC = "Id of the design, as returned by list_designs or create_design.";
366
+ 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.";
338
367
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
339
368
  tools: [
340
369
  {
@@ -344,7 +373,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
344
373
  },
345
374
  {
346
375
  name: "create_design",
347
- description: "Create an empty design (artboard). Returns its id, which every other tool needs. Default canvas is 1440x900 on a white background.",
376
+ description: "Create an empty design (artboard). Returns its id, which every other tool needs. Default canvas is 1440x900 on a white background. " +
377
+ "As soon as a project has more than a handful of pages, pass `group` so they are filed under a named page group instead of piling up flat in the sidebar.",
348
378
  inputSchema: {
349
379
  type: "object",
350
380
  properties: {
@@ -352,6 +382,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
352
382
  width: { type: "number", description: "Canvas width in pixels (default 1440). Use 375 for mobile, 1440 for desktop." },
353
383
  height: { type: "number", description: "Canvas height in pixels (default 900)." },
354
384
  background: { type: "string", description: "CSS color of the canvas background (default #ffffff)." },
385
+ group: { type: ["string", "null"], description: GROUP_PATH_DOC },
355
386
  },
356
387
  required: ["name"],
357
388
  },
@@ -370,12 +401,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
370
401
  },
371
402
  {
372
403
  name: "update_design",
373
- description: "Change the artboard itself: its name, its dimensions or its background color. Does not touch the nodes.",
404
+ description: "Change the artboard itself: its name, its page group, its dimensions or its background color. Does not touch the nodes.",
374
405
  inputSchema: {
375
406
  type: "object",
376
407
  properties: {
377
408
  design_id: { type: "string", description: DESIGN_ID_DOC },
378
409
  name: { type: "string", description: "New name." },
410
+ group: { type: ["string", "null"], description: `New page group. ${GROUP_PATH_DOC} Pass null to move the page back to the root.` },
379
411
  width: { type: "number", description: "New canvas width in pixels." },
380
412
  height: { type: "number", description: "New canvas height in pixels." },
381
413
  background: { type: "string", description: "New CSS background color." },
@@ -396,7 +428,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
396
428
  name: "add_nodes",
397
429
  description: `Add one or more nodes on top of the design. Node types: ${Object.keys(NODE_DEFAULTS).join(", ")}. ` +
398
430
  NODE_FIELDS_DOC +
399
- " Nodes are drawn in array order, so the last one added sits on top.",
431
+ " Nodes are drawn in array order, so the last one added sits on top. " +
432
+ HIERARCHY_DOC,
400
433
  inputSchema: {
401
434
  type: "object",
402
435
  properties: {
@@ -421,6 +454,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
421
454
  {
422
455
  name: "update_nodes",
423
456
  description: "Patch existing nodes. Each entry needs an id plus only the fields to change; everything else is preserved. " +
457
+ "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. " +
424
458
  NODE_FIELDS_DOC +
425
459
  " The whole batch is validated before anything is written, so an invalid entry changes nothing.",
426
460
  inputSchema: {
@@ -456,9 +490,37 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
456
490
  required: ["design_id", "node_ids"],
457
491
  },
458
492
  },
493
+ {
494
+ name: "duplicate_nodes",
495
+ 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. " +
497
+ "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
+ inputSchema: {
499
+ type: "object",
500
+ properties: {
501
+ design_id: { type: "string", description: DESIGN_ID_DOC },
502
+ node_ids: {
503
+ type: "array",
504
+ items: { type: "string" },
505
+ description: "Ids of the elements or groups to copy. A node whose ancestor is also listed is ignored: the ancestor already carries it.",
506
+ minItems: 1,
507
+ },
508
+ offset_x: { type: "number", description: "Horizontal shift of each copy, in pixels (default 20). Use the element width plus a gutter to lay copies out in a row." },
509
+ offset_y: { type: "number", description: "Vertical shift of each copy, in pixels (default 20). Use the element height plus a gutter to stack copies in a column." },
510
+ count: { type: "number", description: `How many copies to make (default 1, max ${MAX_DUPLICATE_COPIES}). Copy n is shifted by n times the offset, which lays out a regular series in one call.` },
511
+ parent_id: {
512
+ type: ["string", "null"],
513
+ description: "Group the copies are dropped into. Their x/y are then read in that group's space. Omit to keep the original's parent, or pass null to place the copies at the root.",
514
+ },
515
+ },
516
+ required: ["design_id", "node_ids"],
517
+ },
518
+ },
459
519
  {
460
520
  name: "group_nodes",
461
- 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.",
521
+ 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
+ "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,
462
524
  inputSchema: {
463
525
  type: "object",
464
526
  properties: {
@@ -510,6 +572,28 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
510
572
  required: ["design_id", "node_ids", "position"],
511
573
  },
512
574
  },
575
+ {
576
+ name: "group_designs",
577
+ description: "File several pages into the same page group, or pull them back to the root. This is how the sidebar gets reorganised after the fact: " +
578
+ "create the pages first, then sort them, or move a whole batch under a new heading. " +
579
+ GROUP_PATH_DOC,
580
+ inputSchema: {
581
+ type: "object",
582
+ properties: {
583
+ design_ids: {
584
+ type: "array",
585
+ items: { type: "string" },
586
+ description: "Ids of the pages to file, as returned by list_designs.",
587
+ minItems: 1,
588
+ },
589
+ group: {
590
+ type: ["string", "null"],
591
+ description: `Destination page group. ${GROUP_PATH_DOC} Pass null to move the pages back to the root.`,
592
+ },
593
+ },
594
+ required: ["design_ids", "group"],
595
+ },
596
+ },
513
597
  {
514
598
  name: "get_changes",
515
599
  description: "Show every node added, modified, removed or reordered since the last mark_synced — this is how you pick up what the user changed by hand in the GUI. Reading changes never acknowledges them; call mark_synced once the changes are integrated into the code.",
@@ -619,6 +703,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
619
703
  notifyDesignChanged(result.design.id);
620
704
  return result;
621
705
  }
706
+ case "duplicate_nodes": {
707
+ const result = duplicateNodes(store, params);
708
+ notifyDesignChanged(result.design.id);
709
+ return result;
710
+ }
622
711
  case "group_nodes": {
623
712
  const result = groupNodes(store, params);
624
713
  notifyDesignChanged(result.design.id);
@@ -634,6 +723,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
634
723
  notifyDesignChanged(result.design.id);
635
724
  return result;
636
725
  }
726
+ case "group_designs": {
727
+ const result = groupDesigns(store, params);
728
+ broadcastToGUI({ type: "DESIGNS_UPDATED", data: { designs: store.list() }, timestamp: new Date().toISOString() });
729
+ return result;
730
+ }
637
731
  case "get_changes":
638
732
  return getChanges(store, params);
639
733
  case "mark_synced": {
@@ -6,9 +6,11 @@ export function createDesign(store, args) {
6
6
  width: args?.width === undefined ? undefined : Number(args.width),
7
7
  height: args?.height === undefined ? undefined : Number(args.height),
8
8
  background: typeof args?.background === "string" ? args.background : undefined,
9
+ group: args?.group === undefined ? null : args.group,
9
10
  });
10
11
  return {
11
- ...textResult(`Design "${design.name}" created with id "${design.id}" (${design.width}x${design.height}, background ${design.background}).\n` +
12
+ ...textResult(`Design "${design.name}" created with id "${design.id}" (${design.width}x${design.height}, background ${design.background})` +
13
+ `${design.group === null ? "" : ` in page group "${design.group}"`}.\n` +
12
14
  `Add content with add_nodes, then open the GUI to let the user refine it.`),
13
15
  design,
14
16
  };
@@ -0,0 +1,11 @@
1
+ import type { DesignStore } from "../core/design-store.js";
2
+ import type { MutationResult } from "./add-nodes.js";
3
+ export declare const MAX_DUPLICATE_COPIES = 50;
4
+ /**
5
+ * Copie des éléments — un groupe emporte tout son contenu.
6
+ *
7
+ * Les identifiants sont réattribués et les liens parent/enfant recâblés sur les
8
+ * copies : sans cela, deux noeuds partageraient un id, ce qui casserait aussi
9
+ * bien le rendu que le diff relu par l'agent.
10
+ */
11
+ export declare function duplicateNodes(store: DesignStore, args: Record<string, unknown> | undefined): MutationResult;
@@ -0,0 +1,104 @@
1
+ import { descendantIds, withoutRedundantDescendants } from "../../shared/tree.js";
2
+ import { DesignError } from "../core/node-factory.js";
3
+ import { shortId } from "../core/ids.js";
4
+ import { requireString, textResult } from "./types.js";
5
+ /** Décalage par défaut d'une copie, en pixels : assez pour la voir, assez peu pour la retrouver. */
6
+ const DEFAULT_OFFSET = 20;
7
+ export const MAX_DUPLICATE_COPIES = 50;
8
+ function readNumber(args, field, fallback) {
9
+ if (args?.[field] === undefined)
10
+ return fallback;
11
+ const value = Number(args[field]);
12
+ if (!Number.isFinite(value))
13
+ throw new DesignError(`"${field}" must be a number.`);
14
+ return value;
15
+ }
16
+ /**
17
+ * Copie des éléments — un groupe emporte tout son contenu.
18
+ *
19
+ * Les identifiants sont réattribués et les liens parent/enfant recâblés sur les
20
+ * copies : sans cela, deux noeuds partageraient un id, ce qui casserait aussi
21
+ * bien le rendu que le diff relu par l'agent.
22
+ */
23
+ export function duplicateNodes(store, args) {
24
+ const id = requireString(args, "design_id");
25
+ const nodeIds = args?.node_ids;
26
+ if (!Array.isArray(nodeIds) || nodeIds.length === 0) {
27
+ throw new Error(`"node_ids" is required and must be a non-empty array of node ids.`);
28
+ }
29
+ const current = store.get(id);
30
+ const byId = new Map(current.nodes.map((node) => [node.id, node]));
31
+ for (const raw of nodeIds) {
32
+ if (!byId.has(String(raw)))
33
+ throw new DesignError(`Node "${String(raw)}" not found in design "${id}".`);
34
+ }
35
+ // Un élément dont le groupe est aussi listé serait copié deux fois : une fois
36
+ // pour lui-même, une fois avec son groupe.
37
+ const roots = withoutRedundantDescendants(current.nodes, nodeIds.map((raw) => String(raw)));
38
+ const offsetX = readNumber(args, "offset_x", DEFAULT_OFFSET);
39
+ const offsetY = readNumber(args, "offset_y", DEFAULT_OFFSET);
40
+ const count = readNumber(args, "count", 1);
41
+ if (!Number.isInteger(count) || count < 1 || count > MAX_DUPLICATE_COPIES) {
42
+ throw new DesignError(`"count" must be an integer between 1 and ${MAX_DUPLICATE_COPIES}.`);
43
+ }
44
+ let parentId;
45
+ if (args?.parent_id !== undefined) {
46
+ if (args.parent_id === null) {
47
+ parentId = null;
48
+ }
49
+ else {
50
+ const parent = byId.get(String(args.parent_id));
51
+ if (!parent)
52
+ throw new DesignError(`Group "${String(args.parent_id)}" not found in design "${id}".`);
53
+ if (parent.type !== "group") {
54
+ throw new DesignError(`"parent_id" must reference a group, but "${parent.id}" is a "${parent.type}".`);
55
+ }
56
+ parentId = parent.id;
57
+ }
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
+ const copies = [];
67
+ const createdRootIds = [];
68
+ 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);
88
+ }
89
+ }
90
+ const design = store.mutate(id, "agent", (draft) => {
91
+ draft.nodes.push(...copies);
92
+ });
93
+ const lines = copies
94
+ .filter((node) => createdRootIds.includes(node.id))
95
+ .map((node) => `- ${node.id} — "${node.name}" (${node.type}) at ${Math.round(node.x)},${Math.round(node.y)}`);
96
+ return {
97
+ ...textResult([
98
+ `Copied ${roots.length} node(s)${count > 1 ? ` ${count} times` : ""} in "${design.name}" ` +
99
+ `(${copies.length} node(s) created, content included). New top-level copies:`,
100
+ ...lines,
101
+ ].join("\n")),
102
+ design,
103
+ };
104
+ }
@@ -0,0 +1,10 @@
1
+ import type { DesignStore } from "../core/design-store.js";
2
+ import { type ToolResult } from "./types.js";
3
+ /**
4
+ * Range plusieurs pages dans un meme groupe, ou les en sort (group = null).
5
+ *
6
+ * Un outil dedie plutot qu'un appel a update_design par page : reorganiser la
7
+ * barre laterale se fait presque toujours par lots, et chaque appel est une
8
+ * ecriture disque verrouillee.
9
+ */
10
+ export declare function groupDesigns(store: DesignStore, args: Record<string, unknown> | undefined): ToolResult;
@@ -0,0 +1,33 @@
1
+ import { normalizeDesignGroup } from "../../shared/schemas/design.schema.js";
2
+ import { textResult } from "./types.js";
3
+ /**
4
+ * Range plusieurs pages dans un meme groupe, ou les en sort (group = null).
5
+ *
6
+ * Un outil dedie plutot qu'un appel a update_design par page : reorganiser la
7
+ * barre laterale se fait presque toujours par lots, et chaque appel est une
8
+ * ecriture disque verrouillee.
9
+ */
10
+ export function groupDesigns(store, args) {
11
+ const ids = args?.design_ids;
12
+ if (!Array.isArray(ids) || ids.length === 0) {
13
+ throw new Error(`"design_ids" is required and must be a non-empty array of design ids.`);
14
+ }
15
+ if (!("group" in (args ?? {}))) {
16
+ throw new Error(`"group" is required. Pass a path like "Marketing/Landing", or null to move the pages back to the root.`);
17
+ }
18
+ const group = normalizeDesignGroup(args.group);
19
+ // Toutes les pages sont verifiees avant la premiere ecriture : un id fautif
20
+ // ne doit pas laisser le classement a moitie applique.
21
+ const targets = ids.map((raw) => {
22
+ const id = String(raw);
23
+ if (!store.has(id))
24
+ throw new Error(`Design "${id}" not found. Use list_designs to see the available pages.`);
25
+ return id;
26
+ });
27
+ for (const id of targets) {
28
+ store.mutate(id, "agent", (draft) => {
29
+ draft.group = group;
30
+ });
31
+ }
32
+ return textResult(`Moved ${targets.length} page(s) ${group === null ? "back to the root" : `into page group "${group}"`}: ${targets.join(", ")}.`);
33
+ }
@@ -4,10 +4,27 @@ export function listDesigns(store) {
4
4
  if (designs.length === 0) {
5
5
  return textResult("No design yet. Use create_design to start one.");
6
6
  }
7
- const lines = designs.map((d) => {
7
+ const describe = (d) => {
8
8
  const pending = d.pendingChanges > 0 ? ` — ${d.pendingChanges} unsynced change(s)` : "";
9
9
  return (`- ${d.id} — "${d.name}" ${d.width}x${d.height}, ${d.nodeCount} node(s), ` +
10
10
  `rev ${d.revision} last edited by ${d.lastEditor} at ${d.updatedAt}${pending}`);
11
- });
11
+ };
12
+ // Presentées par groupe de pages : c'est la vue qu'a l'utilisateur dans la
13
+ // barre latérale, et celle qui montre à l'agent où ranger une nouvelle page.
14
+ const byGroup = new Map();
15
+ for (const design of designs) {
16
+ const key = design.group ?? "";
17
+ const bucket = byGroup.get(key);
18
+ if (bucket)
19
+ bucket.push(design);
20
+ else
21
+ byGroup.set(key, [design]);
22
+ }
23
+ const lines = [];
24
+ for (const key of [...byGroup.keys()].sort()) {
25
+ lines.push(key === "" ? "[no group]" : `[${key}]`);
26
+ for (const design of byGroup.get(key))
27
+ lines.push(` ${describe(design)}`);
28
+ }
12
29
  return textResult([`${designs.length} design(s):`, ...lines].join("\n"));
13
30
  }
@@ -1,8 +1,9 @@
1
+ import { normalizeDesignGroup } from "../../shared/schemas/design.schema.js";
1
2
  import { requireString, textResult } from "./types.js";
2
3
  /** Propriétés de la planche elle-même (nom, dimensions, fond) — pas de ses noeuds. */
3
4
  export function updateDesign(store, args) {
4
5
  const id = requireString(args, "design_id");
5
- const fields = ["name", "width", "height", "background"];
6
+ const fields = ["name", "group", "width", "height", "background"];
6
7
  if (fields.every((field) => args?.[field] === undefined)) {
7
8
  throw new Error(`Nothing to update. Provide at least one of: ${fields.join(", ")}.`);
8
9
  }
@@ -24,6 +25,12 @@ export function updateDesign(store, args) {
24
25
  draft[field] = value;
25
26
  changed.push(`${field} -> ${value}`);
26
27
  }
28
+ if (args?.group !== undefined) {
29
+ // null (ou "") sort la page de tout groupe : c'est la seule facon de la
30
+ // remettre a la racine sans passer par un autre outil.
31
+ draft.group = normalizeDesignGroup(args.group);
32
+ changed.push(`group -> ${draft.group === null ? "(racine)" : `"${draft.group}"`}`);
33
+ }
27
34
  if (args?.background !== undefined) {
28
35
  draft.background = String(args.background);
29
36
  changed.push(`background -> ${draft.background}`);
@@ -1 +1 @@
1
- export declare const APP_VERSION = "1.0.1";
1
+ export declare const APP_VERSION = "1.0.3";
@@ -1,2 +1,2 @@
1
1
  // Auto-generated version file - do not edit manually
2
- export const APP_VERSION = '1.0.1';
2
+ export const APP_VERSION = '1.0.3';
@@ -0,0 +1 @@
1
+ :root{--ink:#16161a;--ink-soft:#5c5c66;--ink-faint:#8e8e99;--paper:#fff;--shell:#f4f4f2;--line:#e0e0dc;--line-strong:#c9c9c3;--accent:#1b4dd6;--accent-soft:#e8edfc;--danger:#b4231f;--ok:#1f7a45;color:var(--ink);-webkit-font-smoothing:antialiased;font-family:Inter,system-ui,-apple-system,Segoe UI,sans-serif;font-size:14px}*{box-sizing:border-box}body,html,#root{height:100%;margin:0}body{background:var(--shell)}button{font:inherit;color:inherit;cursor:pointer}input,select,textarea{font:inherit;color:inherit}h1,h2{letter-spacing:.02em;margin:0;font-size:13px;font-weight:600}code{background:var(--shell);border-radius:3px;padding:0 3px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}.app{grid-template-columns:var(--left-panel,232px) 5px minmax(0, 1fr) 5px var(--right-panel,268px);height:100%;display:grid}.panel-resizer{background:var(--line);cursor:col-resize;touch-action:none;position:relative}.panel-resizer:after{content:"";position:absolute;inset:0 -3px}.panel-resizer:hover,.panel-resizer:active{background:var(--accent)}.sidebar,.right-rail{background:var(--paper);overflow-y:auto}.sidebar{border-right:1px solid var(--line);padding:14px}.right-rail{border-left:1px solid var(--line);flex-direction:column;display:flex}.workspace{flex-direction:column;min-width:0;display:flex}.workspace-empty{color:var(--ink-faint);flex:1;place-items:center;display:grid}.toolbar{background:var(--paper);border-bottom:1px solid var(--line);align-items:center;gap:16px;padding:8px 14px;display:flex}.tool-group{align-items:center;gap:4px;display:flex}.tool-button{background:0 0;border:1px solid #0000;border-radius:6px;align-items:center;gap:6px;padding:6px 10px;line-height:1;display:inline-flex}.tool-button:hover:not(:disabled){background:var(--shell)}.tool-button[data-active]{background:var(--accent-soft);border-color:var(--accent);color:var(--accent)}.tool-button:disabled{opacity:.35;cursor:default}.tool-glyph{text-align:center;flex-shrink:0;width:15px;font-size:15px}.tool-label{font-size:12px}.zoom-control{color:var(--ink-soft);align-items:center;gap:6px;font-size:12px;display:inline-flex}.zoom-control select{border:1px solid var(--line);background:var(--paper);border-radius:6px;padding:4px 6px}.toolbar-status{align-items:center;gap:8px;margin-left:auto;font-size:12px;display:flex}.status-name{font-weight:600}.status-chip{background:var(--shell);color:var(--ink-soft);border-radius:999px;padding:3px 8px}.status-chip[data-state=saved]{color:var(--ok)}.status-chip[data-state=error]{color:var(--danger)}.canvas-viewport{touch-action:none;overscroll-behavior:contain;flex:1;position:relative;overflow:hidden}.canvas-viewport[data-pan],.canvas-viewport[data-pan] .canvas-surface,.canvas-viewport[data-pan] .canvas-node{cursor:grab}.canvas-stage{transform-origin:0 0;position:absolute;top:0;left:0;box-shadow:0 1px 2px #00000014,0 12px 32px #00000014}.canvas-surface{transform-origin:0 0;position:absolute;top:0;left:0;overflow:hidden}.canvas-surface[data-tool=select]{cursor:default}.canvas-surface:not([data-tool=select]){cursor:crosshair}.canvas-svg{display:block}.canvas-node[data-hidden]{visibility:hidden}.canvas-group{pointer-events:none}.canvas-group .canvas-node{pointer-events:auto}.canvas-surface[data-tool=select] .canvas-node{cursor:move}.selection-frame{fill:none;stroke:var(--accent);stroke-width:1px;pointer-events:none}.creation-preview{fill:#1b4dd614;stroke:var(--accent);stroke-width:1px;stroke-dasharray:4 3;pointer-events:none}.marquee{fill:#1b4dd60f;stroke:var(--accent);stroke-width:1px;pointer-events:none}.handle{background:var(--paper);border:1px solid var(--accent);border-radius:2px;width:9px;height:9px;margin:-5px 0 0 -5px;position:absolute}.text-editor{border:1px solid var(--accent);resize:none;white-space:pre-wrap;background:#ffffffeb;outline:none;margin:0;padding:0;position:absolute;overflow:hidden}.design-tree{border-radius:6px;gap:2px;min-height:60px;display:grid}.design-tree[data-drop]{box-shadow:inset 0 0 0 1px var(--accent)}.design-group{gap:2px;display:grid}.design-group-header{width:100%;color:var(--ink-soft);text-align:left;background:0 0;border:1px solid #0000;border-radius:6px;align-items:center;gap:6px;padding:5px 6px;font-size:12px;font-weight:600;display:flex}.design-group-header:hover{background:var(--shell)}.design-group[data-drop]{background:var(--accent-soft);box-shadow:inset 0 0 0 1px var(--accent);border-radius:6px}.design-group[data-drop]>.design-group-header,.design-group[data-drop]>.design-group-header .design-group-caret,.design-group[data-drop]>.design-group-header .design-group-count{color:var(--accent)}.design-group-caret{width:10px;color:var(--ink-faint)}.design-group-name{text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;overflow:hidden}.design-group-count{color:var(--ink-faint);font-size:10px;font-weight:500}.sidebar-header{justify-content:space-between;align-items:center;margin-bottom:12px;display:flex}.ghost-button{border:1px solid var(--line-strong);background:0 0;border-radius:6px;padding:4px 8px;font-size:12px}.ghost-button:hover{background:var(--shell)}.primary-button{background:var(--accent);color:#fff;border:none;border-radius:6px;padding:6px 10px;font-size:12px}.primary-button:disabled{opacity:.4;cursor:default}.create-form{background:var(--shell);border-radius:8px;gap:6px;margin-bottom:12px;padding:10px;display:grid}.create-form input,.create-form select{border:1px solid var(--line);background:var(--paper);border-radius:6px;padding:6px 8px}.design-list{gap:2px;margin:0;padding:0;list-style:none;display:grid}.design-list li{align-items:center;gap:2px;display:flex}.design-item{text-align:left;background:0 0;border:1px solid #0000;border-radius:6px;flex:1;gap:2px;min-width:0;padding:8px;display:grid}.design-item:hover{background:var(--shell)}.design-item[data-active]{background:var(--accent-soft);border-color:var(--accent)}.design-name{text-overflow:ellipsis;white-space:nowrap;font-weight:500;overflow:hidden}.design-meta{color:var(--ink-faint);font-size:11px}.design-badge{color:#8a5a00;background:#fdf1d6;border-radius:999px;justify-self:start;padding:1px 6px;font-size:10px}.icon-button{color:var(--ink-faint);background:0 0;border:none;border-radius:4px;padding:4px 6px;line-height:1}.icon-button:hover:not(:disabled){background:var(--shell);color:var(--ink)}.icon-button:disabled{opacity:.3;cursor:default}.empty-hint{color:var(--ink-faint);margin:12px 0 0;font-size:12px;line-height:1.5}.inspector{border-bottom:1px solid var(--line);align-content:start;gap:10px;padding:14px;display:grid}.inspector h2{justify-content:space-between;align-items:baseline;gap:8px;display:flex;overflow:hidden}.inspector-type{color:var(--ink-faint);flex-shrink:0;font-size:11px;font-weight:400}.field{gap:4px;min-width:0;display:grid}.field>span{color:var(--ink-soft);font-size:11px}.field input,.field select,.field textarea{border:1px solid var(--line);background:var(--paper);border-radius:6px;width:100%;padding:5px 7px}.field textarea{resize:vertical;font-family:inherit}.field-row{grid-template-columns:1fr 1fr;gap:8px;display:grid}.color-input{align-items:center;gap:6px;display:flex}.color-input input[type=color]{flex-shrink:0;width:30px;height:28px;padding:2px}.toggle-button{border:1px solid var(--line);background:var(--paper);border-radius:6px;padding:5px 7px}.toggle-button[data-active]{background:var(--accent-soft);border-color:var(--accent);color:var(--accent)}.segmented{grid-template-columns:repeat(3,1fr);gap:4px;display:grid}.segmented button{border:1px solid var(--line);background:var(--paper);border-radius:6px;padding:5px 0}.segmented button[data-active]{background:var(--accent-soft);border-color:var(--accent);color:var(--accent)}.inspector-actions{grid-template-columns:1fr 1fr;gap:6px;margin-top:4px;display:grid}.inspector-actions button{border:1px solid var(--line-strong);background:var(--paper);border-radius:6px;padding:6px 4px;font-size:12px}.inspector-actions button:hover{background:var(--shell)}.inspector-actions .danger{color:var(--danger);border-color:#e8c4c3}.layers{flex-direction:column;min-height:0;padding:14px;display:flex}.layers-header{justify-content:space-between;align-items:center;margin-bottom:8px;display:flex}.layers-actions{gap:2px;display:flex}.layers ul{align-content:start;gap:1px;margin:0;padding:0;list-style:none;display:grid;overflow-y:auto}.layer-row{align-items:center;gap:2px;display:flex}.layer-twisty{width:14px;color:var(--ink-faint);text-align:center;background:0 0;border:none;flex-shrink:0;padding:0;font-size:10px;line-height:1}.layer-twisty:hover{color:var(--ink)}.layer-count{color:var(--ink-faint);background:var(--shell);border-radius:999px;flex-shrink:0;margin-left:auto;padding:0 5px;font-size:10px}.layer-item[data-group] .layer-name{font-weight:600}.layer-item{text-align:left;background:0 0;border:1px solid #0000;border-radius:5px;align-items:baseline;gap:8px;width:100%;min-width:0;padding:5px 7px;display:flex}.layer-item:hover{background:var(--shell)}.layer-item[data-active]{background:var(--accent-soft);border-color:var(--accent)}.layer-kind{color:var(--ink-faint);flex-shrink:0;width:58px;font-size:10px}.layer-name{text-overflow:ellipsis;white-space:nowrap;font-size:12px;overflow:hidden}.error-toast{background:var(--ink);color:#fff;border-radius:8px;align-items:center;gap:12px;padding:10px 14px;font-size:12px;display:flex;position:fixed;bottom:20px;left:50%;transform:translate(-50%);box-shadow:0 8px 24px #0003}.error-toast button{color:inherit;opacity:.7;background:0 0;border:none}