@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.
@@ -0,0 +1,430 @@
1
+ import { z } from "zod";
2
+ import { formatFeedbackRound } from "../../shared/feedback.js";
3
+ import { CORE_ICONS, hasIcon } from "../../shared/icons/index.js";
4
+ import { suggestIcons } from "../../shared/icons/search.js";
5
+ import { NODE_DEFAULTS, NODE_GEOMETRY_FIELDS, NODE_STYLE_FIELDS, SIZE_DERIVED_KINDS, } from "../../shared/node-defaults.js";
6
+ import { NODE_FIELD_DOCS } from "../../shared/node-fields.js";
7
+ import { normalizeDesignGroup } from "../../shared/schemas/design.schema.js";
8
+ import { mutatedDesign, newDesign, parseDesign, syncedDesign } from "./design-model.js";
9
+ import { parseFeedbackFile, setRemarksProcessed } from "./feedback-model.js";
10
+ import { normalizeHierarchy } from "./hierarchy.js";
11
+ import { DesignError, buildNode } from "./node-factory.js";
12
+ /**
13
+ * Échanges de la version autonome avec un assistant conversationnel :
14
+ *
15
+ * - le prompt qui lui décrit le format d'une page ;
16
+ * - la lecture stricte d'une page qu'il renvoie, refusée en bloc à la moindre
17
+ * erreur avec un message qu'on peut lui recoller tel quel ;
18
+ * - le récap d'un retour, qui lui transmet les remarques et l'état courant des
19
+ * pages concernées ;
20
+ * - l'export et la relecture du projet complet.
21
+ */
22
+ export const PAGE_FORMAT = "mcp-design-page";
23
+ export const PROJECT_FORMAT = "mcp-design-project";
24
+ export const EXCHANGE_VERSION = 1;
25
+ /** Refus d'un fichier importé. `issues` liste chaque problème, `message` s'adresse à l'assistant. */
26
+ export class ExchangeError extends DesignError {
27
+ issues;
28
+ constructor(issues) {
29
+ super(`The JSON could not be imported:\n${issues.map((issue) => `- ${issue}`).join("\n")}\n` +
30
+ `Fix these points and send the whole page again, as a single JSON document.`);
31
+ this.issues = issues;
32
+ }
33
+ }
34
+ /** Les types qu'un assistant peut écrire : pas d'image, la version autonome n'a pas de médiathèque. */
35
+ const EXCHANGE_KINDS = Object.keys(NODE_DEFAULTS).filter((kind) => kind !== "image");
36
+ const COMMON_FIELDS = ["id", "type", "name", "parentId", ...NODE_GEOMETRY_FIELDS];
37
+ function allowedFields(kind) {
38
+ return [...COMMON_FIELDS, ...NODE_STYLE_FIELDS[kind]];
39
+ }
40
+ const ProcessedSchema = z.object({ remark: z.string().min(1), note: z.string().optional() }).strict();
41
+ const PageFileSchema = z
42
+ .object({
43
+ format: z.literal(PAGE_FORMAT),
44
+ version: z.literal(EXCHANGE_VERSION),
45
+ page: z
46
+ .object({
47
+ id: z.string().trim().min(1),
48
+ name: z.string().trim().min(1),
49
+ group: z.string().nullable().optional(),
50
+ width: z.number().positive(),
51
+ height: z.number().positive(),
52
+ background: z.string().optional(),
53
+ nodes: z.array(z.record(z.string(), z.unknown())),
54
+ })
55
+ .strict(),
56
+ processed: z.array(ProcessedSchema).optional(),
57
+ })
58
+ .strict();
59
+ const ProjectFileSchema = z
60
+ .object({
61
+ format: z.literal(PROJECT_FORMAT),
62
+ version: z.literal(EXCHANGE_VERSION),
63
+ exportedAt: z.string(),
64
+ designs: z.array(z.unknown()),
65
+ feedback: z.unknown(),
66
+ captures: z.record(z.string(), z.string().startsWith("data:image/png;base64,")),
67
+ })
68
+ .strict();
69
+ function zodIssues(error) {
70
+ return error.issues.map((issue) => `${issue.path.length > 0 ? issue.path.join(".") : "(root)"}: ${issue.message}`);
71
+ }
72
+ /**
73
+ * Le JSON à lire : le texte lui-même, ou l'unique bloc de code d'une réponse
74
+ * collée en entier. Plusieurs blocs sont refusés : on ne choisit pas à la
75
+ * place de l'utilisateur.
76
+ */
77
+ function jsonOf(text) {
78
+ const trimmed = text.trim();
79
+ if (trimmed.startsWith("{"))
80
+ return trimmed;
81
+ const blocks = [...trimmed.matchAll(/```[\w-]*[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*```/g)].map((match) => match[1]);
82
+ if (blocks.length > 1) {
83
+ throw new ExchangeError([`The text holds ${blocks.length} code blocks: paste a single page at a time.`]);
84
+ }
85
+ return blocks[0] ?? trimmed;
86
+ }
87
+ function parseJson(text) {
88
+ const json = jsonOf(text);
89
+ try {
90
+ return JSON.parse(json);
91
+ }
92
+ catch (err) {
93
+ throw new ExchangeError([`The text is not valid JSON: ${err.message}`]);
94
+ }
95
+ }
96
+ /** Le nom d'icône est vérifié ici : le message de `buildNode` renvoie aux outils MCP. */
97
+ function checkIconName(icon) {
98
+ if (typeof icon !== "string" || icon.trim() === "") {
99
+ throw new DesignError(`An icon node needs an "icon" field naming a Lucide icon, e.g. "shopping-cart".`);
100
+ }
101
+ if (hasIcon(icon.trim()))
102
+ return;
103
+ const suggestions = suggestIcons(icon.trim());
104
+ throw new DesignError(`Unknown icon "${icon}".` + (suggestions.length > 0 ? ` Closest matches: ${suggestions.join(", ")}.` : ""));
105
+ }
106
+ function checkNode(raw) {
107
+ const type = raw.type;
108
+ if (type === "image") {
109
+ throw new DesignError(`"image" nodes are not supported: draw a placeholder rect (with a label) where the picture goes.`);
110
+ }
111
+ if (typeof type !== "string" || !EXCHANGE_KINDS.includes(type)) {
112
+ throw new DesignError(`Unknown node type ${JSON.stringify(type)}. Expected one of: ${EXCHANGE_KINDS.join(", ")}.`);
113
+ }
114
+ if (type === "icon")
115
+ checkIconName(raw.icon);
116
+ const allowed = allowedFields(type);
117
+ const unknown = Object.keys(raw).filter((field) => !allowed.includes(field));
118
+ if (unknown.length > 0) {
119
+ throw new DesignError(`Field(s) [${unknown.join(", ")}] do not apply to a "${type}" node. Allowed: ${allowed.join(", ")}.`);
120
+ }
121
+ }
122
+ function buildNodes(rawNodes, issues) {
123
+ const ids = new Set();
124
+ const nodes = [];
125
+ rawNodes.forEach((raw, index) => {
126
+ const label = typeof raw.id === "string" ? `nodes[${index}] "${raw.id}"` : `nodes[${index}]`;
127
+ try {
128
+ checkNode(raw);
129
+ if (typeof raw.id !== "string" || raw.id.trim() === "")
130
+ throw new DesignError(`"id" is required.`);
131
+ const node = buildNode(raw, ids, index + 1);
132
+ ids.add(node.id);
133
+ nodes.push(node);
134
+ }
135
+ catch (err) {
136
+ issues.push(`${label}: ${err.message}`);
137
+ }
138
+ });
139
+ return nodes;
140
+ }
141
+ /**
142
+ * Lit une page renvoyée par l'assistant. Rien n'est appliqué ici : le résultat
143
+ * dit ce que l'import ferait, `applyPageImport` l'exécute.
144
+ */
145
+ export function parsePageReply(text, project) {
146
+ const parsed = PageFileSchema.safeParse(parseJson(text));
147
+ if (!parsed.success)
148
+ throw new ExchangeError(zodIssues(parsed.error));
149
+ const { page, processed = [] } = parsed.data;
150
+ const issues = [];
151
+ const nodes = buildNodes(page.nodes, issues);
152
+ const marked = processed.flatMap((entry) => {
153
+ const round = project.feedback.rounds.find((candidate) => candidate.remarks.some((remark) => remark.id === entry.remark));
154
+ const remark = round?.remarks.find((candidate) => candidate.id === entry.remark);
155
+ if (!round || !remark) {
156
+ issues.push(`processed: remark "${entry.remark}" is not part of any feedback round.`);
157
+ return [];
158
+ }
159
+ if (remark.designId !== page.id) {
160
+ issues.push(`processed: remark "${entry.remark}" is about page "${remark.designId}", not "${page.id}".`);
161
+ return [];
162
+ }
163
+ return [{ round: round.number, remarkId: remark.id, note: entry.note?.trim() || null }];
164
+ });
165
+ if (issues.length > 0)
166
+ throw new ExchangeError(issues);
167
+ const now = new Date().toISOString();
168
+ const replaces = project.designs.find((design) => design.id === page.id) ?? null;
169
+ const base = replaces ??
170
+ newDesign(page.id, { name: page.name, width: page.width, height: page.height, background: page.background }, now);
171
+ let design;
172
+ try {
173
+ design = mutatedDesign(base, "agent", (draft) => {
174
+ draft.name = page.name;
175
+ draft.width = page.width;
176
+ draft.height = page.height;
177
+ if (page.background !== undefined)
178
+ draft.background = page.background;
179
+ if (page.group !== undefined)
180
+ draft.group = normalizeDesignGroup(page.group);
181
+ draft.nodes = nodes;
182
+ });
183
+ }
184
+ catch (err) {
185
+ throw new ExchangeError([err.message]);
186
+ }
187
+ return { design: syncedDesign(design, now), replaces, processed: marked };
188
+ }
189
+ /** Enregistre une page lue par `parsePageReply` et note les remarques traitées. */
190
+ export function applyPageImport(project, plan) {
191
+ if (plan.replaces) {
192
+ project.designs = project.designs.map((design) => (design.id === plan.design.id ? plan.design : design));
193
+ }
194
+ else {
195
+ project.designs.push(plan.design);
196
+ }
197
+ for (const entry of plan.processed) {
198
+ setRemarksProcessed(project.feedback, {
199
+ round: entry.round,
200
+ remarkIds: [entry.remarkId],
201
+ processed: true,
202
+ by: "agent",
203
+ note: entry.note,
204
+ });
205
+ }
206
+ }
207
+ /** La page au format d'échange. La taille des groupes et des instances, dérivée, n'y figure pas. */
208
+ export function pageFileOf(design) {
209
+ return {
210
+ format: PAGE_FORMAT,
211
+ version: EXCHANGE_VERSION,
212
+ page: {
213
+ id: design.id,
214
+ name: design.name,
215
+ group: design.group,
216
+ width: design.width,
217
+ height: design.height,
218
+ background: design.background,
219
+ nodes: design.nodes.map((node) => {
220
+ if (!SIZE_DERIVED_KINDS.includes(node.type))
221
+ return node;
222
+ const { width: _width, height: _height, ...rest } = node;
223
+ return rest;
224
+ }),
225
+ },
226
+ processed: [],
227
+ };
228
+ }
229
+ /** JSON d'une page, un élément par ligne : lisible et bien plus court qu'une indentation complète. */
230
+ export function pageJson(file) {
231
+ const nodes = file.page.nodes.map((node) => ` ${JSON.stringify(node)}`).join(",\n");
232
+ const placeholder = "@@nodes@@";
233
+ return JSON.stringify({ ...file, page: { ...file.page, nodes: placeholder } }, null, 2).replace(JSON.stringify(placeholder), file.page.nodes.length > 0 ? `[\n${nodes}\n ]` : "[]");
234
+ }
235
+ // --- Projet ----------------------------------------------------------------
236
+ export function projectFileOf(project, exportedAt) {
237
+ return {
238
+ format: PROJECT_FORMAT,
239
+ version: EXCHANGE_VERSION,
240
+ exportedAt,
241
+ designs: project.designs,
242
+ feedback: project.feedback,
243
+ captures: project.captures,
244
+ };
245
+ }
246
+ /** Relit un export complet ; refusé en bloc à la moindre incohérence. */
247
+ export function parseProjectFile(text) {
248
+ const parsed = ProjectFileSchema.safeParse(parseJson(text));
249
+ if (!parsed.success)
250
+ throw new ExchangeError(zodIssues(parsed.error));
251
+ const issues = [];
252
+ const designs = [];
253
+ parsed.data.designs.forEach((raw, index) => {
254
+ try {
255
+ const { design } = parseDesign(raw, `designs[${index}]`);
256
+ if (designs.some((other) => other.id === design.id))
257
+ throw new DesignError(`duplicate page id "${design.id}".`);
258
+ if (design.nodes.some((node) => node.type === "image"))
259
+ throw new DesignError(`page "${design.id}" contains image nodes.`);
260
+ designs.push({ ...design, nodes: normalizeHierarchy(design.nodes) });
261
+ }
262
+ catch (err) {
263
+ issues.push(`designs[${index}]: ${err.message}`);
264
+ }
265
+ });
266
+ let feedback = null;
267
+ try {
268
+ feedback = parseFeedbackFile(parsed.data.feedback, "feedback");
269
+ }
270
+ catch (err) {
271
+ issues.push(err.message);
272
+ }
273
+ const captures = parsed.data.captures;
274
+ const referenced = feedback
275
+ ? [...feedback.remarks.map((remark) => remark.capture), ...feedback.rounds.flatMap((round) => round.remarks.map((remark) => remark.capture))]
276
+ : [];
277
+ for (const name of new Set(referenced)) {
278
+ if (name !== null && captures[name] === undefined)
279
+ issues.push(`captures: "${name}" is referenced by a remark but missing.`);
280
+ }
281
+ if (issues.length > 0 || feedback === null)
282
+ throw new ExchangeError(issues);
283
+ return { designs, feedback, captures };
284
+ }
285
+ // --- Prompt ----------------------------------------------------------------
286
+ /** Champs décrits à l'assistant, avec les nuances propres à cet échange. */
287
+ const PROMPT_FIELD_DOCS = {
288
+ ...NODE_FIELD_DOCS,
289
+ id: "Unique id within the page, short and readable (\"hero-title\", \"nav\"). Required.",
290
+ icon: "Name of a Lucide icon, e.g. \"shopping-cart\", \"circle-check\", \"user\". Required on an icon node. NEVER approximate " +
291
+ "an icon out of rects, ellipses and lines. The box is square: the drawing is scaled to fit it.",
292
+ componentId: "Instance nodes only: id of the GROUP this instance mirrors. The group's content is resolved at render time, so " +
293
+ "editing the group updates every instance. When the same block appears several times (cards, list rows, menu items), " +
294
+ "build it once as a group and add instances of it, placed with x and y.",
295
+ };
296
+ function fieldsPerType() {
297
+ return EXCHANGE_KINDS.map((kind) => {
298
+ const style = NODE_STYLE_FIELDS[kind];
299
+ const derived = SIZE_DERIVED_KINDS.includes(kind) ? " — width/height are derived from the content: omit them" : "";
300
+ return `- ${kind}: ${style.length > 0 ? style.join(", ") : "(no style field)"}${derived}`;
301
+ }).join("\n");
302
+ }
303
+ function fieldReference() {
304
+ return Object.entries(PROMPT_FIELD_DOCS)
305
+ .filter(([field]) => field !== "image" && field !== "fit")
306
+ .map(([field, doc]) => `- ${field}: ${doc}`)
307
+ .join("\n");
308
+ }
309
+ function defaultsReference() {
310
+ return EXCHANGE_KINDS.filter((kind) => !SIZE_DERIVED_KINDS.includes(kind))
311
+ .map((kind) => `- ${kind}: ${JSON.stringify(NODE_DEFAULTS[kind])}`)
312
+ .join("\n");
313
+ }
314
+ const EXAMPLE_PAGE = {
315
+ format: PAGE_FORMAT,
316
+ version: EXCHANGE_VERSION,
317
+ page: {
318
+ id: "home",
319
+ name: "Home",
320
+ group: null,
321
+ width: 1440,
322
+ height: 900,
323
+ background: "#ffffff",
324
+ nodes: [
325
+ { id: "header", type: "group", name: "Header", parentId: null, x: 0, y: 0 },
326
+ { id: "header-bg", type: "rect", name: "Background", parentId: "header", x: 0, y: 0, width: 1440, height: 72, fill: "#0f172a", strokeWidth: 0, radius: 0 },
327
+ { id: "logo", type: "text", name: "Logo", parentId: "header", x: 40, y: 0, width: 200, height: 72, text: "Acme", color: "#ffffff", fontSize: 22, fontWeight: 700 },
328
+ { id: "cart", type: "icon", name: "Cart", parentId: "header", x: 1376, y: 24, width: 24, height: 24, icon: "shopping-cart", stroke: "#ffffff" },
329
+ { id: "card", type: "group", name: "Card", parentId: null, x: 40, y: 120 },
330
+ { id: "card-bg", type: "rect", name: "Card background", parentId: "card", x: 0, y: 0, width: 320, height: 200, fill: "#f1f5f9", strokeWidth: 0, radius: 12 },
331
+ { id: "card-title", type: "text", name: "Card title", parentId: "card", x: 24, y: 24, width: 272, text: "Fast delivery\nin 24 hours", fontSize: 20, fontWeight: 600 },
332
+ { id: "card-2", type: "instance", name: "Card 2", parentId: null, x: 400, y: 120, componentId: "card" },
333
+ ],
334
+ },
335
+ processed: [],
336
+ };
337
+ /** Ce que l'utilisateur colle en tête de conversation, suivi de sa demande. */
338
+ export function creationPrompt() {
339
+ return `You design user-interface mockups for mcp-design, a minimal vector canvas. You never produce images: you describe each page as a JSON document that I import into the editor. There I adjust it by hand, pin review remarks on it, and send you a review recap to apply.
340
+
341
+ ## Reply format
342
+
343
+ Send ONE page per reply, as a single JSON document in a \`\`\`json code block (nothing else inside the block):
344
+
345
+ { "format": "${PAGE_FORMAT}", "version": ${EXCHANGE_VERSION}, "page": { "id": "...", "name": "...", "group": null, "width": 1440, "height": 900, "background": "#ffffff", "nodes": [ ... ] }, "processed": [] }
346
+
347
+ - page.id: stable kebab-case identifier ("home", "checkout-step-2"). Sending a page with an existing id REPLACES that page; a new id creates a page.
348
+ - page.name: name shown in the page list.
349
+ - page.group: optional folder path such as "Mobile/Onboarding", or null.
350
+ - page.width / page.height: artboard size in pixels (1440x900 desktop, 375x812 mobile).
351
+ - page.background: CSS colour of the artboard.
352
+ - page.nodes: every element of the page (see below).
353
+ - processed: when you apply a review recap, one entry per remark of THIS page you addressed: { "remark": "rm_xxxxxxxx", "note": "what you changed" }. Leave it empty otherwise.
354
+
355
+ When several pages are needed, first list them (id and name) in plain text and send the first one; I will say "next" to get each following page.
356
+
357
+ ## Nodes
358
+
359
+ All lengths are pixels, angles degrees, opacity 0 to 1. The origin is the top-left corner, y goes down. Coordinates are relative to the parent group (to the artboard at the root).
360
+
361
+ Every node has: id, type, name, parentId, x, y, width, height, rotation, opacity. Each type also accepts:
362
+ ${fieldsPerType()}
363
+
364
+ Any other field is rejected. There are no image nodes: draw a light rect with a short label where a picture would go.
365
+
366
+ Rules:
367
+ - nodes is a flat list; nesting is expressed with parentId, which must name a group. List order is the stacking order among siblings: later nodes are drawn on top.
368
+ - Structure the page: a handful of top-level section groups, blocks inside them, elements inside blocks.
369
+ - Text never wraps: break lines with explicit \\n. Omit height on free text; to centre a label in a button, give the text the button's y and height.
370
+ - Use icon nodes for every pictogram. Common names: ${CORE_ICONS.join(", ")}. Any other Lucide icon name works too; an unknown name is rejected with close suggestions.
371
+ - Fields you omit take these defaults:
372
+ ${defaultsReference()}
373
+ rotation 0, opacity 1, parentId null.
374
+
375
+ ## Field reference
376
+
377
+ ${fieldReference()}
378
+
379
+ ## Example
380
+
381
+ \`\`\`json
382
+ ${pageJson(EXAMPLE_PAGE)}
383
+ \`\`\`
384
+
385
+ ## My request
386
+
387
+ `;
388
+ }
389
+ // --- Récap -----------------------------------------------------------------
390
+ /** Ce qui, pour l'assistant, exprime les coordonnées des remarques. */
391
+ export const COORDINATES_SOURCE = "the page JSON";
392
+ /** Texte du récap d'un retour, à transmettre à l'assistant avec les fichiers joints. */
393
+ export function recapText(round, designs, files) {
394
+ const pageIds = [...new Set(round.remarks.map((remark) => remark.designId))];
395
+ const pages = pageIds.map((id) => designs.find((design) => design.id === id) ?? null);
396
+ const first = pages.find((page) => page !== null);
397
+ const attachments = [
398
+ ...pageIds.flatMap((id) => {
399
+ const file = files.pages.get(id);
400
+ const name = round.remarks.find((remark) => remark.designId === id)?.designName ?? id;
401
+ return file ? [`- ${file}: the whole page "${name}" as it renders now, remarks outlined and numbered.`] : [];
402
+ }),
403
+ ...files.captures.map((file, index) => `- ${file}: image ${index + 1}, the capture cited by the remark that names it.`),
404
+ ];
405
+ const current = pages
406
+ .map((page, index) => page === null
407
+ ? `### Page ${pageIds[index]}\n\nThis page has been deleted since the review: skip its remarks.`
408
+ : `### Page "${page.name}" (${page.id})\n\n\`\`\`json\n${pageJson(pageFileOf(page))}\n\`\`\``)
409
+ .join("\n\n");
410
+ return `# Review recap — "${round.title}" (round ${round.number})
411
+
412
+ Apply every remark below to the pages, then send back the updated pages, ONE PAGE PER REPLY, each as a single JSON document in the "${PAGE_FORMAT}" format. Start with page "${first?.id ?? pageIds[0]}"; I will say "next" for the following one. Keep the ids of the elements you keep, and list in "processed" the remarks of that page you addressed, with a short note.
413
+
414
+ ## Attached images
415
+
416
+ ${attachments.length > 0 ? attachments.join("\n") : "None."}
417
+
418
+ ## Remarks
419
+
420
+ ${formatFeedbackRound(round, COORDINATES_SOURCE)}
421
+
422
+ ## Current pages
423
+
424
+ ${current}
425
+
426
+ ## Format reminder
427
+
428
+ ${creationPrompt().split("## My request")[0].trim()}
429
+ `;
430
+ }
@@ -0,0 +1,64 @@
1
+ import { type FeedbackRound, type Remark, type RemarkTarget } from "../../shared/feedback.js";
2
+ import { type FeedbackFile } from "../../shared/schemas/feedback.schema.js";
3
+ import type { Design } from "../../shared/schemas/design.schema.js";
4
+ import type { Box } from "../../shared/tree.js";
5
+ /**
6
+ * Règles des remarques et des retours, indépendantes de leur stockage. Chaque
7
+ * opération modifie un brouillon du fichier des remarques ; l'appelant le
8
+ * valide puis l'enregistre — dans `remarks.json` pour le serveur, dans le
9
+ * projet en mémoire pour la version autonome de la GUI.
10
+ */
11
+ /** Marge autour d'un élément capturé, comme `screenshot_design`. */
12
+ export declare const NODE_CAPTURE_PADDING = 16;
13
+ /** Cible demandée par la GUI, complétée d'après le design. */
14
+ export type RemarkTargetInput = {
15
+ kind: "page";
16
+ } | {
17
+ kind: "node";
18
+ nodeId: string;
19
+ } | {
20
+ kind: "area";
21
+ box: Box;
22
+ elementIds: string[];
23
+ };
24
+ export interface SetProcessedInput {
25
+ round: number;
26
+ /** Remarques visées ; toutes celles du retour quand omis. */
27
+ remarkIds?: string[];
28
+ processed: boolean;
29
+ by: "agent" | "user";
30
+ note?: string | null;
31
+ }
32
+ export declare function emptyFeedbackFile(): FeedbackFile;
33
+ /** Lit un fichier des remarques brut, après mise à niveau de son format. */
34
+ export declare function parseFeedbackFile(raw: unknown, label: string): FeedbackFile;
35
+ /** Valide un brouillon modifié avant son enregistrement. */
36
+ export declare function validatedFeedbackFile(draft: FeedbackFile): FeedbackFile;
37
+ export declare function requireRemarkText(text: unknown): string;
38
+ /** Complète la cible d'après l'état courant du design. */
39
+ export declare function resolveRemarkTarget(design: Design, input: RemarkTargetInput): RemarkTarget;
40
+ /** Ajoute un brouillon ; `capture` est le nom du fichier de capture, ou null. */
41
+ export declare function addRemark(draft: FeedbackFile, input: {
42
+ id: string;
43
+ designId: string;
44
+ target: RemarkTarget;
45
+ text: string;
46
+ capture: string | null;
47
+ }): Remark;
48
+ export declare function updateRemarkText(draft: FeedbackFile, id: string, text: string): Remark;
49
+ /** Retire une remarque de la liste et la renvoie. */
50
+ export declare function deleteRemark(draft: FeedbackFile, id: string): Remark;
51
+ /** Retire les remarques d'un design supprimé et les renvoie. */
52
+ export declare function deleteDesignRemarks(draft: FeedbackFile, designId: string): Remark[];
53
+ /**
54
+ * Fige les brouillons choisis dans un nouveau retour nommé, et les marque
55
+ * transmis. `names` donne le nom de chaque design existant.
56
+ */
57
+ export declare function submitRound(draft: FeedbackFile, remarkIds: string[], title: string, names: Map<string, string>): FeedbackRound;
58
+ export declare function renameRound(draft: FeedbackFile, number: number, title: string): FeedbackRound;
59
+ /**
60
+ * Déclare des remarques d'un retour traitées, ou les rouvre. Une remarque
61
+ * déjà traitée reste inchangée ; une nouvelle note remplace l'ancienne et
62
+ * son auteur, en gardant la date d'origine.
63
+ */
64
+ export declare function setRemarksProcessed(draft: FeedbackFile, input: SetProcessedInput): FeedbackRound;