@imenam/mcp-design 1.0.6 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,6 +28,18 @@ npx -y @imenam/mcp-design --claude-setup-mcp
28
28
 
29
29
  La commande crée (ou complète) le `.mcp.json` du répertoire courant avec une entrée `mcp-design` dont les variables sont pré-remplies. Ajoutez `--force` pour remplacer une entrée existante ; les autres serveurs sont préservés.
30
30
 
31
+ Deux flags permettent de choisir directement comment la GUI sera exposée, sans éditer le JSON ensuite :
32
+
33
+ ```bash
34
+ # GUI derrière le proxy central
35
+ npx -y @imenam/mcp-design --claude-setup-mcp --proxy_url=http://localhost:3000
36
+
37
+ # GUI en accès direct, sans proxy : http://localhost:4500
38
+ npx -y @imenam/mcp-design --claude-setup-mcp --app_port=4500
39
+ ```
40
+
41
+ Les deux flags sont exclusifs — la commande échoue si les deux sont passés.
42
+
31
43
  Exemple d'entrée générée :
32
44
 
33
45
  ```json
@@ -38,6 +50,7 @@ Exemple d'entrée générée :
38
50
  "args": ["-y", "@imenam/mcp-design"],
39
51
  "env": {
40
52
  "PROXY_URL": "http://localhost:3000",
53
+ "APP_PORT": "",
41
54
  "APP_GROUP": "Mon Projet",
42
55
  "APP_NAME": "Design Mon Projet",
43
56
  "APP_PATH": "/design-mon-projet",
@@ -53,13 +66,21 @@ Exemple d'entrée générée :
53
66
 
54
67
  | Variable | Rôle |
55
68
  |---|---|
56
- | `PROXY_URL` | URL du [proxy central](https://github.com/imenam/proxy-setup). **Sans elle, la GUI est simplement désactivée** — les outils MCP continuent de fonctionner. |
57
- | `APP_PATH` | Chemin de montage de la GUI derrière le proxy (défaut `/design-gui`). |
58
- | `APP_NAME` | Nom affiché dans le dashboard du proxy. |
69
+ | `PROXY_URL` | URL du [proxy central](https://github.com/imenam/proxy-setup) : le proxy attribue le port et monte la GUI sous `APP_PATH`. |
70
+ | `APP_PORT` | Port local utilisé **quand `PROXY_URL` est absent** : la GUI est servie directement sur `http://localhost:<port>`, à la racine. Permet d'utiliser l'interface sans le hub de centralisation. |
71
+ | `APP_PATH` | Chemin de montage de la GUI derrière le proxy (défaut `/design-gui`). Ignoré en mode `APP_PORT`. `PROXY_APP_PATH` puis `GUI_PATH` sont acceptés en repli. |
72
+ | `APP_NAME` | Nom affiché dans le dashboard du proxy. `PROXY_APP_NAME` puis `GUI_NAME` sont acceptés en repli. |
73
+ | `APP_PATH_PREFIX` | Préfixe de namespace ajouté au chemin **par défaut** (ex. `/abc123` → `/abc123/design-gui`). Sert à isoler plusieurs instances sous un même proxy. |
59
74
  | `APP_GROUP` | Section repliable du dashboard du proxy (facultatif). |
60
75
  | `MCP_DESIGN_DATA_DIR` | Répertoire de stockage des designs. `MCP_DATA_DIR` est accepté en repli. Défaut : `<package>/.design-data`. |
61
76
  | `MCP_LOG_DIR` | Répertoire des logs. Défaut : `<data>/logs`. |
62
77
 
78
+ `PROXY_URL` et `APP_PORT` s'excluent : `PROXY_URL` prime si les deux sont renseignés. **Si aucune des deux n'est définie, la GUI est simplement désactivée** — les outils MCP continuent de fonctionner.
79
+
80
+ `APP_PATH` / `APP_NAME` sont les noms canoniques ; `PROXY_APP_*` puis `GUI_*` restent acceptés, dans cet ordre de priorité décroissante. La règle de `APP_PATH_PREFIX` est simple : **il ne préfixe que le chemin par défaut**. Dès que `APP_PATH` (ou l'un de ses replis) est renseigné, cette valeur est prise telle quelle, sans préfixe — la saisie manuelle l'emporte toujours. Les chemins sont normalisés (un seul `/` de séparation, pas de `/` final) et le préfixage est idempotent. Le `<base href>` de l'interface suit le chemin réellement enregistré, assets compris.
81
+
82
+ La résolution est assurée par le paquet partagé [`@imenam/mcp-gui-interface`](https://www.npmjs.com/package/@imenam/mcp-gui-interface), qui documente la règle en détail.
83
+
63
84
  Un design est un simple fichier JSON dans `<data>/designs/<id>.json` — lisible, diffable, versionnable.
64
85
 
65
86
  ---
@@ -146,6 +167,19 @@ Le texte n'est **jamais re-wrappé automatiquement** : seuls les `\n` explicites
146
167
 
147
168
  `lineHeight` est une **distance en pixels entre deux lignes de base**, pas un multiplicateur de `fontSize` : avec `fontSize: 64`, on écrit `76`, pas `1.2`. C'est l'unité que les agents emploient spontanément, et la seule qui se lise dans la même échelle que le reste. Une valeur sous 4 est donc refusée à l'écriture avec un message explicite, et les fichiers écrits avant ce changement sont convertis à la lecture (`ratio x fontSize`) par [`src/core/migrate.ts`](src/core/migrate.ts). Champ omis = `fontSize x 1.35`.
148
169
 
170
+ #### La boîte d'un texte épouse son texte
171
+
172
+ Le bloc de lignes est **centré verticalement dans la boîte** du nœud : c'est ce qui permet de centrer un libellé dans un bouton sans calculer de décalage. Encore faut-il que la boîte soit celle qu'on croit — une hauteur par défaut plus grande que le texte le fait descendre sous le point posé.
173
+
174
+ Donc : **un nœud `text` créé sans `height` prend la hauteur exacte de son texte** (`lignes x lineHeight`). Le centrage n'a alors plus rien à décaler, et la première ligne commence bien à `y`, à gauche de `x`. Ajouter une ligne agrandit la boîte au lieu de la faire déborder : une boîte qui épousait son texte continue de l'épouser, à chaque changement de `text`, `fontSize` ou `lineHeight`, à l'écriture par l'agent comme à l'édition dans la GUI.
175
+
176
+ Une hauteur **explicite**, elle, n'est jamais retouchée — c'est le geste qui dit « centre ce libellé » :
177
+
178
+ ```jsonc
179
+ { "type": "text", "x": 40, "y": 120, "text": "Titre", "fontSize": 32 } // boîte = 43.2px, texte posé à y
180
+ { "type": "text", "x": 40, "y": 100, "height": 26, "text": "OK", "fontSize": 12 } // libellé centré dans son bouton
181
+ ```
182
+
149
183
  ### Groupes réutilisables
150
184
 
151
185
  Quand un même bloc revient plusieurs fois — une carte, une ligne de liste, un bouton —, il ne se recopie pas : il se **construit une fois** en groupe, puis s'**instancie**. Une `instance` ne stocke aucun contenu, seulement l'identifiant du groupe qu'elle reproduit ; le sous-arbre est reconstitué au rendu. Éditer le groupe met donc à jour **toutes** ses instances d'un coup, et un bloc répété reste une seule chose à lire et à modifier. C'est le fonctionnement d'un composant principal dans Figma.
@@ -187,8 +221,12 @@ Un lot est intégralement validé avant la moindre écriture : une entrée inval
187
221
  |---|---|
188
222
  | `get_changes` | Liste tout ce qui a été ajouté, modifié, supprimé ou réordonné depuis la dernière baseline, champ par champ (`x: 40 -> 240`). |
189
223
  | `mark_synced` | Fige l'état courant comme nouvelle baseline. |
190
- | `export_design` | Rend la planche en `html`, `jsx`, `svg`, `json` ou `summary`. `output_path` écrit dans un fichier au lieu de renvoyer le contenu. |
191
- | `screenshot_design` | Rasterise la planche en PNG et le renvoie **en image** à l'agent. `node_id` cadre la capture sur un groupe (ou n'importe quel élément), `scale` zoome, `padding` ajoute de la marge, `output_path` écrit un `.png` au lieu de renvoyer l'image. |
224
+ | `export_design` | Rend la planche en `html`, `jsx`, `svg`, `json` ou `summary`. `destination_path` + `filename` écrivent un fichier **sur la machine de l'agent** au lieu de renvoyer le contenu. |
225
+ | `screenshot_design` | Rasterise la planche en PNG et le renvoie **en image** à l'agent. `node_id` cadre la capture sur un groupe (ou n'importe quel élément), `scale` zoome, `padding` ajoute de la marge, `destination_path` + `filename` écrivent un `.png` **sur la machine de l'agent** au lieu de renvoyer l'image. |
226
+
227
+ > **Où le fichier est écrit.** `destination_path` est un **dossier absolu** sur la machine de l'agent, `filename` le nom que le fichier y prend. Omis, les deux outils se comportent comme avant : le contenu (ou l'image) revient dans la réponse, sans fichier.
228
+ >
229
+ > En stdio, ce MCP tourne à côté de l'agent et écrit lui-même. Derrière [`@imenam/mcp-http-gateway`](https://www.npmjs.com/package/@imenam/mcp-http-gateway), il tourne sur la machine du gateway et ne peut pas atteindre le disque de l'agent : il déclare alors la paire `destination_path` / `destination_relay`, renvoie les octets, et le relais — qui tourne, lui, sur la machine de l'agent — les y écrit. `destination_relay` est rempli par le relais, jamais par l'agent, et cette voie exige `GATEWAY_INLINE_WRITE_ROOTS` dans le bloc `env` du relais.
192
230
 
193
231
  **`get_changes` n'acquitte jamais ce qu'il montre.** Seul `mark_synced` déplace la baseline, et il ne doit être appelé qu'une fois les modifications réellement reportées dans le code. Un simple appel de lecture ne peut donc pas faire disparaître un diff que l'agent n'a pas encore traité.
194
232
 
@@ -225,7 +263,7 @@ Trois colonnes : la liste des planches, le canvas, l'inspecteur et les calques.
225
263
  - **Outils** — sélection, rectangle, ellipse, ligne, texte, icône. Glisser pour dimensionner, cliquer pour poser à la taille par défaut. L'outil icône ouvre d'abord son sélecteur : poser une icône suppose d'avoir choisi laquelle.
226
264
  - **Édition** — déplacement au glisser, 8 poignées de redimensionnement (2 extrémités pour une ligne), sélection multiple au rectangle d'encadrement ou au Maj+clic.
227
265
  - **Groupes** — `Ctrl+G` groupe la sélection, `Ctrl+Maj+G` la dissout. Un clic dans le canvas sélectionne le **groupe le plus haut** ; `Alt+clic` vise l'élément exact à l'intérieur. Déplacer un groupe déplace tout son contenu.
228
- - **Texte** — double-clic pour éditer en place, avec la même métrique qu'au rendu final.
266
+ - **Texte** — double-clic pour éditer en place, avec la même métrique qu'au rendu final. Une boîte à la taille de son texte le reste : ajouter une ligne l'agrandit d'autant.
229
267
  - **Inspecteur** — position, taille, rotation, opacité, couleurs, épaisseur, arrondi, contenu, taille et graisse de police, interligne (en px), alignement, choix de l'icône. Pour un groupe, la taille est affichée en lecture seule puisqu'elle suit son contenu ; pour une icône, un seul champ de taille, puisqu'elle est carrée.
230
268
  - **Calques** — arbre repliable, ordre d'empilement, sélection, montée/descente d'un cran.
231
269
 
@@ -1,4 +1,4 @@
1
- import type { RenderNode } from "./render.js";
1
+ import { type RenderNode } from "./render.js";
2
2
  /**
3
3
  * Valeurs par défaut et champs autorisés par type de noeud.
4
4
  *
@@ -1,3 +1,4 @@
1
+ import { defaultLineHeight } from "./render.js";
1
2
  import { DEFAULT_ICON_STROKE_WIDTH, ICON_GRID } from "./icons/index.js";
2
3
  export const NODE_KINDS = ["rect", "ellipse", "line", "text", "icon", "group", "instance"];
3
4
  /** Types que l'utilisateur peut dessiner directement ; un groupe se cree a partir d'une selection. */
@@ -8,7 +9,11 @@ export const NODE_DEFAULTS = {
8
9
  line: { width: 200, height: 0, stroke: "#1a1a18", strokeWidth: 2 },
9
10
  text: {
10
11
  width: 260,
11
- height: 40,
12
+ // Une ligne : la boîte par défaut épouse le texte par défaut. Toute autre
13
+ // hauteur ferait descendre la première ligne sous le point posé, puisque le
14
+ // bloc de lignes est centré dans sa boîte. Un texte créé sans hauteur la
15
+ // recalcule de toute façon sur son propre contenu.
16
+ height: defaultLineHeight(24),
12
17
  text: "Texte",
13
18
  color: "#1a1a18",
14
19
  fontSize: 24,
@@ -16,8 +21,9 @@ export const NODE_DEFAULTS = {
16
21
  fontWeight: 400,
17
22
  fontStyle: "normal",
18
23
  textAlign: "left",
19
- // En pixels, comme x/y/fontSize — jamais un multiplicateur.
20
- lineHeight: 32,
24
+ // `lineHeight` n'a volontairement pas de valeur par défaut : figée, elle
25
+ // rendrait un texte de 64px sur un interligne de 32. Absente, elle suit la
26
+ // taille de police (fontSize x 1.35), comme l'annonce sa documentation.
21
27
  },
22
28
  // Taille de la grille de Lucide : une icone posee sans consigne rend au
23
29
  // format pour lequel son trace a ete dessine.
@@ -22,7 +22,10 @@ export const NODE_FIELD_DOCS = {
22
22
  width: "Width of the bounding box, in pixels. On a line, the horizontal delta from start to end — it may be negative. " +
23
23
  "On a group, derived from the content and read-only.",
24
24
  height: "Height of the bounding box, in pixels. On a line, the vertical delta from start to end — it may be negative. " +
25
- "On a group, derived from the content and read-only.",
25
+ "On a group, derived from the content and read-only. " +
26
+ "On a TEXT node, leave it out: the box then takes the exact height of the text (lines x lineHeight), " +
27
+ "so the first line starts at y. Set it only to centre a label inside a container — give the text node " +
28
+ "the container's y and height, and the block of lines is centred in it.",
26
29
  rotation: "Rotation in degrees, clockwise, around the centre of the bounding box. 0 = upright.",
27
30
  opacity: "Opacity, from 0 (fully transparent) to 1 (fully opaque).",
28
31
  fill: 'CSS colour filling the interior, e.g. "#ffffff" or "rgba(0,0,0,0.4)". rect, ellipse and icon. On an icon it is "none" by default — the shape is drawn by its stroke.',
@@ -42,7 +45,8 @@ export const NODE_FIELD_DOCS = {
42
45
  "node to read and to change. Whenever the same block appears more than once (cards, list rows, menu items, " +
43
46
  "buttons), build it once as a group and add instances of it instead of duplicating it. " +
44
47
  "Change this field to swap an instance over to another group; use detach_instances when one copy must diverge.",
45
- text: "Text content. It is NEVER auto-wrapped: only explicit \\n characters start a new line.",
48
+ text: "Text content. It is NEVER auto-wrapped: only explicit \\n characters start a new line. " +
49
+ "A box that hugs its text keeps hugging it: adding a line grows the box instead of overflowing it.",
46
50
  color: "CSS colour of the text.",
47
51
  fontSize: "Font size, in pixels (same meaning as the CSS font-size property).",
48
52
  fontFamily: 'CSS font stack, e.g. "Inter, system-ui, sans-serif".',
@@ -81,6 +81,24 @@ export declare function textMetrics(node: RenderNode): {
81
81
  };
82
82
  /** Hauteur minimale nécessaire pour afficher tout le texte du noeud. */
83
83
  export declare function measuredTextHeight(node: RenderNode): number;
84
+ /**
85
+ * Vrai quand la boîte épouse déjà exactement son texte.
86
+ *
87
+ * C'est le signe qu'aucune hauteur n'a été voulue pour elle-même : la boîte
88
+ * suit son contenu, et doit continuer à le suivre quand le texte change. Une
89
+ * hauteur choisie (un libellé calé sur la hauteur de son bouton) ne vérifie pas
90
+ * cette égalité et n'est donc jamais réajustée.
91
+ */
92
+ export declare function hugsText(node: RenderNode): boolean;
93
+ /**
94
+ * Le noeud, avec la hauteur qui épouse son texte.
95
+ *
96
+ * Une boîte à la taille de son texte le place de fait en haut à gauche : le
97
+ * centrage vertical n'a plus rien à décaler. C'est ce qu'attend qui pose un
98
+ * texte sans penser à sa boîte ; un conteneur qui veut centrer son libellé,
99
+ * lui, donne une hauteur explicite.
100
+ */
101
+ export declare function withFittedTextHeight<T extends RenderNode>(node: T): T;
84
102
  /**
85
103
  * Transform SVG d'un groupe : translation vers son origine, puis rotation
86
104
  * autour du centre de son contenu (exprime dans l'espace local du groupe).
@@ -98,6 +98,28 @@ export function measuredTextHeight(node) {
98
98
  const { lineStep } = textMetrics(node);
99
99
  return Math.max(lineStep, textLines(node).length * lineStep);
100
100
  }
101
+ /**
102
+ * Vrai quand la boîte épouse déjà exactement son texte.
103
+ *
104
+ * C'est le signe qu'aucune hauteur n'a été voulue pour elle-même : la boîte
105
+ * suit son contenu, et doit continuer à le suivre quand le texte change. Une
106
+ * hauteur choisie (un libellé calé sur la hauteur de son bouton) ne vérifie pas
107
+ * cette égalité et n'est donc jamais réajustée.
108
+ */
109
+ export function hugsText(node) {
110
+ return Math.abs(Math.abs(node.height) - measuredTextHeight(node)) < 0.5;
111
+ }
112
+ /**
113
+ * Le noeud, avec la hauteur qui épouse son texte.
114
+ *
115
+ * Une boîte à la taille de son texte le place de fait en haut à gauche : le
116
+ * centrage vertical n'a plus rien à décaler. C'est ce qu'attend qui pose un
117
+ * texte sans penser à sa boîte ; un conteneur qui veut centrer son libellé,
118
+ * lui, donne une hauteur explicite.
119
+ */
120
+ export function withFittedTextHeight(node) {
121
+ return { ...node, height: measuredTextHeight(node) };
122
+ }
101
123
  function shapeAttributes(node) {
102
124
  const parts = [];
103
125
  if (node.type !== "line" && node.type !== "text") {
@@ -1,5 +1,24 @@
1
+ /**
2
+ * Comment la GUI sera exposée. `undefined` des deux côtés = rien n'a été passé
3
+ * en ligne de commande : on retombe sur le placeholder PROXY_URL à compléter.
4
+ */
5
+ export interface GuiExposure {
6
+ proxyUrl?: string;
7
+ appPort?: number;
8
+ }
9
+ /**
10
+ * Traduit `--proxy_url=` / `--app_port=` en mode d'exposition de la GUI.
11
+ * Les deux ensemble sont refusés : au runtime PROXY_URL primerait toujours,
12
+ * donc un fichier portant les deux serait silencieusement trompeur.
13
+ */
14
+ export declare function parseGuiExposure(argv: string[]): {
15
+ exposure: GuiExposure;
16
+ } | {
17
+ error: string;
18
+ };
1
19
  export interface SetupMcpOptions {
2
20
  cwd: string;
3
21
  force: boolean;
22
+ exposure?: GuiExposure;
4
23
  }
5
- export declare function runSetupMcp({ cwd, force }: SetupMcpOptions): number;
24
+ export declare function runSetupMcp({ cwd, force, exposure }: SetupMcpOptions): number;
@@ -16,16 +16,20 @@ function toDisplayName(folder) {
16
16
  }
17
17
  /**
18
18
  * Valeurs volontairement vides : chaque variable est inactive tant qu'elle n'est
19
- * pas remplie (PROXY_URL vide = GUI désactivée), donc le fichier généré est
20
- * utilisable tel quel, jamais à moitié cassé.
19
+ * pas remplie (PROXY_URL et APP_PORT vides = GUI désactivée), donc le fichier
20
+ * généré est utilisable tel quel, jamais à moitié cassé.
21
21
  */
22
- function buildServerEntry(codeRoot) {
22
+ function buildServerEntry(codeRoot, exposure) {
23
23
  const folder = path.basename(codeRoot) || "design";
24
+ // Un port explicite désactive le placeholder proxy : les deux variables ne
25
+ // doivent jamais être renseignées ensemble, PROXY_URL primant au runtime.
26
+ const proxyUrl = exposure.appPort !== undefined ? "" : exposure.proxyUrl ?? "http://localhost:";
24
27
  return {
25
28
  command: "npx",
26
29
  args: ["-y", PACKAGE_NAME],
27
30
  env: {
28
- PROXY_URL: "http://localhost:",
31
+ PROXY_URL: proxyUrl,
32
+ APP_PORT: exposure.appPort !== undefined ? String(exposure.appPort) : "",
29
33
  APP_GROUP: capitalize(folder),
30
34
  APP_NAME: `Design ${toDisplayName(folder)}`,
31
35
  APP_PATH: `/design-${folder}`,
@@ -36,13 +40,60 @@ function buildServerEntry(codeRoot) {
36
40
  }
37
41
  const GENERATED_VARS = [
38
42
  ["PROXY_URL", "URL du proxy pour exposer la GUI (compléter le port)"],
43
+ ["APP_PORT", "port local si aucun proxy — la GUI est servie sur http://localhost:<port>"],
39
44
  ["APP_GROUP", "groupe d'affichage dans le dashboard du proxy"],
40
45
  ["APP_NAME", "nom affiché de la GUI dans le dashboard du proxy"],
41
46
  ["APP_PATH", "chemin de montage de la GUI derrière le proxy"],
42
47
  ["MCP_DESIGN_DATA_DIR", "répertoire de stockage des designs (pré-rempli)"],
43
48
  ["MCP_LOG_DIR", "répertoire des logs (défaut : <data>/logs)"],
44
49
  ];
45
- export function runSetupMcp({ cwd, force }) {
50
+ /** Lit `--flag=valeur` (tirets ou underscores acceptés). */
51
+ function readFlag(argv, name) {
52
+ const prefixes = [`--${name}=`, `--${name.replace(/_/g, "-")}=`];
53
+ for (const arg of argv) {
54
+ const prefix = prefixes.find((p) => arg.startsWith(p));
55
+ if (prefix)
56
+ return arg.slice(prefix.length).trim();
57
+ }
58
+ return undefined;
59
+ }
60
+ /**
61
+ * Traduit `--proxy_url=` / `--app_port=` en mode d'exposition de la GUI.
62
+ * Les deux ensemble sont refusés : au runtime PROXY_URL primerait toujours,
63
+ * donc un fichier portant les deux serait silencieusement trompeur.
64
+ */
65
+ export function parseGuiExposure(argv) {
66
+ const rawProxyUrl = readFlag(argv, "proxy_url");
67
+ const rawAppPort = readFlag(argv, "app_port");
68
+ if (rawProxyUrl !== undefined && rawAppPort !== undefined) {
69
+ return { error: "--proxy_url et --app_port sont exclusifs : la GUI passe par le proxy, ou écoute sur un port local." };
70
+ }
71
+ if (rawProxyUrl !== undefined) {
72
+ if (rawProxyUrl === "")
73
+ return { error: "--proxy_url attend une URL, ex : --proxy_url=http://localhost:3000" };
74
+ let parsed;
75
+ try {
76
+ parsed = new URL(rawProxyUrl);
77
+ }
78
+ catch {
79
+ return { error: `--proxy_url invalide : "${rawProxyUrl}" n'est pas une URL absolue (ex : http://localhost:3000).` };
80
+ }
81
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
82
+ return { error: `--proxy_url invalide : protocole "${parsed.protocol}" non supporté (http ou https attendu).` };
83
+ }
84
+ // Une barre finale casserait la concaténation PROXY_URL + APP_PATH.
85
+ return { exposure: { proxyUrl: rawProxyUrl.replace(/\/+$/, "") } };
86
+ }
87
+ if (rawAppPort !== undefined) {
88
+ const port = Number(rawAppPort);
89
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
90
+ return { error: `--app_port invalide : "${rawAppPort}" n'est pas un entier entre 1 et 65535.` };
91
+ }
92
+ return { exposure: { appPort: port } };
93
+ }
94
+ return { exposure: {} };
95
+ }
96
+ export function runSetupMcp({ cwd, force, exposure = {} }) {
46
97
  const target = path.join(cwd, ".mcp.json");
47
98
  let config = {};
48
99
  const exists = fs.existsSync(target);
@@ -72,16 +123,23 @@ export function runSetupMcp({ cwd, force }) {
72
123
  console.error("Relancez avec --force pour écraser cette entrée (les autres serveurs sont préservés).");
73
124
  return 1;
74
125
  }
75
- config.mcpServers[SERVER_NAME] = buildServerEntry(cwd);
126
+ config.mcpServers[SERVER_NAME] = buildServerEntry(cwd, exposure);
76
127
  fs.writeFileSync(target, JSON.stringify(config, null, 2) + "\n", "utf-8");
77
128
  const action = alreadyPresent ? "remplacée" : exists ? "ajoutée" : "créée";
78
129
  console.log(`Configuration ${SERVER_NAME} ${action} dans ${target}`);
79
130
  console.log("");
131
+ if (exposure.appPort !== undefined) {
132
+ console.log(`GUI en accès direct : http://localhost:${exposure.appPort} (sans proxy).`);
133
+ }
134
+ else if (exposure.proxyUrl !== undefined) {
135
+ console.log(`GUI exposée via le proxy ${exposure.proxyUrl}.`);
136
+ }
80
137
  console.log('À compléter dans le champ "env" :');
81
138
  for (const [name, description] of GENERATED_VARS) {
82
139
  console.log(` ${name.padEnd(24)} ${description}`);
83
140
  }
84
141
  console.log("");
85
142
  console.log("Une variable laissée vide est simplement inactive.");
143
+ console.log("PROXY_URL et APP_PORT s'excluent : PROXY_URL prime si les deux sont renseignés.");
86
144
  return 0;
87
145
  }
@@ -1,5 +1,6 @@
1
1
  import { DesignNodeSchema } from "../../shared/schemas/design.schema.js";
2
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 { hugsText, measuredTextHeight } from "../../shared/render.js";
3
4
  import { CORE_ICONS, hasIcon, iconCount } from "../../shared/icons/index.js";
4
5
  import { suggestIcons } from "../../shared/icons/search.js";
5
6
  import { shortId } from "./ids.js";
@@ -106,6 +107,14 @@ export function buildNode(input, existingIds, index) {
106
107
  const size = clampNodeSize(type, Number(draft.width), Number(draft.height));
107
108
  draft.width = size.width;
108
109
  draft.height = size.height;
110
+ // Sans hauteur demandée, la boîte d'un texte est celle de son texte : le bloc
111
+ // de lignes, centré dans une boîte à sa taille, se pose alors exactement au
112
+ // point donné — ce que l'appelant attend quand il n'a parlé que de x et y.
113
+ // Après le clamp, donc : ce plancher garde une boîte dessinée sélectionnable,
114
+ // il n'a pas à gonfler une boîte qui n'a jamais été dimensionnée à la souris.
115
+ if (type === "text" && input.height === undefined) {
116
+ draft.height = measuredTextHeight(draft);
117
+ }
109
118
  const parsed = DesignNodeSchema.safeParse(draft);
110
119
  if (!parsed.success) {
111
120
  throw new DesignError(`Invalid node: ${parsed.error.issues.map((i) => `${i.path.join(".")} ${i.message}`).join("; ")}`);
@@ -131,6 +140,12 @@ export function applyPatch(node, patch) {
131
140
  if (patch[field] !== undefined)
132
141
  next[field] = patch[field];
133
142
  }
143
+ // Une boîte qui épousait son texte continue de l'épouser : rallonger le texte
144
+ // d'une ligne agrandit la boîte au lieu de la faire déborder. Une hauteur
145
+ // choisie — ou redonnée dans ce patch — reste intacte.
146
+ if (node.type === "text" && patch.height === undefined && hugsText(node)) {
147
+ next.height = measuredTextHeight(next);
148
+ }
134
149
  if (node.type === "icon" && patch.icon !== undefined)
135
150
  next.icon = requireIconName(patch.icon);
136
151
  // Changer le lien echange l'instance de composant, comme un "swap instance".
@@ -21,6 +21,29 @@ function findProjectRoot(startDir) {
21
21
  const rootDir = findProjectRoot(__dirname);
22
22
  const absoluteStaticRoot = path.resolve(path.join(rootDir, "gui/dist"));
23
23
  const configuredAppPath = process.env.APP_PATH || "/design-gui";
24
+ /**
25
+ * Chemin réellement monté par le proxy. Il peut différer de `configuredAppPath`
26
+ * quand `APP_PATH_PREFIX` est défini : le client partagé préfixe alors le chemin
27
+ * naturel et renvoie le chemin final dans `result.path`. Le `<base href>` doit
28
+ * suivre ce chemin, sinon les assets de la SPA sont demandés au mauvais endroit.
29
+ */
30
+ let effectiveAppPath = configuredAppPath;
31
+ function resolveGuiMode() {
32
+ const proxyUrl = process.env.PROXY_URL?.trim();
33
+ if (proxyUrl)
34
+ return { kind: "proxy", proxyUrl };
35
+ const rawPort = process.env.APP_PORT?.trim();
36
+ if (!rawPort) {
37
+ return { kind: "disabled", reason: "PROXY_URL et APP_PORT non définis" };
38
+ }
39
+ const port = Number(rawPort);
40
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
41
+ return { kind: "disabled", reason: `APP_PORT invalide (${rawPort}) — attendu : entier entre 1 et 65535` };
42
+ }
43
+ return { kind: "standalone", port };
44
+ }
45
+ const guiMode = resolveGuiMode();
46
+ const isStandalone = guiMode.kind === "standalone";
24
47
  setupLogging({ processLabel: "mcp-design:GUI", logDir: resolveLogDir(rootDir) });
25
48
  const logger = createLogger("mcp-design:GUI");
26
49
  process.on("uncaughtException", (err) => {
@@ -136,7 +159,7 @@ app.get("/proxy/health", (c) => {
136
159
  const memory = process.memoryUsage();
137
160
  return c.json({
138
161
  status: "ok",
139
- proxy: proxyClient ? proxyClient.getStatus() : process.env.PROXY_URL ? "error" : "standalone",
162
+ proxy: proxyClient ? proxyClient.getStatus() : isStandalone ? "standalone" : "error",
140
163
  uptime: process.uptime(),
141
164
  memory: { rss: memory.rss, heapUsed: memory.heapUsed },
142
165
  ipc: process.connected ? "connected" : "disconnected",
@@ -262,7 +285,8 @@ app.get("/*", (c, next) => {
262
285
  if (fs.existsSync(indexPath)) {
263
286
  // ?__direct=1 : accès au worker sur son port local, sans passer par le
264
287
  // proxy — le préfixe de montage ne doit alors pas préfixer les assets.
265
- const baseHref = c.req.query("__direct") === "1" ? "/" : getHtmlBaseHref(configuredAppPath);
288
+ // En mode standalone la GUI est déjà servie à la racine : même règle.
289
+ const baseHref = isStandalone || c.req.query("__direct") === "1" ? "/" : getHtmlBaseHref(effectiveAppPath);
266
290
  const html = fs.readFileSync(indexPath, "utf8").replace("<head>", `<head>\n <base href="${baseHref}" />`);
267
291
  return c.html(html);
268
292
  }
@@ -272,12 +296,51 @@ app.get("/*", (c, next) => {
272
296
  return next();
273
297
  }
274
298
  });
299
+ /**
300
+ * Lance Hono sur `port` et prévient le master. `publicUrl` est l'URL que
301
+ * l'utilisateur doit ouvrir : via le proxy, ou directement en localhost.
302
+ */
303
+ function listen(port, publicUrl, isProxied) {
304
+ const serverInstance = serve({ fetch: app.fetch, port, hostname: "0.0.0.0" }, (info) => {
305
+ logger.info(`Design canvas server listening on port ${info.port}`);
306
+ safeSend(IPCMessageSchema.parse({
307
+ type: "READY",
308
+ data: { port: info.port, isProxied, url: publicUrl },
309
+ timestamp: new Date().toISOString(),
310
+ }));
311
+ });
312
+ serverInstance.on("error", (err) => {
313
+ if (err.code === "EADDRINUSE") {
314
+ // Sortie propre (code 0) : le master ne doit pas relancer un worker de plus.
315
+ logger.error(`Port ${port} already in use — another GUI instance is running.`);
316
+ safeSend(IPCMessageSchema.parse({
317
+ type: "ALREADY_RUNNING",
318
+ data: { url: publicUrl },
319
+ timestamp: new Date().toISOString(),
320
+ }));
321
+ process.exit(0);
322
+ }
323
+ logger.error(`Server error: ${err.message}`);
324
+ process.exit(1);
325
+ });
326
+ }
327
+ /** Sans proxy : la GUI écoute sur APP_PORT et est servie à la racine. */
328
+ function startStandalone(port) {
329
+ const publicUrl = `http://localhost:${port}`;
330
+ logger.info(`Standalone mode (no PROXY_URL): serving GUI on ${publicUrl}`);
331
+ setupIpcErrorHandlers();
332
+ listen(port, publicUrl, false);
333
+ }
275
334
  async function startServer() {
276
- if (!process.env.PROXY_URL) {
277
- process.stderr.write("[GUI] PROXY_URL non défini — arrêt.\n");
335
+ if (guiMode.kind === "disabled") {
336
+ process.stderr.write(`[GUI] ${guiMode.reason} — arrêt.\n`);
278
337
  process.exit(0);
279
338
  }
280
- const proxyUrl = process.env.PROXY_URL;
339
+ if (guiMode.kind === "standalone") {
340
+ startStandalone(guiMode.port);
341
+ return;
342
+ }
343
+ const proxyUrl = guiMode.proxyUrl;
281
344
  const appPath = configuredAppPath;
282
345
  const appName = process.env.APP_NAME || "MCP Design";
283
346
  logger.info(`Starting server with proxyUrl=${proxyUrl}`);
@@ -311,7 +374,8 @@ async function startServer() {
311
374
  return;
312
375
  }
313
376
  const finalPort = result.port;
314
- const publicUrl = result.url || `${proxyUrl}${appPath}`;
377
+ effectiveAppPath = result.path ?? appPath;
378
+ const publicUrl = result.url || `${proxyUrl}${effectiveAppPath}`;
315
379
  logger.info(`Registered with proxy. Public URL: ${publicUrl}, Local Port: ${finalPort}`);
316
380
  const cleanupProxy = async () => {
317
381
  if (proxyClient)
@@ -321,23 +385,7 @@ async function startServer() {
321
385
  process.on("SIGTERM", cleanupProxy);
322
386
  process.on("SIGINT", cleanupProxy);
323
387
  setupIpcErrorHandlers();
324
- const serverInstance = serve({ fetch: app.fetch, port: finalPort, hostname: "0.0.0.0" }, (info) => {
325
- logger.info(`Design canvas server listening on port ${info.port}`);
326
- safeSend(IPCMessageSchema.parse({
327
- type: "READY",
328
- data: { port: info.port, isProxied: true, url: publicUrl },
329
- timestamp: new Date().toISOString(),
330
- }));
331
- });
332
- serverInstance.on("error", (err) => {
333
- if (err.code === "EADDRINUSE") {
334
- // Sortie propre (code 0) : le master ne doit pas relancer un worker de plus.
335
- logger.error(`Port ${finalPort} already in use — another GUI instance is running.`);
336
- process.exit(0);
337
- }
338
- logger.error(`Server error: ${err.message}`);
339
- process.exit(1);
340
- });
388
+ listen(finalPort, publicUrl, true);
341
389
  }
342
390
  startServer().catch((err) => {
343
391
  logger.error(`Failed to start GUI Worker: ${err.message}`);