@imenam/mcp-design 1.0.14 → 1.0.16

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
@@ -361,6 +361,51 @@ Les retours créés avant l'arrivée des noms reçoivent « Retour n°N », et a
361
361
 
362
362
  ---
363
363
 
364
+ ## Version autonome : un seul fichier HTML
365
+
366
+ La même interface existe en un fichier HTML unique, à ouvrir directement dans le navigateur : ni serveur, ni agent MCP, ni connexion réseau. Les pages naissent d'un assistant conversationnel (ChatGPT ou autre) et s'échangent avec lui par fichiers JSON.
367
+
368
+ ```bash
369
+ npm run build:standalone # produit gui/dist-standalone/mcp-design.html
370
+ ```
371
+
372
+ Le fichier embarque tout : le code, les 1 791 icônes, la police Inter (le canvas, les captures et le récap ont donc le même rendu sur toutes les machines).
373
+
374
+ ### Le circuit
375
+
376
+ 1. **Projet › Copier le prompt de création**, coller dans ChatGPT, puis décrire ce qu'on veut. Le prompt décrit le format d'une page, les types d'éléments et leurs champs, les règles de structure, les icônes et un exemple complet.
377
+ 2. ChatGPT renvoie **une page par réponse**, en JSON. **Projet › Importer une page** : coller la réponse (entière, ou seulement son bloc JSON) ou choisir un fichier `.json`. La page est vérifiée en entier avant tout changement ; à la moindre erreur, rien n'est importé et chaque problème est listé, dans un message que **Copier pour ChatGPT** permet de lui renvoyer tel quel. Une page dont l'`id` existe déjà la remplace ; la boîte de dialogue prévient alors des modifications faites à la main qui seraient perdues.
378
+ 3. Retoucher la page à la main, et poser des remarques comme dans la version serveur (mode Remarques, **Terminer les retours**).
379
+ 4. **Télécharger le récap** (à la fin de « Terminer les retours », ou dans la page « Retours ») : une archive `recap-retour-N.zip` qui réunit un fichier `recap-retour-N.md` et des images — une vue de chaque page concernée, remarques entourées et numérotées, et les captures des remarques. Le texte reprend les remarques, le JSON courant de chaque page et un rappel du format. **Copier le texte** le met dans le presse-papiers à la place du fichier.
380
+ 5. ChatGPT renvoie les pages corrigées, une par réponse, à importer comme à l'étape 2. Chacune liste dans `processed` les remarques traitées, avec une note : elles passent à *Traitée par l'agent* dans la page « Retours ».
381
+
382
+ ### Format d'une page
383
+
384
+ ```json
385
+ {
386
+ "format": "mcp-design-page",
387
+ "version": 1,
388
+ "page": { "id": "home", "name": "Home", "group": null, "width": 1440, "height": 900, "background": "#ffffff", "nodes": [] },
389
+ "processed": [{ "remark": "rm_xxxxxxxx", "note": "Logo agrandi" }]
390
+ }
391
+ ```
392
+
393
+ Les éléments de `nodes` suivent le modèle des outils MCP (liste plate, `parentId`, coordonnées relatives au parent) et passent par la même validation que `add_nodes`. L'import est plus strict : un champ qui ne s'applique pas au type de l'élément est refusé, et les éléments `image` n'existent pas dans cette version (pas de médiathèque). La taille des groupes et des instances, dérivée de leur contenu, n'apparaît pas dans le JSON transmis et n'a pas à être fournie.
394
+
395
+ ### Données
396
+
397
+ Le projet (pages, remarques, captures) vit dans le navigateur, qui en garde une copie (IndexedDB) relue à l'ouverture : recharger l'onglet ne perd rien. Cette copie dépend du navigateur et de son profil ; **le fichier exporté fait foi**. **Projet › Exporter le projet** télécharge `mcp-design-projet-AAAA-MM-JJ.json`, **Ouvrir un projet…** le recharge, après confirmation, à la place du projet courant. Le bouton **Projet** porte un point orange tant que des modifications n'ont pas été exportées, rouge si la copie du navigateur ne peut pas être lue ou écrite.
398
+
399
+ Le filtre **À synchroniser** retient les pages modifiées à la main depuis le dernier échange avec ChatGPT : import de la page, ou récap qui la contient.
400
+
401
+ ### Ce qui diffère de la version serveur
402
+
403
+ - Pas d'agent MCP : ni outils, ni `get_changes`, ni rafraîchissement périodique. L'assistant ne voit que ce qu'on lui transmet.
404
+ - Pas d'images posées sur la planche.
405
+ - Une seule personne, un seul navigateur : le projet circule par fichier.
406
+
407
+ ---
408
+
364
409
  ## Architecture
365
410
 
366
411
  Deux processus, conformément au standard de l'écosystème (`@imenam/mcp-gui-interface`) :
@@ -372,7 +417,11 @@ Les deux communiquent par IPC. Les évènements que le master pousse au worker (
372
417
 
373
418
  `shared/feedback.ts` porte le modèle des remarques, la détection des éléments d'une zone et la mise en forme d'un retour ; le serveur et la GUI l'importent tous deux, ce qui garantit que l'aperçu affiché est le texte transmis. `shared/render.ts` contient le rendu SVG d'un élément, `shared/tree.ts` la résolution de la hiérarchie en coordonnées document, `shared/instances.ts` l'expansion des instances en groupes, `shared/node-defaults.ts` les valeurs par défaut et `shared/icons/` la bibliothèque d'icônes. Tous sont importés **à la fois** par l'exporteur côté serveur et par le canvas côté navigateur : ce que l'utilisateur voit est exactement ce que `export_design` — et donc `screenshot_design`, qui rasterise ce même SVG via resvg — produit.
374
419
 
375
- Les invariants de hiérarchie — parent existant et de type `group`, absence de cycle (y compris dans le graphe composant/instance), taille des groupes et des instances recalculée, ordre préfixe — sont appliqués dans `DesignStore.mutate`, donc sur **tout** chemin d'écriture : outil MCP comme sauvegarde depuis la GUI.
420
+ Les invariants de hiérarchie — parent existant et de type `group`, absence de cycle (y compris dans le graphe composant/instance), taille des groupes et des instances recalculée, ordre préfixe — sont appliqués par `mutatedDesign` (`src/core/design-model.ts`), donc sur **tout** chemin d'écriture : outil MCP, sauvegarde depuis la GUI, import dans la version autonome.
421
+
422
+ `src/core/design-model.ts` et `src/core/feedback-model.ts` portent les règles des pages et des remarques indépendamment de leur stockage : `DesignStore` et `FeedbackStore` les appliquent à des fichiers, la version autonome à son projet en mémoire. `src/core/exchange.ts` porte ce qui est propre à la version autonome et reste testable sans navigateur : prompt, lecture d'une page, récap, export du projet.
423
+
424
+ La version autonome (`gui/vite.standalone.config.ts`) bâtit la même application React depuis `gui/mcp-design.html`, en substituant deux modules : `utils/api.ts` (appels HTTP au worker) par `standalone/api.ts` (projet en mémoire), et `hooks/useServerChanges.ts` par un simple chargement initial. Ce qui distingue les deux hôtes — textes qui nomment le destinataire des retours, menu **Projet**, actions du récap — passe par `gui/src/host.tsx`.
376
425
 
377
426
  **Limite assumée de cette version** : un groupe se déplace, se pivote et change d'opacité, mais ne se redimensionne pas — mettre son contenu à l'échelle demanderait de propager un facteur dans tout le sous-arbre, ce qui dépasse le cadre d'un premier jet sobre.
378
427
 
@@ -383,6 +432,7 @@ Les invariants de hiérarchie — parent existant et de type `group`, absence de
383
432
  ```bash
384
433
  npm install
385
434
  npm run build # backend (tsc) + GUI (vite)
435
+ npm run build:standalone # version autonome : gui/dist-standalone/mcp-design.html
386
436
  npm test # suite complète
387
437
  npm run generate-icons # refige shared/icons/ après une mise à jour de lucide-static
388
438
  ```
@@ -391,6 +441,7 @@ La suite comprend :
391
441
 
392
442
  - des tests unitaires du store, de la fabrique d'éléments, du moteur de diff et de l'export ;
393
443
  - des tests des outils MCP appelés directement ;
444
+ - des tests des échanges de la version autonome (`test/exchange.test.ts`) : l'exemple du prompt s'importe tel quel, une page exportée se réimporte à l'identique, chaque erreur est signalée sans rien importer, les remarques traitées sont vérifiées puis marquées ;
394
445
  - des tests d'exposition réseau (`test/network-exposure.test.ts`) qui lancent le worker GUI en mode `APP_PORT` et vérifient l'écoute sur `127.0.0.1` seul, le refus d'un `Host` étranger et des modifications cross-site, et l'acceptation des requêtes relayées par le proxy ;
395
446
  - une suite de bout en bout (`test/e2e.test.ts`) qui lance un vrai serveur MCP sur stdio, attend l'enregistrement de son worker auprès du proxy, puis vérifie l'aller-retour complet agent → GUI → agent.
396
447
 
@@ -401,3 +452,9 @@ Pour inspecter le worker sans traverser le proxy, ajoutez `?__direct=1` à son p
401
452
  ### Méta-configurations du proxy
402
453
 
403
454
  `mcp-design` n'expose pas `GET /api/configurations` : il n'a pas de configuration métier (ni token, ni identifiant de ressource) qui justifierait des profils. Le proxy ignore simplement les MCPs qui n'implémentent pas ces routes.
455
+
456
+ ## Publication
457
+
458
+ Chaque push sur `master`, merge de pull request compris, publie une nouvelle version de `@imenam/mcp-design` sur npm (`.github/workflows/publish.yml`) : `npm ci`, build et tests, puis `npm version patch`, dont le commit et le tag sont poussés sur `master`, et `npm publish`. La publication passe par le trusted publishing de npm (OIDC, sans token), avec attestation de provenance. Un échec du build ou des tests arrête le workflow avant la montée de version.
459
+
460
+ Sur npmjs.com, le paquet déclare ce workflow comme publieur de confiance (Settings → Trusted publishing → GitHub Actions : `Matthieu-Pesnot-Pin`, `mcp-design`, `publish.yml`, avec `npm publish` dans les actions autorisées). Le commit de version est poussé avec `GITHUB_TOKEN` : si `master` est protégée, la règle doit autoriser GitHub Actions à y pousser.
@@ -123,6 +123,7 @@ export declare const PATH_SEPARATOR = " \u203A ";
123
123
  * La GUI l'affiche en aperçu avant l'enregistrement : c'est la même fonction,
124
124
  * donc le même texte. Chaque remarque porte son identifiant, que l'agent passe
125
125
  * à `mark_feedback_processed`. Les captures sont numérotées dans l'ordre des
126
- * remarques ; `get_feedback` joint les images dans cet ordre.
126
+ * remarques ; `get_feedback` joint les images dans cet ordre. `coordinatesSource`
127
+ * nomme ce qui, chez le lecteur, exprime les coordonnées dans le même repère.
127
128
  */
128
- export declare function formatFeedbackRound(round: Pick<FeedbackRound, "number" | "title" | "submittedAt" | "remarks">): string;
129
+ export declare function formatFeedbackRound(round: Pick<FeedbackRound, "number" | "title" | "submittedAt" | "remarks">, coordinatesSource?: string): string;
@@ -118,9 +118,10 @@ function indent(text, prefix) {
118
118
  * La GUI l'affiche en aperçu avant l'enregistrement : c'est la même fonction,
119
119
  * donc le même texte. Chaque remarque porte son identifiant, que l'agent passe
120
120
  * à `mark_feedback_processed`. Les captures sont numérotées dans l'ordre des
121
- * remarques ; `get_feedback` joint les images dans cet ordre.
121
+ * remarques ; `get_feedback` joint les images dans cet ordre. `coordinatesSource`
122
+ * nomme ce qui, chez le lecteur, exprime les coordonnées dans le même repère.
122
123
  */
123
- export function formatFeedbackRound(round) {
124
+ export function formatFeedbackRound(round, coordinatesSource = "get_design") {
124
125
  const pages = new Map();
125
126
  for (const remark of round.remarks) {
126
127
  const list = pages.get(remark.designId);
@@ -133,7 +134,7 @@ export function formatFeedbackRound(round) {
133
134
  const lines = [
134
135
  `Feedback round ${round.number} "${round.title}" — submitted ${round.submittedAt} — ` +
135
136
  `${round.remarks.length} remark(s) on ${pages.size} page(s), ${progress.done} processed.`,
136
- "Coordinates are design pixels in the artboard's space, as in get_design.",
137
+ `Coordinates are design pixels in the artboard's space, as in ${coordinatesSource}.`,
137
138
  ];
138
139
  let image = 0;
139
140
  for (const [designId, remarks] of pages) {
@@ -0,0 +1,54 @@
1
+ import { type Design, type DesignSummary } from "../../shared/schemas/design.schema.js";
2
+ /**
3
+ * Règles d'un design indépendantes de son stockage : création, copie,
4
+ * mutation validée, synchronisation. Le `DesignStore` du serveur les applique à
5
+ * des fichiers JSON, la version autonome de la GUI à son projet en mémoire.
6
+ */
7
+ export interface CreateDesignInput {
8
+ name: string;
9
+ width?: number;
10
+ height?: number;
11
+ background?: string;
12
+ /** Groupe de pages, chemin "Parent/Enfant". Null ou omis : a la racine. */
13
+ group?: string | null;
14
+ }
15
+ export interface MutateOptions {
16
+ /**
17
+ * Révision sur laquelle l'édition est basée. Quand elle est fournie et que le
18
+ * design a avancé entre-temps, l'écriture est refusée : sans cette garde, un
19
+ * éditeur parti d'un état périmé réécrit le document entier et efface le
20
+ * travail de l'autre.
21
+ */
22
+ baseRevision?: number;
23
+ }
24
+ /** Identifiant lisible dérivé du nom, suffixé seulement en cas de collision. */
25
+ export declare function allocateDesignId(name: string, exists: (id: string) => boolean): string;
26
+ /**
27
+ * Lit un design brut, après mise à niveau de son format. `migrated` indique
28
+ * qu'il diffère de ce qui a été lu et mérite d'être réécrit.
29
+ */
30
+ export declare function parseDesign(raw: unknown, label: string): {
31
+ design: Design;
32
+ migrated: boolean;
33
+ };
34
+ export declare function newDesign(id: string, input: CreateDesignInput, now: string): Design;
35
+ /**
36
+ * Nouveau design reprenant la planche et tous les éléments d'un design
37
+ * existant, identifiants compris. La copie part synchronisée : sa référence
38
+ * de comparaison est son propre contenu.
39
+ */
40
+ export declare function duplicatedDesign(source: Design, id: string, input: {
41
+ name?: string;
42
+ group?: string | null;
43
+ }, now: string): Design;
44
+ /**
45
+ * Applique une mutation à une copie du design et incrémente la révision.
46
+ * `editor` distingue une modification de l'agent d'une modification faite à la
47
+ * main dans la GUI — c'est ce qui permet à get_changes de rester lisible.
48
+ */
49
+ export declare function mutatedDesign(current: Design, editor: "agent" | "user", fn: (design: Design) => void, options?: MutateOptions): Design;
50
+ /** Le design avec son état courant comme nouvelle référence de comparaison. */
51
+ export declare function syncedDesign(current: Design, now: string): Design;
52
+ export declare function summaryOf(design: Design): DesignSummary;
53
+ /** Plus récemment modifiés d'abord, comme la liste des pages de la GUI. */
54
+ export declare function sortSummaries(summaries: DesignSummary[]): DesignSummary[];
@@ -0,0 +1,129 @@
1
+ import { DesignSchema, normalizeDesignGroup, } from "../../shared/schemas/design.schema.js";
2
+ import { DesignError } from "./node-factory.js";
3
+ import { normalizeHierarchy } from "./hierarchy.js";
4
+ import { migrateDesign } from "./migrate.js";
5
+ import { shortId, slugify } from "./ids.js";
6
+ import { countChanges } from "./diff.js";
7
+ function issuesOf(error) {
8
+ return error.issues.map((issue) => `${issue.path.map(String).join(".")} ${issue.message}`).join("; ");
9
+ }
10
+ /** Identifiant lisible dérivé du nom, suffixé seulement en cas de collision. */
11
+ export function allocateDesignId(name, exists) {
12
+ const base = slugify(name);
13
+ if (!exists(base))
14
+ return base;
15
+ for (let i = 2; i < 100; i++) {
16
+ const candidate = `${base}-${i}`;
17
+ if (!exists(candidate))
18
+ return candidate;
19
+ }
20
+ return `${base}-${shortId("x", 4)}`;
21
+ }
22
+ /**
23
+ * Lit un design brut, après mise à niveau de son format. `migrated` indique
24
+ * qu'il diffère de ce qui a été lu et mérite d'être réécrit.
25
+ */
26
+ export function parseDesign(raw, label) {
27
+ const migrated = migrateDesign(raw);
28
+ const parsed = DesignSchema.safeParse(migrated ?? raw);
29
+ if (!parsed.success) {
30
+ throw new DesignError(`Design ${label} does not match the expected schema: ${issuesOf(parsed.error)}`);
31
+ }
32
+ return { design: parsed.data, migrated: migrated !== null };
33
+ }
34
+ export function newDesign(id, input, now) {
35
+ const name = input.name?.trim();
36
+ if (!name)
37
+ throw new DesignError(`"name" is required to create a design.`);
38
+ const width = input.width ?? 1440;
39
+ const height = input.height ?? 900;
40
+ if (!Number.isFinite(width) || width <= 0)
41
+ throw new DesignError(`"width" must be a positive number.`);
42
+ if (!Number.isFinite(height) || height <= 0)
43
+ throw new DesignError(`"height" must be a positive number.`);
44
+ return {
45
+ id,
46
+ name,
47
+ group: normalizeDesignGroup(input.group),
48
+ width,
49
+ height,
50
+ background: input.background ?? "#ffffff",
51
+ nodes: [],
52
+ createdAt: now,
53
+ updatedAt: now,
54
+ revision: 0,
55
+ lastEditor: "agent",
56
+ baseline: { revision: 0, at: now, nodes: [] },
57
+ };
58
+ }
59
+ /**
60
+ * Nouveau design reprenant la planche et tous les éléments d'un design
61
+ * existant, identifiants compris. La copie part synchronisée : sa référence
62
+ * de comparaison est son propre contenu.
63
+ */
64
+ export function duplicatedDesign(source, id, input, now) {
65
+ const name = input.name?.trim() || `${source.name} (copy)`;
66
+ return {
67
+ id,
68
+ name,
69
+ group: input.group === undefined ? source.group : normalizeDesignGroup(input.group),
70
+ width: source.width,
71
+ height: source.height,
72
+ background: source.background,
73
+ nodes: structuredClone(source.nodes),
74
+ createdAt: now,
75
+ updatedAt: now,
76
+ revision: 0,
77
+ lastEditor: "agent",
78
+ baseline: { revision: 0, at: now, nodes: structuredClone(source.nodes) },
79
+ };
80
+ }
81
+ /**
82
+ * Applique une mutation à une copie du design et incrémente la révision.
83
+ * `editor` distingue une modification de l'agent d'une modification faite à la
84
+ * main dans la GUI — c'est ce qui permet à get_changes de rester lisible.
85
+ */
86
+ export function mutatedDesign(current, editor, fn, options = {}) {
87
+ if (options.baseRevision !== undefined && options.baseRevision !== current.revision) {
88
+ throw new DesignError(`Design "${current.id}" has changed since it was loaded: your edit is based on revision ${options.baseRevision}, ` +
89
+ `the stored design is at revision ${current.revision}. Reload it with get_design and reapply your change — ` +
90
+ `writing now would discard the other editor's work.`);
91
+ }
92
+ const draft = structuredClone(current);
93
+ fn(draft);
94
+ // Les invariants de hiérarchie sont vérifiés ici, une seule fois : aucun
95
+ // appelant — outil MCP ou sauvegarde depuis la GUI — ne peut les contourner.
96
+ draft.nodes = normalizeHierarchy(draft.nodes);
97
+ draft.revision = current.revision + 1;
98
+ draft.updatedAt = new Date().toISOString();
99
+ draft.lastEditor = editor;
100
+ const parsed = DesignSchema.safeParse(draft);
101
+ if (!parsed.success)
102
+ throw new DesignError(`Resulting design is invalid: ${issuesOf(parsed.error)}`);
103
+ return parsed.data;
104
+ }
105
+ /** Le design avec son état courant comme nouvelle référence de comparaison. */
106
+ export function syncedDesign(current, now) {
107
+ return {
108
+ ...structuredClone(current),
109
+ baseline: { revision: current.revision, at: now, nodes: structuredClone(current.nodes) },
110
+ };
111
+ }
112
+ export function summaryOf(design) {
113
+ return {
114
+ id: design.id,
115
+ name: design.name,
116
+ group: design.group,
117
+ width: design.width,
118
+ height: design.height,
119
+ nodeCount: design.nodes.length,
120
+ updatedAt: design.updatedAt,
121
+ revision: design.revision,
122
+ lastEditor: design.lastEditor,
123
+ pendingChanges: countChanges(design.baseline.nodes, design.nodes),
124
+ };
125
+ }
126
+ /** Plus récemment modifiés d'abord, comme la liste des pages de la GUI. */
127
+ export function sortSummaries(summaries) {
128
+ return summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
129
+ }
@@ -1,22 +1,7 @@
1
- import { type Design, type DesignSummary } from "../../shared/schemas/design.schema.js";
1
+ import type { Design, DesignSummary } from "../../shared/schemas/design.schema.js";
2
+ import { type CreateDesignInput, type MutateOptions } from "./design-model.js";
2
3
  import { ImageStore } from "./image-store.js";
3
- export interface CreateDesignInput {
4
- name: string;
5
- width?: number;
6
- height?: number;
7
- background?: string;
8
- /** Groupe de pages, chemin "Parent/Enfant". Null ou omis : a la racine. */
9
- group?: string | null;
10
- }
11
- export interface MutateOptions {
12
- /**
13
- * Révision sur laquelle l'édition est basée. Quand elle est fournie et que le
14
- * fichier a avancé entre-temps, l'écriture est refusée : sans cette garde, un
15
- * éditeur parti d'un état périmé réécrit le document entier et efface le
16
- * travail de l'autre.
17
- */
18
- baseRevision?: number;
19
- }
4
+ export type { CreateDesignInput, MutateOptions };
20
5
  /**
21
6
  * Persistance des designs : un fichier JSON par design dans <dataDir>/designs.
22
7
  * Pas de base de données — un design tient dans quelques dizaines de Ko et le
@@ -1,12 +1,8 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
- import { DesignSchema, normalizeDesignGroup, } from "../../shared/schemas/design.schema.js";
4
3
  import { DesignError } from "./node-factory.js";
5
4
  import { withFileLock } from "./file-lock.js";
6
- import { normalizeHierarchy } from "./hierarchy.js";
7
- import { migrateDesign } from "./migrate.js";
8
- import { shortId, slugify } from "./ids.js";
9
- import { countChanges } from "./diff.js";
5
+ import { allocateDesignId, duplicatedDesign, mutatedDesign, newDesign, parseDesign, sortSummaries, summaryOf, syncedDesign, } from "./design-model.js";
10
6
  import { ImageStore } from "./image-store.js";
11
7
  /**
12
8
  * Persistance des designs : un fichier JSON par design dans <dataDir>/designs.
@@ -75,15 +71,7 @@ export class DesignStore {
75
71
  }
76
72
  /** Identifiant lisible dérivé du nom, suffixé seulement en cas de collision. */
77
73
  allocateId(name) {
78
- const base = slugify(name);
79
- if (!fs.existsSync(this.filePath(base)))
80
- return base;
81
- for (let i = 2; i < 100; i++) {
82
- const candidate = `${base}-${i}`;
83
- if (!fs.existsSync(this.filePath(candidate)))
84
- return candidate;
85
- }
86
- return `${base}-${shortId("x", 4)}`;
74
+ return allocateDesignId(name, (id) => fs.existsSync(this.filePath(id)));
87
75
  }
88
76
  list() {
89
77
  const files = fs.existsSync(this.designsDir)
@@ -99,20 +87,9 @@ export class DesignStore {
99
87
  catch {
100
88
  continue; // Fichier corrompu ou hors schéma : il ne doit pas casser la liste.
101
89
  }
102
- summaries.push({
103
- id: design.id,
104
- name: design.name,
105
- group: design.group,
106
- width: design.width,
107
- height: design.height,
108
- nodeCount: design.nodes.length,
109
- updatedAt: design.updatedAt,
110
- revision: design.revision,
111
- lastEditor: design.lastEditor,
112
- pendingChanges: countChanges(design.baseline.nodes, design.nodes),
113
- });
90
+ summaries.push(summaryOf(design));
114
91
  }
115
- return summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
92
+ return sortSummaries(summaries);
116
93
  }
117
94
  has(id) {
118
95
  return fs.existsSync(this.filePath(id));
@@ -138,13 +115,7 @@ export class DesignStore {
138
115
  }
139
116
  // Mise à niveau du format avant validation : un fichier écrit par une
140
117
  // version antérieure reste lisible, et n'est réécrit qu'en cas de changement.
141
- const migrated = migrateDesign(raw);
142
- const parsed = DesignSchema.safeParse(migrated ?? raw);
143
- if (!parsed.success) {
144
- throw new DesignError(`Design "${id}" does not match the expected schema: ${parsed.error.issues
145
- .map((i) => `${i.path.join(".")} ${i.message}`)
146
- .join("; ")}`);
147
- }
118
+ const { design, migrated } = parseDesign(raw, `"${id}"`);
148
119
  if (migrated) {
149
120
  // Best effort : un répertoire de données en lecture seule ne doit pas
150
121
  // empêcher de lire le design, la migration reste appliquée en mémoire.
@@ -152,42 +123,19 @@ export class DesignStore {
152
123
  // de `withLock`, et le verrou n'est pas réentrant. Le contenu écrit est
153
124
  // celui qu'on vient de lire, la mise à niveau ne perd donc rien.
154
125
  try {
155
- this.write(parsed.data);
126
+ this.write(design);
156
127
  }
157
128
  catch {
158
129
  this.cache.delete(id);
159
130
  }
160
- return parsed.data;
131
+ return design;
161
132
  }
162
133
  if (stamp)
163
- this.cache.set(id, { design: parsed.data, stamp });
164
- return parsed.data;
134
+ this.cache.set(id, { design, stamp });
135
+ return design;
165
136
  }
166
137
  create(input) {
167
- const name = input.name?.trim();
168
- if (!name)
169
- throw new DesignError(`"name" is required to create a design.`);
170
- const width = input.width ?? 1440;
171
- const height = input.height ?? 900;
172
- if (!Number.isFinite(width) || width <= 0)
173
- throw new DesignError(`"width" must be a positive number.`);
174
- if (!Number.isFinite(height) || height <= 0)
175
- throw new DesignError(`"height" must be a positive number.`);
176
- const now = new Date().toISOString();
177
- const design = {
178
- id: this.allocateId(name),
179
- name,
180
- group: normalizeDesignGroup(input.group),
181
- width,
182
- height,
183
- background: input.background ?? "#ffffff",
184
- nodes: [],
185
- createdAt: now,
186
- updatedAt: now,
187
- revision: 0,
188
- lastEditor: "agent",
189
- baseline: { revision: 0, at: now, nodes: [] },
190
- };
138
+ const design = newDesign(this.allocateId(input.name?.trim() ?? ""), input, new Date().toISOString());
191
139
  this.write(design);
192
140
  return design;
193
141
  }
@@ -199,21 +147,7 @@ export class DesignStore {
199
147
  duplicate(sourceId, input = {}) {
200
148
  const source = this.get(sourceId);
201
149
  const name = input.name?.trim() || `${source.name} (copy)`;
202
- const now = new Date().toISOString();
203
- const design = {
204
- id: this.allocateId(name),
205
- name,
206
- group: input.group === undefined ? source.group : normalizeDesignGroup(input.group),
207
- width: source.width,
208
- height: source.height,
209
- background: source.background,
210
- nodes: structuredClone(source.nodes),
211
- createdAt: now,
212
- updatedAt: now,
213
- revision: 0,
214
- lastEditor: "agent",
215
- baseline: { revision: 0, at: now, nodes: structuredClone(source.nodes) },
216
- };
150
+ const design = duplicatedDesign(source, this.allocateId(name), { ...input, name }, new Date().toISOString());
217
151
  this.write(design);
218
152
  return design;
219
153
  }
@@ -228,36 +162,14 @@ export class DesignStore {
228
162
  return this.withLock(id, () => this.mutateLocked(id, editor, fn, options));
229
163
  }
230
164
  mutateLocked(id, editor, fn, options) {
231
- const current = this.get(id);
232
- if (options.baseRevision !== undefined && options.baseRevision !== current.revision) {
233
- throw new DesignError(`Design "${id}" has changed since it was loaded: your edit is based on revision ${options.baseRevision}, ` +
234
- `the stored design is at revision ${current.revision}. Reload it with get_design and reapply your change — ` +
235
- `writing now would discard the other editor's work.`);
236
- }
237
- const draft = structuredClone(current);
238
- fn(draft);
239
- // Les invariants de hiérarchie sont vérifiés ici, une seule fois : aucun
240
- // appelant — outil MCP ou sauvegarde depuis la GUI — ne peut les contourner.
241
- draft.nodes = normalizeHierarchy(draft.nodes);
242
- draft.revision = current.revision + 1;
243
- draft.updatedAt = new Date().toISOString();
244
- draft.lastEditor = editor;
245
- const parsed = DesignSchema.safeParse(draft);
246
- if (!parsed.success) {
247
- throw new DesignError(`Resulting design is invalid: ${parsed.error.issues.map((i) => `${i.path.join(".")} ${i.message}`).join("; ")}`);
248
- }
249
- this.write(parsed.data);
250
- return parsed.data;
165
+ const next = mutatedDesign(this.get(id), editor, fn, options);
166
+ this.write(next);
167
+ return next;
251
168
  }
252
169
  /** Fige l'état courant comme nouvelle référence de comparaison. */
253
170
  markSynced(id) {
254
171
  return this.withLock(id, () => {
255
- const current = this.get(id);
256
- const now = new Date().toISOString();
257
- const next = {
258
- ...structuredClone(current),
259
- baseline: { revision: current.revision, at: now, nodes: structuredClone(current.nodes) },
260
- };
172
+ const next = syncedDesign(this.get(id), new Date().toISOString());
261
173
  this.write(next);
262
174
  return next;
263
175
  });
@@ -0,0 +1,86 @@
1
+ import { type FeedbackRound } from "../../shared/feedback.js";
2
+ import { type Design } from "../../shared/schemas/design.schema.js";
3
+ import type { FeedbackFile } from "../../shared/schemas/feedback.schema.js";
4
+ import { DesignError } from "./node-factory.js";
5
+ /**
6
+ * Échanges de la version autonome avec un assistant conversationnel :
7
+ *
8
+ * - le prompt qui lui décrit le format d'une page ;
9
+ * - la lecture stricte d'une page qu'il renvoie, refusée en bloc à la moindre
10
+ * erreur avec un message qu'on peut lui recoller tel quel ;
11
+ * - le récap d'un retour, qui lui transmet les remarques et l'état courant des
12
+ * pages concernées ;
13
+ * - l'export et la relecture du projet complet.
14
+ */
15
+ export declare const PAGE_FORMAT = "mcp-design-page";
16
+ export declare const PROJECT_FORMAT = "mcp-design-project";
17
+ export declare const EXCHANGE_VERSION = 1;
18
+ /** Pages, remarques et captures (data URI PNG par nom de fichier) d'un projet autonome. */
19
+ export interface ProjectData {
20
+ designs: Design[];
21
+ feedback: FeedbackFile;
22
+ captures: Record<string, string>;
23
+ }
24
+ /** Refus d'un fichier importé. `issues` liste chaque problème, `message` s'adresse à l'assistant. */
25
+ export declare class ExchangeError extends DesignError {
26
+ readonly issues: string[];
27
+ constructor(issues: string[]);
28
+ }
29
+ /** Une page lue et validée, prête à remplacer ou rejoindre celles du projet. */
30
+ export interface PageImport {
31
+ /** La page telle qu'elle sera enregistrée, synchronisée sur son nouveau contenu. */
32
+ design: Design;
33
+ /** Page existante que l'import remplace, ou null pour une nouvelle page. */
34
+ replaces: Design | null;
35
+ /** Remarques déclarées traitées, avec le retour qui les contient. */
36
+ processed: {
37
+ round: number;
38
+ remarkId: string;
39
+ note: string | null;
40
+ }[];
41
+ }
42
+ /**
43
+ * Lit une page renvoyée par l'assistant. Rien n'est appliqué ici : le résultat
44
+ * dit ce que l'import ferait, `applyPageImport` l'exécute.
45
+ */
46
+ export declare function parsePageReply(text: string, project: Pick<ProjectData, "designs" | "feedback">): PageImport;
47
+ /** Enregistre une page lue par `parsePageReply` et note les remarques traitées. */
48
+ export declare function applyPageImport(project: ProjectData, plan: PageImport): void;
49
+ interface PageFile {
50
+ format: string;
51
+ version: number;
52
+ page: {
53
+ id: string;
54
+ name: string;
55
+ group: string | null;
56
+ width: number;
57
+ height: number;
58
+ background: string;
59
+ nodes: object[];
60
+ };
61
+ processed: {
62
+ remark: string;
63
+ note?: string;
64
+ }[];
65
+ }
66
+ /** La page au format d'échange. La taille des groupes et des instances, dérivée, n'y figure pas. */
67
+ export declare function pageFileOf(design: Design): PageFile;
68
+ /** JSON d'une page, un élément par ligne : lisible et bien plus court qu'une indentation complète. */
69
+ export declare function pageJson(file: PageFile): string;
70
+ export declare function projectFileOf(project: ProjectData, exportedAt: string): object;
71
+ /** Relit un export complet ; refusé en bloc à la moindre incohérence. */
72
+ export declare function parseProjectFile(text: string): ProjectData;
73
+ /** Ce que l'utilisateur colle en tête de conversation, suivi de sa demande. */
74
+ export declare function creationPrompt(): string;
75
+ /** Ce qui, pour l'assistant, exprime les coordonnées des remarques. */
76
+ export declare const COORDINATES_SOURCE = "the page JSON";
77
+ /** Fichiers joints au récap : une vue de chaque page, et une image par capture. */
78
+ export interface RecapFiles {
79
+ /** Vue d'ensemble de chaque page concernée, par id de page. */
80
+ pages: Map<string, string>;
81
+ /** Fichier de chaque capture, dans l'ordre où le texte du retour les numérote. */
82
+ captures: string[];
83
+ }
84
+ /** Texte du récap d'un retour, à transmettre à l'assistant avec les fichiers joints. */
85
+ export declare function recapText(round: FeedbackRound, designs: Design[], files: RecapFiles): string;
86
+ export {};