@imenam/mcp-design 1.0.1

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 (71) hide show
  1. package/README.md +231 -0
  2. package/dist/shared/node-defaults.d.ts +30 -0
  3. package/dist/shared/node-defaults.js +67 -0
  4. package/dist/shared/node-fields.d.ts +33 -0
  5. package/dist/shared/node-fields.js +40 -0
  6. package/dist/shared/render.d.ts +105 -0
  7. package/dist/shared/render.js +215 -0
  8. package/dist/shared/schemas/design.schema.d.ts +231 -0
  9. package/dist/shared/schemas/design.schema.js +98 -0
  10. package/dist/shared/tree.d.ts +93 -0
  11. package/dist/shared/tree.js +223 -0
  12. package/dist/src/cli/setup-mcp.d.ts +5 -0
  13. package/dist/src/cli/setup-mcp.js +87 -0
  14. package/dist/src/core/design-store.d.ts +61 -0
  15. package/dist/src/core/design-store.js +294 -0
  16. package/dist/src/core/diff.d.ts +22 -0
  17. package/dist/src/core/diff.js +97 -0
  18. package/dist/src/core/export.d.ts +6 -0
  19. package/dist/src/core/export.js +251 -0
  20. package/dist/src/core/hierarchy.d.ts +18 -0
  21. package/dist/src/core/hierarchy.js +68 -0
  22. package/dist/src/core/ids.d.ts +4 -0
  23. package/dist/src/core/ids.js +21 -0
  24. package/dist/src/core/log-dir.d.ts +6 -0
  25. package/dist/src/core/log-dir.js +12 -0
  26. package/dist/src/core/migrate.d.ts +18 -0
  27. package/dist/src/core/migrate.js +54 -0
  28. package/dist/src/core/node-factory.d.ts +19 -0
  29. package/dist/src/core/node-factory.js +103 -0
  30. package/dist/src/core/screenshot.d.ts +31 -0
  31. package/dist/src/core/screenshot.js +53 -0
  32. package/dist/src/gui-worker.d.ts +1 -0
  33. package/dist/src/gui-worker.js +334 -0
  34. package/dist/src/index.d.ts +2 -0
  35. package/dist/src/index.js +673 -0
  36. package/dist/src/tools/add-nodes.d.ts +7 -0
  37. package/dist/src/tools/add-nodes.js +26 -0
  38. package/dist/src/tools/create-design.d.ts +7 -0
  39. package/dist/src/tools/create-design.js +15 -0
  40. package/dist/src/tools/delete-design.d.ts +3 -0
  41. package/dist/src/tools/delete-design.js +7 -0
  42. package/dist/src/tools/delete-nodes.d.ts +3 -0
  43. package/dist/src/tools/delete-nodes.js +29 -0
  44. package/dist/src/tools/export-design.d.ts +3 -0
  45. package/dist/src/tools/export-design.js +21 -0
  46. package/dist/src/tools/get-changes.d.ts +11 -0
  47. package/dist/src/tools/get-changes.js +27 -0
  48. package/dist/src/tools/get-design.d.ts +7 -0
  49. package/dist/src/tools/get-design.js +29 -0
  50. package/dist/src/tools/group-nodes.d.ts +10 -0
  51. package/dist/src/tools/group-nodes.js +83 -0
  52. package/dist/src/tools/list-designs.d.ts +3 -0
  53. package/dist/src/tools/list-designs.js +13 -0
  54. package/dist/src/tools/mark-synced.d.ts +7 -0
  55. package/dist/src/tools/mark-synced.js +19 -0
  56. package/dist/src/tools/reorder-nodes.d.ts +7 -0
  57. package/dist/src/tools/reorder-nodes.js +60 -0
  58. package/dist/src/tools/screenshot-design.d.ts +3 -0
  59. package/dist/src/tools/screenshot-design.js +36 -0
  60. package/dist/src/tools/types.d.ts +25 -0
  61. package/dist/src/tools/types.js +13 -0
  62. package/dist/src/tools/ungroup-nodes.d.ts +7 -0
  63. package/dist/src/tools/ungroup-nodes.js +64 -0
  64. package/dist/src/tools/update-design.d.ts +4 -0
  65. package/dist/src/tools/update-design.js +33 -0
  66. package/dist/src/tools/update-nodes.d.ts +3 -0
  67. package/dist/src/tools/update-nodes.js +35 -0
  68. package/dist/src/version.d.ts +1 -0
  69. package/dist/src/version.js +2 -0
  70. package/package.json +58 -0
  71. package/scripts/generate-version.js +20 -0
@@ -0,0 +1,22 @@
1
+ import type { DesignNode } from "../../shared/schemas/design.schema.js";
2
+ export interface NodeFieldChange {
3
+ field: string;
4
+ before: unknown;
5
+ after: unknown;
6
+ }
7
+ export interface UpdatedNode {
8
+ id: string;
9
+ name: string;
10
+ type: string;
11
+ changes: NodeFieldChange[];
12
+ }
13
+ export interface DesignDiff {
14
+ added: DesignNode[];
15
+ updated: UpdatedNode[];
16
+ removed: DesignNode[];
17
+ reordered: boolean;
18
+ }
19
+ export declare function diffNodes(before: DesignNode[], after: DesignNode[]): DesignDiff;
20
+ export declare function countChanges(before: DesignNode[], after: DesignNode[]): number;
21
+ /** Résumé lisible du diff, destiné à être renvoyé tel quel à l'agent. */
22
+ export declare function formatDiff(diff: DesignDiff): string;
@@ -0,0 +1,97 @@
1
+ import { GROUP_DERIVED_FIELDS } from "../../shared/node-defaults.js";
2
+ /** Champs recalculés automatiquement : les signaler serait du bruit, pas un changement de design. */
3
+ const DERIVED_GROUP_FIELDS = new Set(GROUP_DERIVED_FIELDS);
4
+ /** Les nombres issus de la GUI portent du bruit sous-pixel qui n'est pas un changement de design. */
5
+ function equalValues(a, b) {
6
+ if (typeof a === "number" && typeof b === "number")
7
+ return Math.abs(a - b) < 0.01;
8
+ return a === b;
9
+ }
10
+ export function diffNodes(before, after) {
11
+ const beforeById = new Map(before.map((n) => [n.id, n]));
12
+ const afterById = new Map(after.map((n) => [n.id, n]));
13
+ const added = after.filter((n) => !beforeById.has(n.id));
14
+ const removed = before.filter((n) => !afterById.has(n.id));
15
+ const updated = [];
16
+ for (const node of after) {
17
+ const previous = beforeById.get(node.id);
18
+ if (!previous)
19
+ continue;
20
+ const fields = new Set([...Object.keys(previous), ...Object.keys(node)]);
21
+ const changes = [];
22
+ for (const field of fields) {
23
+ if (field === "id" || field === "type")
24
+ continue;
25
+ if (node.type === "group" && DERIVED_GROUP_FIELDS.has(field))
26
+ continue;
27
+ const a = previous[field];
28
+ const b = node[field];
29
+ if (!equalValues(a, b))
30
+ changes.push({ field, before: a, after: b });
31
+ }
32
+ if (changes.length > 0) {
33
+ updated.push({ id: node.id, name: node.name, type: node.type, changes });
34
+ }
35
+ }
36
+ // L'ordre d'empilement change le rendu sans changer un seul champ : on le signale à part.
37
+ const commonBefore = before.filter((n) => afterById.has(n.id)).map((n) => n.id);
38
+ const commonAfter = after.filter((n) => beforeById.has(n.id)).map((n) => n.id);
39
+ const reordered = commonBefore.join("|") !== commonAfter.join("|");
40
+ return { added, updated, removed, reordered };
41
+ }
42
+ export function countChanges(before, after) {
43
+ const diff = diffNodes(before, after);
44
+ return diff.added.length + diff.updated.length + diff.removed.length;
45
+ }
46
+ function formatValue(value) {
47
+ if (value === undefined)
48
+ return "—";
49
+ if (value === null)
50
+ return "racine";
51
+ if (typeof value === "number")
52
+ return String(Math.round(value * 100) / 100);
53
+ return JSON.stringify(value);
54
+ }
55
+ /** "dans le groupe X" plutôt qu'un identifiant nu, quand le noeud n'est pas à la racine. */
56
+ function parentSuffix(node) {
57
+ return node.parentId === null ? "" : ` in group ${node.parentId}`;
58
+ }
59
+ /** Résumé lisible du diff, destiné à être renvoyé tel quel à l'agent. */
60
+ export function formatDiff(diff) {
61
+ const lines = [];
62
+ if (diff.added.length > 0) {
63
+ lines.push(`## Added (${diff.added.length})`);
64
+ for (const node of diff.added) {
65
+ const geometry = node.type === "group"
66
+ ? `at ${Math.round(node.x)},${Math.round(node.y)}`
67
+ : `at ${Math.round(node.x)},${Math.round(node.y)} size ${Math.round(node.width)}x${Math.round(node.height)}`;
68
+ lines.push(`- ${node.id} — "${node.name}" (${node.type}) ${geometry}${parentSuffix(node)}`);
69
+ }
70
+ lines.push("");
71
+ }
72
+ if (diff.updated.length > 0) {
73
+ lines.push(`## Updated (${diff.updated.length})`);
74
+ for (const node of diff.updated) {
75
+ lines.push(`- ${node.id} — "${node.name}" (${node.type})`);
76
+ for (const change of node.changes) {
77
+ lines.push(` ${change.field}: ${formatValue(change.before)} -> ${formatValue(change.after)}`);
78
+ }
79
+ }
80
+ lines.push("");
81
+ }
82
+ if (diff.removed.length > 0) {
83
+ lines.push(`## Removed (${diff.removed.length})`);
84
+ for (const node of diff.removed) {
85
+ lines.push(`- ${node.id} — "${node.name}" (${node.type})`);
86
+ }
87
+ lines.push("");
88
+ }
89
+ if (diff.reordered) {
90
+ lines.push("## Reordered");
91
+ lines.push("- The stacking order of existing nodes changed.");
92
+ lines.push("");
93
+ }
94
+ if (lines.length === 0)
95
+ return "No changes since the last mark_synced.";
96
+ return lines.join("\n").trimEnd();
97
+ }
@@ -0,0 +1,6 @@
1
+ import type { Design } from "../../shared/schemas/design.schema.js";
2
+ export declare const EXPORT_FORMATS: readonly ["svg", "html", "jsx", "json", "summary"];
3
+ export type ExportFormat = (typeof EXPORT_FORMATS)[number];
4
+ /** Inventaire textuel : ce que l'agent lit quand il veut comprendre sans parser du JSON. */
5
+ export declare function toSummary(design: Design): string;
6
+ export declare function exportDesign(design: Design, format: ExportFormat): string;
@@ -0,0 +1,251 @@
1
+ import { DEFAULT_FONT_FAMILY, documentToSvg, escapeXml, measuredTextHeight, normalizedBox, textLines, textMetrics, } from "../../shared/render.js";
2
+ import { boundsOf, childrenByParent, computeLayout } from "../../shared/tree.js";
3
+ export const EXPORT_FORMATS = ["svg", "html", "jsx", "json", "summary"];
4
+ function round(value) {
5
+ return Math.round(value * 100) / 100;
6
+ }
7
+ function toRenderNodes(design) {
8
+ return design.nodes;
9
+ }
10
+ /** "Titre principal" -> "titre-principal" ; sert de nom de classe CSS. */
11
+ function cssClassName(node) {
12
+ const base = node.name
13
+ .normalize("NFD")
14
+ .replace(/\p{Diacritic}/gu, "")
15
+ .toLowerCase()
16
+ .replace(/[^a-z0-9]+/g, "-")
17
+ .replace(/^-+|-+$/g, "");
18
+ return `d-${base || node.type}-${node.id.replace(/[^a-z0-9]/gi, "")}`;
19
+ }
20
+ /**
21
+ * Position d'un élément **dans la div de son parent**.
22
+ *
23
+ * Un groupe est exporté comme une div positionnée sur la boîte englobante de son
24
+ * contenu ; ses enfants sont donc décalés de cette boîte, ce qui produit du HTML
25
+ * imbriqué directement réutilisable plutôt qu'une liste plate de coordonnées.
26
+ */
27
+ function offsetWithin(layout, node, parentBox) {
28
+ const box = boundsOf(layout, node.id);
29
+ return { left: box.x - (parentBox?.x ?? 0), top: box.y - (parentBox?.y ?? 0) };
30
+ }
31
+ function nodeCss(node, layout, parentBox) {
32
+ const declarations = ["position: absolute"];
33
+ const offset = offsetWithin(layout, node, parentBox);
34
+ if (node.type === "group") {
35
+ const box = boundsOf(layout, node.id);
36
+ declarations.push(`left: ${round(offset.left)}px`, `top: ${round(offset.top)}px`, `width: ${round(box.width)}px`, `height: ${round(box.height)}px`);
37
+ if (node.rotation)
38
+ declarations.push(`transform: rotate(${round(node.rotation)}deg)`);
39
+ if (node.opacity < 1)
40
+ declarations.push(`opacity: ${round(node.opacity)}`);
41
+ return declarations;
42
+ }
43
+ if (node.type === "line") {
44
+ // Une ligne se rend comme une barre pivotée autour de son point de départ :
45
+ // c'est le seul type dont l'ancrage n'est pas le coin de la boîte englobante.
46
+ const length = Math.hypot(node.width, node.height);
47
+ const angle = (Math.atan2(node.height, node.width) * 180) / Math.PI;
48
+ const thickness = round(node.strokeWidth ?? 1);
49
+ const local = normalizedBox(node);
50
+ // offset porte sur la boîte normalisée ; le trait part de son extrémité d'origine.
51
+ const startLeft = offset.left + (node.x - local.x);
52
+ const startTop = offset.top + (node.y - local.y);
53
+ declarations.push(`left: ${round(startLeft)}px`, `top: ${round(startTop - thickness / 2)}px`, `width: ${round(length)}px`, `height: ${thickness}px`, `background: ${node.stroke ?? "#000000"}`, `transform: rotate(${round(angle + node.rotation)}deg)`, "transform-origin: 0 50%");
54
+ }
55
+ else {
56
+ const box = boundsOf(layout, node.id);
57
+ declarations.push(`left: ${round(offset.left)}px`, `top: ${round(offset.top)}px`, `width: ${round(box.width)}px`, `height: ${round(box.height)}px`);
58
+ if (node.rotation)
59
+ declarations.push(`transform: rotate(${round(node.rotation)}deg)`);
60
+ }
61
+ if (node.opacity < 1)
62
+ declarations.push(`opacity: ${round(node.opacity)}`);
63
+ if (node.type === "rect") {
64
+ if (node.fill)
65
+ declarations.push(`background: ${node.fill}`);
66
+ if (node.stroke && (node.strokeWidth ?? 0) > 0) {
67
+ declarations.push(`border: ${round(node.strokeWidth ?? 1)}px solid ${node.stroke}`);
68
+ declarations.push("box-sizing: border-box");
69
+ }
70
+ if (node.radius)
71
+ declarations.push(`border-radius: ${round(node.radius)}px`);
72
+ }
73
+ else if (node.type === "ellipse") {
74
+ if (node.fill)
75
+ declarations.push(`background: ${node.fill}`);
76
+ if (node.stroke && (node.strokeWidth ?? 0) > 0) {
77
+ declarations.push(`border: ${round(node.strokeWidth ?? 1)}px solid ${node.stroke}`);
78
+ declarations.push("box-sizing: border-box");
79
+ }
80
+ declarations.push("border-radius: 50%");
81
+ }
82
+ else if (node.type === "text") {
83
+ declarations.push(`color: ${node.color ?? "#000000"}`, `font-family: ${node.fontFamily ?? DEFAULT_FONT_FAMILY}`, `font-size: ${round(node.fontSize ?? 24)}px`, `font-weight: ${node.fontWeight ?? 400}`, `line-height: ${round(textMetrics(node).lineStep)}px`, `text-align: ${node.textAlign ?? "left"}`, "white-space: pre-wrap");
84
+ if (node.fontStyle === "italic")
85
+ declarations.push("font-style: italic");
86
+ declarations.push("height: auto", `min-height: ${round(measuredTextHeight(node))}px`);
87
+ }
88
+ return declarations;
89
+ }
90
+ function contextOf(design) {
91
+ return { layout: computeLayout(toRenderNodes(design)), children: childrenByParent(design.nodes) };
92
+ }
93
+ function toHtml(design) {
94
+ const { layout, children } = contextOf(design);
95
+ const rules = [];
96
+ const walk = (nodes, parentBox, depth) => {
97
+ const pad = " ".repeat(depth + 1);
98
+ return nodes.flatMap((node) => {
99
+ rules.push(`.${cssClassName(node)} {\n${nodeCss(node, layout, parentBox).map((d) => ` ${d};`).join("\n")}\n}`);
100
+ const cls = cssClassName(node);
101
+ if (node.type === "group") {
102
+ return [
103
+ `${pad}<div class="${cls}">`,
104
+ ...walk(children.get(node.id) ?? [], boundsOf(layout, node.id), depth + 1),
105
+ `${pad}</div>`,
106
+ ];
107
+ }
108
+ if (node.type === "text")
109
+ return [`${pad}<div class="${cls}">${escapeXml(node.text ?? "")}</div>`];
110
+ return [`${pad}<div class="${cls}"></div>`];
111
+ });
112
+ };
113
+ const body = walk(children.get(null) ?? [], null, 1).join("\n");
114
+ return [
115
+ "<!doctype html>",
116
+ '<html lang="fr">',
117
+ "<head>",
118
+ ' <meta charset="utf-8" />',
119
+ ` <title>${escapeXml(design.name)}</title>`,
120
+ " <style>",
121
+ " body { margin: 0; }",
122
+ ` .design-root { position: relative; width: ${design.width}px; height: ${design.height}px; background: ${design.background}; overflow: hidden; }`,
123
+ rules
124
+ .join("\n\n")
125
+ .split("\n")
126
+ .map((line) => (line ? ` ${line}` : ""))
127
+ .join("\n"),
128
+ " </style>",
129
+ "</head>",
130
+ "<body>",
131
+ ' <div class="design-root">',
132
+ body,
133
+ " </div>",
134
+ "</body>",
135
+ "</html>",
136
+ ].join("\n");
137
+ }
138
+ function toCamelCase(property) {
139
+ return property.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
140
+ }
141
+ function toComponentName(name) {
142
+ const pascal = name
143
+ .normalize("NFD")
144
+ .replace(/\p{Diacritic}/gu, "")
145
+ .replace(/[^a-zA-Z0-9]+(.)?/g, (_, letter) => (letter ? letter.toUpperCase() : ""))
146
+ .replace(/^[a-z]/, (letter) => letter.toUpperCase());
147
+ return /^[A-Za-z]/.test(pascal) ? pascal : "Design";
148
+ }
149
+ function toJsx(design) {
150
+ const { layout, children } = contextOf(design);
151
+ const styleOf = (node, parentBox) => nodeCss(node, layout, parentBox)
152
+ .map((declaration) => {
153
+ const separator = declaration.indexOf(":");
154
+ const property = declaration.slice(0, separator).trim();
155
+ const value = declaration.slice(separator + 1).trim();
156
+ return `${toCamelCase(property)}: ${JSON.stringify(value)}`;
157
+ })
158
+ .join(", ");
159
+ const walk = (nodes, parentBox, depth) => {
160
+ const pad = " ".repeat(depth + 3);
161
+ return nodes.flatMap((node) => {
162
+ const comment = `${pad}{/* ${node.name} */}`;
163
+ const style = styleOf(node, parentBox);
164
+ if (node.type === "group") {
165
+ return [
166
+ comment,
167
+ `${pad}<div style={{ ${style} }}>`,
168
+ ...walk(children.get(node.id) ?? [], boundsOf(layout, node.id), depth + 1),
169
+ `${pad}</div>`,
170
+ ];
171
+ }
172
+ if (node.type === "text") {
173
+ return [comment, `${pad}<div style={{ ${style} }}>{${JSON.stringify(node.text ?? "")}}</div>`];
174
+ }
175
+ return [comment, `${pad}<div style={{ ${style} }} />`];
176
+ });
177
+ };
178
+ return [
179
+ `export function ${toComponentName(design.name)}() {`,
180
+ " return (",
181
+ ` <div style={{ position: "relative", width: ${design.width}, height: ${design.height}, background: ${JSON.stringify(design.background)}, overflow: "hidden" }}>`,
182
+ ...walk(children.get(null) ?? [], null, 0),
183
+ " </div>",
184
+ " );",
185
+ "}",
186
+ "",
187
+ ].join("\n");
188
+ }
189
+ /** Inventaire textuel : ce que l'agent lit quand il veut comprendre sans parser du JSON. */
190
+ export function toSummary(design) {
191
+ const { layout, children } = contextOf(design);
192
+ const lines = [
193
+ `# ${design.name} (${design.id})`,
194
+ `Canvas: ${design.width}x${design.height}, background ${design.background}`,
195
+ `Revision ${design.revision}, last edited by ${design.lastEditor} at ${design.updatedAt}`,
196
+ `Nodes: ${design.nodes.length} (listed bottom to top; indentation = grouping, coordinates are document-absolute)`,
197
+ "",
198
+ ];
199
+ const walk = (nodes, depth) => {
200
+ const indent = " ".repeat(depth);
201
+ for (const node of nodes) {
202
+ const box = boundsOf(layout, node.id);
203
+ const geometry = `${Math.round(box.x)},${Math.round(box.y)} ${Math.round(box.width)}x${Math.round(box.height)}`;
204
+ if (node.type === "group") {
205
+ const count = (children.get(node.id) ?? []).length;
206
+ lines.push(`${indent}- ${node.id} group "${node.name}" @ ${geometry} — ${count} child(ren)`);
207
+ walk(children.get(node.id) ?? [], depth + 1);
208
+ }
209
+ else if (node.type === "text") {
210
+ const preview = textLines(node).join(" / ");
211
+ lines.push(`${indent}- ${node.id} text "${node.name}" @ ${geometry} — ${node.fontSize}px/${node.fontWeight} ${node.color} — "${preview}"`);
212
+ }
213
+ else if (node.type === "line") {
214
+ lines.push(`${indent}- ${node.id} line "${node.name}" @ ${geometry} — ${node.strokeWidth}px ${node.stroke}`);
215
+ }
216
+ else {
217
+ lines.push(`${indent}- ${node.id} ${node.type} "${node.name}" @ ${geometry} — fill ${node.fill ?? "none"}, ` +
218
+ `stroke ${node.stroke ?? "none"}${node.radius ? `, radius ${node.radius}` : ""}`);
219
+ }
220
+ }
221
+ };
222
+ walk(children.get(null) ?? [], 0);
223
+ return lines.join("\n");
224
+ }
225
+ export function exportDesign(design, format) {
226
+ switch (format) {
227
+ case "svg":
228
+ return documentToSvg({
229
+ name: design.name,
230
+ width: design.width,
231
+ height: design.height,
232
+ background: design.background,
233
+ nodes: toRenderNodes(design),
234
+ });
235
+ case "html":
236
+ return toHtml(design);
237
+ case "jsx":
238
+ return toJsx(design);
239
+ case "json":
240
+ return JSON.stringify({
241
+ id: design.id,
242
+ name: design.name,
243
+ width: design.width,
244
+ height: design.height,
245
+ background: design.background,
246
+ nodes: design.nodes,
247
+ }, null, 2);
248
+ case "summary":
249
+ return toSummary(design);
250
+ }
251
+ }
@@ -0,0 +1,18 @@
1
+ import type { DesignNode } from "../../shared/schemas/design.schema.js";
2
+ /**
3
+ * Invariants de la hiérarchie, appliqués à chaque mutation.
4
+ *
5
+ * Une planche dont un `parentId` pend dans le vide ou boucle sur elle-même est
6
+ * irréparable côté GUI : on refuse l'écriture plutôt que de rendre un design
7
+ * partiellement invisible.
8
+ */
9
+ export declare function validateHierarchy(nodes: DesignNode[]): void;
10
+ /**
11
+ * Recalcule la taille de chaque groupe à partir de son contenu. Ces champs sont
12
+ * dérivés : ils existent pour que l'agent puisse lire l'encombrement d'un
13
+ * groupe sans refaire le calcul, jamais pour être écrits.
14
+ */
15
+ export declare function recomputeGroupSizes(nodes: DesignNode[]): DesignNode[];
16
+ export declare function normalizeHierarchy(nodes: DesignNode[]): DesignNode[];
17
+ /** Un élément et tous ses descendants — ce qu'une suppression de groupe emporte. */
18
+ export declare function withDescendants(nodes: DesignNode[], ids: string[]): Set<string>;
@@ -0,0 +1,68 @@
1
+ import { boundsOf, computeLayout, descendantIds, flattenPreOrder } from "../../shared/tree.js";
2
+ import { DesignError } from "./node-factory.js";
3
+ /**
4
+ * Invariants de la hiérarchie, appliqués à chaque mutation.
5
+ *
6
+ * Une planche dont un `parentId` pend dans le vide ou boucle sur elle-même est
7
+ * irréparable côté GUI : on refuse l'écriture plutôt que de rendre un design
8
+ * partiellement invisible.
9
+ */
10
+ export function validateHierarchy(nodes) {
11
+ const byId = new Map(nodes.map((node) => [node.id, node]));
12
+ const duplicates = nodes.filter((node, index) => nodes.findIndex((other) => other.id === node.id) !== index);
13
+ if (duplicates.length > 0) {
14
+ throw new DesignError(`Duplicate node id(s): ${[...new Set(duplicates.map((n) => n.id))].join(", ")}.`);
15
+ }
16
+ for (const node of nodes) {
17
+ if (node.parentId === null)
18
+ continue;
19
+ const parent = byId.get(node.parentId);
20
+ if (!parent) {
21
+ throw new DesignError(`Node "${node.id}" references a parent "${node.parentId}" that does not exist.`);
22
+ }
23
+ if (parent.type !== "group") {
24
+ throw new DesignError(`Node "${node.id}" cannot be parented to "${parent.id}" because it is a "${parent.type}", not a group.`);
25
+ }
26
+ // Un cycle rendrait le calcul de disposition infini.
27
+ let cursor = parent;
28
+ const seen = new Set([node.id]);
29
+ while (cursor) {
30
+ if (seen.has(cursor.id)) {
31
+ throw new DesignError(`Node "${node.id}" would create a cycle through group "${parent.id}".`);
32
+ }
33
+ seen.add(cursor.id);
34
+ cursor = cursor.parentId === null ? undefined : byId.get(cursor.parentId);
35
+ }
36
+ }
37
+ }
38
+ /**
39
+ * Recalcule la taille de chaque groupe à partir de son contenu. Ces champs sont
40
+ * dérivés : ils existent pour que l'agent puisse lire l'encombrement d'un
41
+ * groupe sans refaire le calcul, jamais pour être écrits.
42
+ */
43
+ export function recomputeGroupSizes(nodes) {
44
+ if (!nodes.some((node) => node.type === "group"))
45
+ return nodes;
46
+ const layout = computeLayout(nodes);
47
+ return nodes.map((node) => {
48
+ if (node.type !== "group")
49
+ return node;
50
+ const box = boundsOf(layout, node.id);
51
+ if (node.width === box.width && node.height === box.height)
52
+ return node;
53
+ return { ...node, width: box.width, height: box.height };
54
+ });
55
+ }
56
+ export function normalizeHierarchy(nodes) {
57
+ validateHierarchy(nodes);
58
+ return recomputeGroupSizes(flattenPreOrder(nodes));
59
+ }
60
+ /** Un élément et tous ses descendants — ce qu'une suppression de groupe emporte. */
61
+ export function withDescendants(nodes, ids) {
62
+ const all = new Set(ids);
63
+ for (const id of ids) {
64
+ for (const descendant of descendantIds(nodes, id))
65
+ all.add(descendant);
66
+ }
67
+ return all;
68
+ }
@@ -0,0 +1,4 @@
1
+ /** Identifiant court, lisible dans un JSON lu par un agent. */
2
+ export declare function shortId(prefix: string, length?: number): string;
3
+ /** "Ma Landing Page" -> "ma-landing-page" */
4
+ export declare function slugify(value: string): string;
@@ -0,0 +1,21 @@
1
+ import crypto from "crypto";
2
+ const ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
3
+ /** Identifiant court, lisible dans un JSON lu par un agent. */
4
+ export function shortId(prefix, length = 8) {
5
+ const bytes = crypto.randomBytes(length);
6
+ let out = "";
7
+ for (const byte of bytes)
8
+ out += ALPHABET[byte % ALPHABET.length];
9
+ return `${prefix}_${out}`;
10
+ }
11
+ /** "Ma Landing Page" -> "ma-landing-page" */
12
+ export function slugify(value) {
13
+ const slug = value
14
+ .normalize("NFD")
15
+ .replace(/\p{Diacritic}/gu, "")
16
+ .toLowerCase()
17
+ .replace(/[^a-z0-9]+/g, "-")
18
+ .replace(/^-+|-+$/g, "")
19
+ .slice(0, 48);
20
+ return slug || "design";
21
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Répertoire des logs : MCP_LOG_DIR s'il est renseigné, sinon <racine>/.design-data/logs.
3
+ * Lu depuis process.env uniquement (le .env du package est chargé après l'init du
4
+ * logger), donc la variable doit venir de la config du client MCP.
5
+ */
6
+ export declare function resolveLogDir(rootDir: string): string;
@@ -0,0 +1,12 @@
1
+ import path from "path";
2
+ /**
3
+ * Répertoire des logs : MCP_LOG_DIR s'il est renseigné, sinon <racine>/.design-data/logs.
4
+ * Lu depuis process.env uniquement (le .env du package est chargé après l'init du
5
+ * logger), donc la variable doit venir de la config du client MCP.
6
+ */
7
+ export function resolveLogDir(rootDir) {
8
+ const configured = process.env.MCP_LOG_DIR?.trim();
9
+ if (configured)
10
+ return path.resolve(configured);
11
+ return path.join(rootDir, ".design-data", "logs");
12
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Mises à niveau appliquées à la lecture d'un fichier de design.
3
+ *
4
+ * Elles tournent avant la validation de schéma et sont idempotentes : un design
5
+ * déjà à jour ressort inchangé, et le fichier n'est réécrit que si quelque chose
6
+ * a bougé.
7
+ */
8
+ /**
9
+ * `lineHeight` a d'abord été stocké comme un multiplicateur de `fontSize`
10
+ * (1.35), puis est passé en pixels — la seule unité que les agents emploient
11
+ * spontanément, et la seule cohérente avec x, y et fontSize.
12
+ *
13
+ * Une valeur sous MIN_LINE_HEIGHT_PX est donc forcément un ancien ratio : on la
14
+ * reconvertit en la multipliant par la taille de police du noeud.
15
+ */
16
+ export declare function migrateNode(node: Record<string, unknown>): Record<string, unknown>;
17
+ /** Applique la migration à un design brut. Retourne `null` si rien n'a changé. */
18
+ export declare function migrateDesign(raw: unknown): Record<string, unknown> | null;
@@ -0,0 +1,54 @@
1
+ import { DEFAULT_FONT_SIZE, MIN_LINE_HEIGHT_PX } from "../../shared/render.js";
2
+ /**
3
+ * Mises à niveau appliquées à la lecture d'un fichier de design.
4
+ *
5
+ * Elles tournent avant la validation de schéma et sont idempotentes : un design
6
+ * déjà à jour ressort inchangé, et le fichier n'est réécrit que si quelque chose
7
+ * a bougé.
8
+ */
9
+ /**
10
+ * `lineHeight` a d'abord été stocké comme un multiplicateur de `fontSize`
11
+ * (1.35), puis est passé en pixels — la seule unité que les agents emploient
12
+ * spontanément, et la seule cohérente avec x, y et fontSize.
13
+ *
14
+ * Une valeur sous MIN_LINE_HEIGHT_PX est donc forcément un ancien ratio : on la
15
+ * reconvertit en la multipliant par la taille de police du noeud.
16
+ */
17
+ export function migrateNode(node) {
18
+ const lineHeight = node.lineHeight;
19
+ if (typeof lineHeight !== "number" || !(lineHeight > 0) || lineHeight >= MIN_LINE_HEIGHT_PX)
20
+ return node;
21
+ const fontSize = typeof node.fontSize === "number" && node.fontSize > 0 ? node.fontSize : DEFAULT_FONT_SIZE;
22
+ return { ...node, lineHeight: Math.round(lineHeight * fontSize * 100) / 100 };
23
+ }
24
+ /** Applique la migration à un design brut. Retourne `null` si rien n'a changé. */
25
+ export function migrateDesign(raw) {
26
+ if (!raw || typeof raw !== "object")
27
+ return null;
28
+ const design = raw;
29
+ let changed = false;
30
+ const migrateList = (value) => {
31
+ if (!Array.isArray(value))
32
+ return value;
33
+ return value.map((entry) => {
34
+ if (!entry || typeof entry !== "object")
35
+ return entry;
36
+ const next = migrateNode(entry);
37
+ if (next !== entry)
38
+ changed = true;
39
+ return next;
40
+ });
41
+ };
42
+ const nodes = migrateList(design.nodes);
43
+ // La baseline sert de référence au diff : la laisser dans l'ancienne unité
44
+ // ferait apparaître un faux changement sur chaque texte.
45
+ const baseline = design.baseline;
46
+ const baselineNodes = baseline && typeof baseline === "object" ? migrateList(baseline.nodes) : undefined;
47
+ if (!changed)
48
+ return null;
49
+ return {
50
+ ...design,
51
+ nodes,
52
+ ...(baselineNodes === undefined ? {} : { baseline: { ...baseline, nodes: baselineNodes } }),
53
+ };
54
+ }
@@ -0,0 +1,19 @@
1
+ import { type DesignNode } from "../../shared/schemas/design.schema.js";
2
+ import { NODE_DEFAULTS } from "../../shared/node-defaults.js";
3
+ export declare class DesignError extends Error {
4
+ }
5
+ export interface CreateNodeInput {
6
+ [key: string]: unknown;
7
+ type?: unknown;
8
+ }
9
+ /**
10
+ * Construit un noeud complet et valide à partir d'une entrée partielle d'agent :
11
+ * seul `type` est obligatoire, tout le reste vient des valeurs par défaut, ce qui
12
+ * évite les noeuds invisibles (taille nulle, pas de remplissage).
13
+ *
14
+ * `existingIds` empêche une collision d'identifiants fournis explicitement.
15
+ */
16
+ export declare function buildNode(input: CreateNodeInput, existingIds: Set<string>, index: number): DesignNode;
17
+ /** Applique un patch partiel en ne conservant que les champs valides pour le type du noeud. */
18
+ export declare function applyPatch(node: DesignNode, patch: Record<string, unknown>): DesignNode;
19
+ export { NODE_DEFAULTS };