@formicoidea/labre-framework-wardley 0.31.0 → 0.33.0

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 (58) hide show
  1. package/dist/actions.d.ts +63 -20
  2. package/dist/actions.js +190 -57
  3. package/dist/audit-criteria.d.ts +31 -0
  4. package/dist/audit-criteria.js +90 -0
  5. package/dist/background.d.ts +14 -0
  6. package/dist/background.js +338 -0
  7. package/dist/commands.d.ts +9 -0
  8. package/dist/commands.js +352 -0
  9. package/dist/consts.d.ts +10 -4
  10. package/dist/consts.js +10 -9
  11. package/dist/descriptor.d.ts +8 -3
  12. package/dist/descriptor.js +6 -3
  13. package/dist/element-renderer.d.ts +8 -5
  14. package/dist/element-renderer.js +12 -147
  15. package/dist/element-view.d.ts +8 -4
  16. package/dist/element-view.js +30 -22
  17. package/dist/export.d.ts +211 -0
  18. package/dist/export.js +655 -0
  19. package/dist/gradient.d.ts +6 -11
  20. package/dist/gradient.js +59 -48
  21. package/dist/import.d.ts +116 -0
  22. package/dist/import.js +905 -0
  23. package/dist/index.d.ts +27 -1
  24. package/dist/index.js +31 -1
  25. package/dist/interchange.d.ts +80 -0
  26. package/dist/interchange.js +138 -0
  27. package/dist/legend.js +8 -0
  28. package/dist/natures.d.ts +50 -0
  29. package/dist/natures.js +93 -0
  30. package/dist/node/node-renderer.js +1 -1
  31. package/dist/nudges.d.ts +41 -0
  32. package/dist/nudges.js +69 -0
  33. package/dist/profiles.d.ts +2 -0
  34. package/dist/profiles.js +87 -0
  35. package/dist/reading.d.ts +3 -0
  36. package/dist/reading.js +129 -0
  37. package/dist/roles.d.ts +50 -0
  38. package/dist/roles.js +132 -0
  39. package/dist/rules.d.ts +2 -0
  40. package/dist/rules.js +286 -0
  41. package/dist/templates/index.js +66 -10
  42. package/dist/templates/maps.js +146 -22
  43. package/dist/toolbar/config.js +3 -1
  44. package/dist/toolbar/icons.d.ts +20 -0
  45. package/dist/toolbar/icons.js +34 -0
  46. package/dist/toolbar/senior-tool.js +1 -0
  47. package/dist/toolbar/wardley-menu.d.ts +8 -15
  48. package/dist/toolbar/wardley-menu.js +8 -136
  49. package/dist/toolbar/wardley-senior-button.js +12 -6
  50. package/dist/translations.d.ts +16 -0
  51. package/dist/translations.js +24 -0
  52. package/dist/view.d.ts +17 -0
  53. package/dist/view.js +127 -13
  54. package/package.json +2 -2
  55. package/dist/label-layout.d.ts +0 -20
  56. package/dist/label-layout.js +0 -72
  57. package/dist/shortcuts.d.ts +0 -2
  58. package/dist/shortcuts.js +0 -37
package/dist/export.js ADDED
@@ -0,0 +1,655 @@
1
+ import { backgroundPlot } from '@formicoidea/labre-core/blocks/surface';
2
+ import { ConnectorElementModel, TextElementModel, WardleyBackgroundElementModel, WardleyNodeElementModel, } from '@formicoidea/labre-core/model';
3
+ import { WARDLEY_BACKGROUND } from './background.js';
4
+ import { LABEL_FONT_SIZE, LABEL_GAP } from './node/consts.js';
5
+ import { WARDLEY_ROLE } from './roles.js';
6
+ /**
7
+ * A Wardley map as an OnlineWardleyMaps (OWM) DSL document — models in, text
8
+ * out (`docs/adr/0012`, P3).
9
+ *
10
+ * This is the function ADR 0012 records as owed: the Wardley serializer that
11
+ * exists today in **labre-mcp**, outside this repo, and is the ADR's one named
12
+ * violation of P3. It lands here so that both consumers — the editor command
13
+ * and the MCP tool — call one implementation, tested once, and so that the
14
+ * reader next door (`import.ts`) has a writer it agrees with about every
15
+ * coordinate, name and carried line.
16
+ *
17
+ * ## Pure, like its BPMN sibling
18
+ *
19
+ * Element models in, a string out. No `BlockStdScope`, no surface, no DOM, no
20
+ * clock, no randomness. `interchange.ts` is the thin adapter that names the
21
+ * file; `actions.ts` is the thinner one that downloads it.
22
+ *
23
+ * ## The plot IS the coordinate
24
+ *
25
+ * A Wardley node carries **no** `visibility` and **no** `evolution` prop — its
26
+ * position on the map's plot is the whole of what the map says about it. So the
27
+ * writer inverts the projection the reader applied: a node's centre, measured
28
+ * against the plot of the background it sits on, is the `[visibility,
29
+ * evolution]` pair OWM spells. Both numbers are written to exactly **two
30
+ * decimals**, and that stability is load-bearing rather than cosmetic: the
31
+ * fixed point `export(import(export(board)))` is byte-identical only because a
32
+ * value that survives one rounding survives every one after it. The reader
33
+ * tolerates any precision a foreign file happens to use.
34
+ *
35
+ * ## A name is a separate element, so it has to be found
36
+ *
37
+ * On this canvas the name of an artefact is a free text element beside it, not
38
+ * a prop on it (`roles.ts`, `WARDLEY_ROLE.label`). The writer therefore matches
39
+ * each label to the node it names by comparing where the label IS with where a
40
+ * label for that node WOULD be — see {@link matchLabels}, which is the one
41
+ * heuristic in this module and is documented as one.
42
+ *
43
+ * ## v1 reads one map
44
+ *
45
+ * An OWM document is one map. A surface holding several Wardley backgrounds is
46
+ * serialized against the FIRST in document order, and the export warns; the
47
+ * other maps' artefacts are written against that first plot, which is the
48
+ * honest behaviour (nothing is dropped) and is named in the warning so nobody
49
+ * discovers it from a file.
50
+ */
51
+ /* ── The format's own vocabulary ──────────────────────────────────────── */
52
+ /**
53
+ * The format id, and therefore THE KEY foreign matter rides under on an element
54
+ * (ADR 0012, D2) — `interchange.owm`. Declared here and re-exported by
55
+ * `import.ts`, so a reader filing a fragment and a writer looking one up cannot
56
+ * disagree about where it went.
57
+ */
58
+ export const WARDLEY_OWM_FORMAT_ID = 'owm';
59
+ /**
60
+ * OWM's scope vocabulary — where a carried line came off (D2).
61
+ *
62
+ * The DSL is a flat list of statements with no nesting and no ids, so it needs
63
+ * exactly two `@`-prefixed role keys and never an element id:
64
+ *
65
+ * - `@document` — the whole file: the lines this reader has no artefact for,
66
+ * and the `title` it consumed. They ride on the map's background element,
67
+ * which is D6's stated asymmetry (delete the map and the residue goes with
68
+ * it) and is where `profileId` already lives for the same reason.
69
+ * - `@self` — the line an element WAS. Used for the verbatim tail of a mapped
70
+ * line, i.e. everything the writer would otherwise drop: `label [x, y]`,
71
+ * `(build)`, `inertia`, a trailing comment.
72
+ */
73
+ export const OWM_SCOPE = {
74
+ document: '@document',
75
+ self: '@self',
76
+ };
77
+ /** Where a mapped line's un-modelled tail is filed, under `attrs['@self']`. */
78
+ export const OWM_TAIL_ATTR = 'tail';
79
+ /** Where the file's own `title` is filed, under `attrs['@document']`. */
80
+ export const OWM_TITLE_ATTR = 'title';
81
+ /**
82
+ * Keywords a line may open on, none of which can be a bare component name.
83
+ *
84
+ * Two different parsers care. `BaseStrategyRunner` claims a line for a keyword
85
+ * when the TRIMMED line opens on `"<keyword> "`, and `LinksExtractionStrategy`
86
+ * refuses to read a line as a link when it opens on any of these — so a
87
+ * component genuinely called `style` would silently stop being linkable. The
88
+ * writer quotes such a name rather than betting nobody ever picks one.
89
+ */
90
+ export const OWM_KEYWORDS = new Set([
91
+ 'accelerator',
92
+ 'anchor',
93
+ 'annotation',
94
+ 'annotations',
95
+ 'build',
96
+ 'buy',
97
+ 'component',
98
+ 'deaccelerator',
99
+ 'ecosystem',
100
+ 'evolution',
101
+ 'evolve',
102
+ 'market',
103
+ 'note',
104
+ 'outsource',
105
+ 'pioneers',
106
+ 'pipeline',
107
+ 'presentation',
108
+ 'settlers',
109
+ 'size',
110
+ 'style',
111
+ 'submap',
112
+ 'title',
113
+ 'townplanners',
114
+ 'url',
115
+ ]);
116
+ /** A name that needs no quoting: one word of the characters OWM reads bare. */
117
+ const BARE_NAME = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/;
118
+ /**
119
+ * A name as the DSL spells it — bare when it can be, quoted and escaped
120
+ * otherwise.
121
+ *
122
+ * The escaping mirrors OWM's own `escapeComponentNameForMapText` /
123
+ * `unescapeComponentNameFromMapText` pair character for character, which is
124
+ * what makes `"Vente retail thés, accessoires, coffrets"` come back with its
125
+ * commas and its accents intact.
126
+ */
127
+ export function owmName(raw) {
128
+ return BARE_NAME.test(raw) && !opensOnKeyword(raw) ? raw : owmQuote(raw);
129
+ }
130
+ /**
131
+ * Whether a bare name would be mistaken for a statement of another kind.
132
+ *
133
+ * A PREFIX test, not an equality one, because that is what the reference reader
134
+ * does: `LinksExtractionStrategy.canProcessLine` refuses a line whose trimmed
135
+ * text merely BEGINS with one of these (`element.trim().indexOf(keyword) === 0`),
136
+ * and `BaseStrategyRunner` claims one the same way. So `urlShortener->Cache` is
137
+ * not a link there, and a component genuinely called `urlShortener` silently
138
+ * stops being linkable unless the writer quotes it.
139
+ */
140
+ function opensOnKeyword(raw) {
141
+ for (const keyword of OWM_KEYWORDS) {
142
+ if (raw.startsWith(keyword))
143
+ return true;
144
+ }
145
+ return false;
146
+ }
147
+ /** A name in quotes, escaped as OWM's `escapeComponentNameForMapText` does. */
148
+ export function owmQuote(raw) {
149
+ const escaped = raw
150
+ .replaceAll('\\', '\\\\')
151
+ .replaceAll('"', '\\"')
152
+ .replaceAll('\n', '\\n')
153
+ .replaceAll('\r', '\\r')
154
+ .replaceAll('\t', '\\t')
155
+ .replaceAll('[', '\\[')
156
+ .replaceAll(']', '\\]');
157
+ return `"${escaped}"`;
158
+ }
159
+ /**
160
+ * The `X -> Y` half of an `evolve` line, with BOTH names quoted whenever
161
+ * either needs it.
162
+ *
163
+ * Not cosmetic, and not symmetry for its own sake — it is the only spelling the
164
+ * reference reader understands. `setNameWithMaturity` has two branches, and
165
+ * only the QUOTED one (`nameSection.startsWith('"')`) ever unquotes an override:
166
+ * the legacy branch splits on `->` and takes `parts[1].trim()` verbatim, so
167
+ * `evolve Kettle -> "Electric kettle" 0.75` gives an evolved component whose
168
+ * name still has the quote characters in it, and onlinewardleymaps draws them.
169
+ * Quoting the source name is what puts the reader in the branch that strips
170
+ * them off the target.
171
+ */
172
+ function evolvePair(was, becomes) {
173
+ const quoted = `${owmName(was)} -> ${owmName(becomes)}`;
174
+ if (!quoted.includes('"'))
175
+ return quoted;
176
+ return `${owmQuote(was)} -> ${owmQuote(becomes)}`;
177
+ }
178
+ /**
179
+ * A coordinate, to exactly two decimals — the whole of the fixed point's
180
+ * arithmetic.
181
+ *
182
+ * `-0` is written as `0.00`, because `(-0).toFixed(2)` is `"-0.00"` and a
183
+ * node dropped one pixel above the plot's top edge would otherwise produce a
184
+ * file whose bytes depend on which side of zero a float landed on. A
185
+ * non-finite value (an element with no geometry) is written as `0.00` rather
186
+ * than as `NaN`, which no parser reads.
187
+ */
188
+ export function owmNumber(value) {
189
+ if (!Number.isFinite(value))
190
+ return '0.00';
191
+ const fixed = value.toFixed(2);
192
+ return fixed === '-0.00' ? '0.00' : fixed;
193
+ }
194
+ /** The reference map an import lays out on, and an export falls back to. */
195
+ export const OWM_DEFAULT_MAP_WIDTH = WARDLEY_BACKGROUND.geometry.width;
196
+ export const OWM_DEFAULT_MAP_HEIGHT = WARDLEY_BACKGROUND.geometry.height;
197
+ /**
198
+ * The plot of a background element, in absolute units — the declaration's
199
+ * margins, never a hand-written inset.
200
+ *
201
+ * `templates/maps.ts` learned this the hard way: a plot copied as four numbers
202
+ * drifted from the drawn one, and a rule measuring against the declaration then
203
+ * judged nodes laid out against the copy. Both directions of this format read
204
+ * the same function for the same reason.
205
+ */
206
+ export function owmPlotOf(bound) {
207
+ const plot = backgroundPlot(WARDLEY_BACKGROUND, bound.w, bound.h);
208
+ return {
209
+ x0: bound.x + plot.x0,
210
+ y0: bound.y + plot.y0,
211
+ width: plot.width,
212
+ height: plot.height,
213
+ };
214
+ }
215
+ /** The default plot: a reference map at the origin. */
216
+ export function owmDefaultPlot() {
217
+ return owmPlotOf({
218
+ x: 0,
219
+ y: 0,
220
+ w: OWM_DEFAULT_MAP_WIDTH,
221
+ h: OWM_DEFAULT_MAP_HEIGHT,
222
+ });
223
+ }
224
+ /**
225
+ * `[visibility, evolution]` → a surface point.
226
+ *
227
+ * Mind the inversion, which is the one thing about these axes that is easy to
228
+ * get backwards and impossible to see in a test that only round-trips: OWM's
229
+ * visibility `1.0` is the TOP of the value chain, and a canvas' y grows
230
+ * downwards.
231
+ */
232
+ export function owmPointOf(plot, visibility, evolution) {
233
+ return [
234
+ plot.x0 + evolution * plot.width,
235
+ plot.y0 + (1 - visibility) * plot.height,
236
+ ];
237
+ }
238
+ /** A surface point → `[visibility, evolution]`. The exact inverse. */
239
+ export function owmCoordsOf(plot, x, y) {
240
+ return {
241
+ visibility: 1 - (y - plot.y0) / plot.height,
242
+ evolution: (x - plot.x0) / plot.width,
243
+ };
244
+ }
245
+ export function wardleyBoardFrom(elements) {
246
+ const maps = [];
247
+ const nodes = [];
248
+ const labels = [];
249
+ const notes = [];
250
+ const connectors = [];
251
+ for (const element of elements) {
252
+ if (element instanceof WardleyBackgroundElementModel)
253
+ maps.push(element);
254
+ else if (element instanceof WardleyNodeElementModel)
255
+ nodes.push(element);
256
+ else if (element instanceof ConnectorElementModel)
257
+ connectors.push(element);
258
+ else if (element instanceof TextElementModel) {
259
+ if (element.role === WARDLEY_ROLE.label)
260
+ labels.push(element);
261
+ else if (element.role === undefined)
262
+ notes.push(element);
263
+ }
264
+ }
265
+ return { maps, nodes, labels, notes, connectors };
266
+ }
267
+ /**
268
+ * A name a file system will accept, minus the extension. BPMN's sanitizer,
269
+ * verbatim in behaviour and different only in its fallback — `map`, because
270
+ * that is what an OWM document is.
271
+ */
272
+ export function wardleySafeFilename(raw) {
273
+ const safe = (raw ?? '')
274
+ .trim()
275
+ .replaceAll(/[\\/:*?"<>|]/g, '-')
276
+ .replaceAll(/\s+/g, ' ')
277
+ .trim()
278
+ .slice(0, 120)
279
+ .replace(/[. ]+$/, '');
280
+ return safe || 'map';
281
+ }
282
+ /* ── Matching a name to the artefact it names ─────────────────────────── */
283
+ /** How far a label may sit from where this node's label belongs, in units. */
284
+ const LABEL_MATCH_TOLERANCE = 24;
285
+ /** The label box an import writes, and the width a prediction assumes. */
286
+ export const OWM_LABEL_WIDTH = 200;
287
+ export const OWM_LABEL_HEIGHT = LABEL_FONT_SIZE + 8;
288
+ const boxOf = (element) => {
289
+ const { x, y, w, h } = element.elementBound;
290
+ return { x, y, w, h };
291
+ };
292
+ /**
293
+ * Where a label for this node would be written, under each of the three
294
+ * conventions this library actually uses.
295
+ *
296
+ * `actions.ts` writes a name to the RIGHT of the circle it names;
297
+ * `templates/maps.ts` writes some to the left and some centred above; this
298
+ * reader writes to the right, and above for a pipeline. All four are the same
299
+ * two numbers — the node's own half-size plus {@link LABEL_GAP} — so the
300
+ * prediction is computed from the node rather than tabulated, and a node of any
301
+ * size (a market is 30 units across, an ecosystem 40) is predicted correctly
302
+ * without a table to keep in step.
303
+ */
304
+ function labelAnchors(node, label) {
305
+ const cx = node.x + node.w / 2;
306
+ const cy = node.y + node.h / 2;
307
+ const rx = node.w / 2;
308
+ const ry = node.h / 2;
309
+ return [
310
+ // To the right, vertically centred — the toolbox's own gesture.
311
+ [cx + rx + LABEL_GAP, cy - label.h / 2],
312
+ // To the left, vertically centred.
313
+ [cx - rx - LABEL_GAP - label.w, cy - label.h / 2],
314
+ // Centred above.
315
+ [cx - label.w / 2, cy - ry - LABEL_GAP - label.h],
316
+ ];
317
+ }
318
+ /**
319
+ * Which label names which node — the one heuristic in this module.
320
+ *
321
+ * A name is a separate element on this canvas, so "which node is this the name
322
+ * of" is a question the document does not answer directly and the writer has to
323
+ * ask of the geometry. The naive answer — nearest node to the label box — is
324
+ * WRONG on a real map and provably so: two components 60 units apart
325
+ * horizontally and 17 apart vertically (which is nothing on a 1530-wide plot,
326
+ * and is exactly what the tea-shop corpus holds) put the lower one's centre
327
+ * INSIDE the upper one's label box, so it wins by a distance of half a unit and
328
+ * steals the name.
329
+ *
330
+ * What is asked instead is: how far is this label from where a label for this
331
+ * node WOULD have been written? A wrong node's answer is a whole node spacing;
332
+ * the right node's is the few units between one convention and another. Pairs
333
+ * within {@link LABEL_MATCH_TOLERANCE} are then assigned greedily, closest
334
+ * first, ties broken by document order — so the assignment is a function of the
335
+ * document and not of the iteration order.
336
+ *
337
+ * A pipeline HANDLE never claims a label: it sits on the body's top edge, right
338
+ * under the name the body owns, and would win every pipeline's name from it.
339
+ */
340
+ function matchLabels(nodes, labels) {
341
+ const pairs = [];
342
+ nodes.forEach((node, nodeIndex) => {
343
+ // A handle sits on the pipeline body's top edge, directly under the name
344
+ // the BODY owns, and would win it from the body every time. A roleless node
345
+ // is a glyph's own wiring (a market's three inner dots) and names nothing.
346
+ if (node.kind === 'handle' || node.role === undefined)
347
+ return;
348
+ const nodeBox = boxOf(node);
349
+ labels.forEach((label, labelIndex) => {
350
+ const labelBox = boxOf(label);
351
+ const distance = Math.min(...labelAnchors(nodeBox, labelBox).map(([x, y]) => Math.hypot(labelBox.x - x, labelBox.y - y)));
352
+ if (distance <= LABEL_MATCH_TOLERANCE) {
353
+ pairs.push({ distance, node: nodeIndex, label: labelIndex });
354
+ }
355
+ });
356
+ });
357
+ pairs.sort((a, b) => a.distance - b.distance || a.node - b.node || a.label - b.label);
358
+ const byNode = new Map();
359
+ const takenLabels = new Set();
360
+ const takenNodes = new Set();
361
+ for (const pair of pairs) {
362
+ if (takenNodes.has(pair.node) || takenLabels.has(pair.label))
363
+ continue;
364
+ takenNodes.add(pair.node);
365
+ takenLabels.add(pair.label);
366
+ byNode.set(nodes[pair.node].id, textOf(labels[pair.label]));
367
+ }
368
+ return byNode;
369
+ }
370
+ /** A text element's string, whether it is a `Y.Text` or a test's plain one. */
371
+ export function textOf(element) {
372
+ const text = element.text;
373
+ return text === undefined || text === null ? '' : String(text);
374
+ }
375
+ /* ── What a node is, in OWM's vocabulary ──────────────────────────────── */
376
+ /**
377
+ * The keyword each drawn kind is written under.
378
+ *
379
+ * `method` is the one that is not a mapping: OWM has no `method` ELEMENT — a
380
+ * method is the `(build)` / `(buy)` / `(outsource)` decorator on a component
381
+ * line — so a Labre method node is written as a component and the export warns
382
+ * that the method itself could not be said. `pipeline` and `handle` are absent
383
+ * because a pipeline is written from its BODY, in its own section.
384
+ */
385
+ const OWM_KEYWORD_OF_KIND = {
386
+ component: 'component',
387
+ anchor: 'anchor',
388
+ market: 'market',
389
+ ecosystem: 'ecosystem',
390
+ method: 'component',
391
+ };
392
+ /** What one element carried from the file it came out of, if anything. */
393
+ function carriedOf(element) {
394
+ return element.interchange?.[WARDLEY_OWM_FORMAT_ID];
395
+ }
396
+ /** The verbatim tail of the line this element was, or the empty string. */
397
+ function tailOf(element) {
398
+ return carriedOf(element)?.attrs?.[OWM_SCOPE.self]?.[OWM_TAIL_ATTR] ?? '';
399
+ }
400
+ /**
401
+ * The board as an OWM document, plus what the format could not say.
402
+ *
403
+ * Sections in a fixed order — title, nodes, pipelines, notes, evolutions,
404
+ * links, carried lines — and DOCUMENT order inside each. That pairing is what
405
+ * makes the fixed point hold: the reader creates elements in the order it meets
406
+ * them, so a file's sections come back as a document whose order re-sections
407
+ * identically.
408
+ */
409
+ export function exportWardleyOwmWithWarnings(board, options = {}) {
410
+ const warnings = [];
411
+ const map = board.maps[0];
412
+ if (board.maps.length > 1) {
413
+ warnings.push(`This board holds ${board.maps.length} Wardley maps and an OWM file holds one. Everything was measured against the first map in the document; the others' coordinates are read against it too.`);
414
+ }
415
+ if (!map) {
416
+ warnings.push('No Wardley map background was found, so there is no plot to measure against. Coordinates were read against a default 1600 × 900 map at the origin.');
417
+ }
418
+ const plot = map ? owmPlotOf(boxOf(map)) : owmDefaultPlot();
419
+ const carried = map ? carriedOf(map) : undefined;
420
+ const titleAttr = carried?.attrs?.[OWM_SCOPE.document]?.[OWM_TITLE_ATTR];
421
+ // A name of nothing but spaces is a name the caller does not have, and this
422
+ // is a PUBLIC entry point (P3: labre-mcp calls it directly, with whatever it
423
+ // has). `title ` with a trailing space and no title after it is not a
424
+ // statement any reader can do anything with — and the reference one would
425
+ // give the map the empty string as its name.
426
+ const named = options.name !== undefined && options.name.trim().length > 0
427
+ ? options.name
428
+ : undefined;
429
+ // The FILE's title wins, and the caller's name is the fallback — D3's rule
430
+ // applied to the one thing this format has an identity for besides a
431
+ // component's name: record what we were given, never reconstruct what we
432
+ // think we sent. It is the same precedence `interchange.<fmt>.id` already has
433
+ // on every element, and it was the other way round until a browser recette
434
+ // caught it: a tea-shop map imported under its own title
435
+ // ("Tea Shop moderne 2026 …") left again as "BlockSuite Playground", because
436
+ // the host's document name is what the command passes as `context.name` and
437
+ // it was overriding the title the file actually carried.
438
+ //
439
+ // The unit suite could not see it, and that is worth saying: a fixed-point
440
+ // test passes the SAME `name` through both halves, so the two candidates
441
+ // agree in every round trip and the precedence between them is unobservable.
442
+ // The assertion shape that catches it is an import whose file has a title
443
+ // followed by an export under a DIFFERENT name.
444
+ const title = titleAttr ?? named;
445
+ if (titleAttr !== undefined && named !== undefined && titleAttr !== named) {
446
+ warnings.push(`This map came from a file titled "${titleAttr}", and that is the title written out — not "${named}", which is what the board is called here. Rename the map inside the file if you want the exported title to change.`);
447
+ }
448
+ /* Names, resolved once and read by every section. */
449
+ const nameByNode = matchLabels(board.nodes, board.labels);
450
+ const unnamed = [];
451
+ // MEMOIZED, and it is not an optimization: a node is asked for its name once
452
+ // per section it appears in — its own line, and every link that ends on it —
453
+ // and an unnamed one would otherwise be counted twice and christened twice,
454
+ // so one artefact would leave under two names and the links would disagree
455
+ // with the components.
456
+ const resolved = new Map();
457
+ const nameOf = (node) => {
458
+ const already = resolved.get(node.id);
459
+ if (already !== undefined)
460
+ return already;
461
+ const matched = nameByNode.get(node.id);
462
+ const carriedHere = carriedOf(node);
463
+ let name;
464
+ if (matched !== undefined && matched.trim().length > 0) {
465
+ name = matched;
466
+ }
467
+ else if (
468
+ // A carried id is what the FILE called this thing (D3) — but only when it
469
+ // is one. A handle this reader minted so a composite's own wiring could
470
+ // resolve says `element`, and is not a name anybody wrote.
471
+ carriedHere?.element === undefined &&
472
+ carriedHere?.id !== undefined &&
473
+ carriedHere.id.length > 0) {
474
+ name = carriedHere.id;
475
+ }
476
+ else {
477
+ unnamed.push(node);
478
+ name = `Component ${unnamed.length}`;
479
+ }
480
+ resolved.set(node.id, name);
481
+ return name;
482
+ };
483
+ /* Which nodes are not components: pipeline parts, and evolved twins. */
484
+ const twins = new Set();
485
+ for (const connector of board.connectors) {
486
+ if (connector.role !== WARDLEY_ROLE.changeArrow)
487
+ continue;
488
+ const target = connector.target?.id;
489
+ if (target !== undefined)
490
+ twins.add(target);
491
+ }
492
+ const nodeById = new Map(board.nodes.map(node => [node.id, node]));
493
+ const centreOf = (node) => {
494
+ const box = boxOf(node);
495
+ return owmCoordsOf(plot, box.x + box.w / 2, box.y + box.h / 2);
496
+ };
497
+ const offPlot = [];
498
+ const pair = (node, name) => {
499
+ const { visibility, evolution } = centreOf(node);
500
+ if (visibility < 0 || visibility > 1 || evolution < 0 || evolution > 1) {
501
+ offPlot.push(name);
502
+ }
503
+ return `[${owmNumber(visibility)}, ${owmNumber(evolution)}]`;
504
+ };
505
+ /* ── Sections ─────────────────────────────────────────────────────── */
506
+ const nodeLines = [];
507
+ const pipelineLines = [];
508
+ const methodNodes = [];
509
+ for (const node of board.nodes) {
510
+ if (node.role === undefined)
511
+ continue; // a glyph's own wiring, not an artefact
512
+ if (node.kind === 'handle')
513
+ continue;
514
+ if (twins.has(node.id))
515
+ continue; // written by its `evolve` line
516
+ const name = nameOf(node);
517
+ if (node.kind === 'pipeline') {
518
+ const box = boxOf(node);
519
+ const left = owmCoordsOf(plot, box.x, box.y).evolution;
520
+ const right = owmCoordsOf(plot, box.x + box.w, box.y).evolution;
521
+ pipelineLines.push(`pipeline ${owmName(name)} [${owmNumber(left)}, ${owmNumber(right)}]${tailOf(node)}`);
522
+ continue;
523
+ }
524
+ const keyword = OWM_KEYWORD_OF_KIND[node.kind];
525
+ if (keyword === undefined)
526
+ continue;
527
+ if (node.kind === 'method')
528
+ methodNodes.push(name);
529
+ nodeLines.push(`${keyword} ${owmName(name)} ${pair(node, name)}${tailOf(node)}`);
530
+ }
531
+ const noteLines = board.notes.map(note => {
532
+ const box = boxOf(note);
533
+ const { visibility, evolution } = owmCoordsOf(plot, box.x + box.w / 2, box.y + box.h / 2);
534
+ return `note ${owmName(textOf(note))} [${owmNumber(visibility)}, ${owmNumber(evolution)}]${tailOf(note)}`;
535
+ });
536
+ const evolveLines = [];
537
+ const linkLines = [];
538
+ const looseArrows = [];
539
+ const looseLinks = [];
540
+ const movedTwins = [];
541
+ for (const connector of board.connectors) {
542
+ const role = connector.role;
543
+ if (role !== WARDLEY_ROLE.dependency && role !== WARDLEY_ROLE.changeArrow) {
544
+ continue;
545
+ }
546
+ const from = connector.source?.id;
547
+ const to = connector.target?.id;
548
+ const source = from === undefined ? undefined : nodeById.get(from);
549
+ const target = to === undefined ? undefined : nodeById.get(to);
550
+ if (role === WARDLEY_ROLE.changeArrow) {
551
+ if (!source || !target) {
552
+ looseArrows.push(connector.id);
553
+ continue;
554
+ }
555
+ const was = nameOf(source);
556
+ const becomes = nameOf(target);
557
+ const here = centreOf(source);
558
+ const there = centreOf(target);
559
+ // OWM's `evolve` moves a component along the evolution axis and says
560
+ // nothing about the value chain, so a twin drawn at a different height is
561
+ // a sentence the format has no way to write down.
562
+ if (Math.abs(owmNumberValue(here.visibility) - owmNumberValue(there.visibility)) > 0) {
563
+ movedTwins.push(was);
564
+ }
565
+ evolveLines.push(becomes === was
566
+ ? `evolve ${owmName(was)} ${owmNumber(there.evolution)}${tailOf(target)}`
567
+ : `evolve ${evolvePair(was, becomes)} ${owmNumber(there.evolution)}${tailOf(target)}`);
568
+ continue;
569
+ }
570
+ if (!source || !target) {
571
+ looseLinks.push(connector.id);
572
+ continue;
573
+ }
574
+ // `source` is the CONSUMER and `target` is what it needs — the verb of
575
+ // `wardley:dependency` (ADR 0010, and `reading.ts`). Never inverted here:
576
+ // the arrow the user drew is the statement, and a writer that flipped it
577
+ // would publish the opposite of what the board says.
578
+ linkLines.push(`${owmName(nameOf(source))}->${owmName(nameOf(target))}`);
579
+ }
580
+ // NOT deduplicated. Two identical lines in a file — `// same` twice, two
581
+ // `pioneers` blocks — are two lines the reader carried and counted as two,
582
+ // and collapsing them here would lose one of them against a report that said
583
+ // it was kept. D1's whole promise is that a carried line comes back; "unless
584
+ // it looked like another one" is not a clause it has.
585
+ const carriedLines = carried?.children?.[OWM_SCOPE.document] ?? [];
586
+ /* ── Warnings the writer owes the person who clicked Export ───────── */
587
+ if (unnamed.length > 0) {
588
+ warnings.push(`${unnamed.length} artefact${unnamed.length === 1 ? ' has' : 's have'} no name on the map and ${unnamed.length === 1 ? 'was' : 'were'} written as "Component 1", "Component 2"… — an OWM component is identified by its name.`);
589
+ }
590
+ const duplicates = duplicateNames([...nodeLines, ...pipelineLines]);
591
+ if (duplicates.length > 0) {
592
+ warnings.push(`${duplicates.join(', ')} ${duplicates.length === 1 ? 'is the name of' : 'are the names of'} more than one artefact. OWM identifies a component by its name, so every link naming one of these means the first.`);
593
+ }
594
+ if (offPlot.length > 0) {
595
+ warnings.push(`${offPlot.length} artefact${offPlot.length === 1 ? '' : 's'} sit outside the map's plot, so their coordinates fall outside 0…1 and other tools will draw them off the map: ${offPlot.slice(0, 5).join(', ')}${offPlot.length > 5 ? '…' : ''}.`);
596
+ }
597
+ if (methodNodes.length > 0) {
598
+ warnings.push(`${methodNodes.length} component${methodNodes.length === 1 ? ' carries' : 's carry'} a method (build / buy / outsource). OWM writes a method as a decorator this export cannot tell apart, so ${methodNodes.length === 1 ? 'it was' : 'they were'} written as plain components.`);
599
+ }
600
+ if (looseLinks.length > 0) {
601
+ warnings.push(`${looseLinks.length} link${looseLinks.length === 1 ? ' has an end' : 's have ends'} that is loose or attached to something that is not a Wardley artefact. A link names two components, so ${looseLinks.length === 1 ? 'it was' : 'they were'} left out.`);
602
+ }
603
+ if (looseArrows.length > 0) {
604
+ // Two losses, not one, and the second is the one nobody would guess: the
605
+ // node an evolution arrow points AT is written by the `evolve` line rather
606
+ // than as a component of its own, so when the arrow cannot be written the
607
+ // twin has no line either and leaves the file altogether.
608
+ warnings.push(`${looseArrows.length} evolution arrow${looseArrows.length === 1 ? ' has an end' : 's have ends'} that is loose or attached to something that is not a Wardley artefact, so ${looseArrows.length === 1 ? 'it was' : 'they were'} left out — and so ${looseArrows.length === 1 ? 'was the evolved component it points at, which is written by its `evolve` line and has no line of its own' : 'were the evolved components they point at, which are written by their `evolve` lines and have no lines of their own'}.`);
609
+ }
610
+ if (movedTwins.length > 0) {
611
+ warnings.push(`${movedTwins.length} evolution arrow${movedTwins.length === 1 ? '' : 's'} ends at a different height on the value chain (${movedTwins.slice(0, 5).join(', ')}). OWM's \`evolve\` moves a component along the evolution axis only, so the change of visibility was not written.`);
612
+ }
613
+ /* ── The document ─────────────────────────────────────────────────── */
614
+ const sections = [
615
+ title === undefined ? [] : [`title ${title}`],
616
+ nodeLines,
617
+ pipelineLines,
618
+ noteLines,
619
+ evolveLines,
620
+ linkLines,
621
+ carriedLines,
622
+ ].filter(section => section.length > 0);
623
+ const text = sections.map(section => section.join('\n')).join('\n\n') + '\n';
624
+ return { text, warnings };
625
+ }
626
+ /** The value a coordinate is WRITTEN as, so a comparison agrees with the file. */
627
+ function owmNumberValue(value) {
628
+ return Number(owmNumber(value));
629
+ }
630
+ /** The names two artefacts share, in first-seen order. */
631
+ function duplicateNames(lines) {
632
+ const seen = new Set();
633
+ const twice = new Set();
634
+ for (const line of lines) {
635
+ // `<keyword> <name> [` — the name is everything between the two.
636
+ const match = /^\S+\s+(.*?)\s+\[/.exec(line);
637
+ if (!match)
638
+ continue;
639
+ const name = match[1];
640
+ if (seen.has(name))
641
+ twice.add(name);
642
+ seen.add(name);
643
+ }
644
+ return [...twice];
645
+ }
646
+ /**
647
+ * The board as an OWM document — models in, text out, and nothing else.
648
+ *
649
+ * The signature P3 names: exported from this package's index so that labre-mcp
650
+ * calls THIS function rather than keeping the copy ADR 0012 records as the one
651
+ * violation of it.
652
+ */
653
+ export function exportWardleyOwm(board, options = {}) {
654
+ return exportWardleyOwmWithWarnings(board, options).text;
655
+ }
@@ -1,15 +1,10 @@
1
- /**
2
- * Curve-driven gradient backgrounds (Slice C). Each analytic background is a
3
- * smooth mathematical curve (piecewise asymmetric Gaussian bells); the gradient
4
- * opacity at each evolution position X follows that curve, normalised between
5
- * its own min and max — i.e. the gradient is strongest where the curve peaks and
6
- * fades to nothing at its minimum. Validated against the reference images at
7
- * `../wardley-mockups/gradient-backgrounds.html`.
8
- */
1
+ import type { BackgroundWashDef } from '@formicoidea/labre-core/blocks/surface';
9
2
  export declare const GRADIENT_GREEN = "#1f9e4d";
10
3
  export declare const GRADIENT_RED = "#d6455d";
11
4
  /**
12
- * Paint the curve-driven gradient over the plot rectangle [px0,px1]×[py0,py1]
13
- * in element-local coordinates. `classic` paints nothing.
5
+ * The washes the Wardley declaration ships, in painting order. Only those whose
6
+ * `variants` name the background's current `variant` are painted, and only
7
+ * while `showGradient` is on — so `classic` paints none of them and the frame
8
+ * stays plain white, exactly as before.
14
9
  */
15
- export declare function paintGradientBackground(ctx: CanvasRenderingContext2D, variant: 'opportunity' | 'benefit' | 'evolution-gradient', px0: number, px1: number, py0: number, py1: number): void;
10
+ export declare const WARDLEY_WASHES: readonly BackgroundWashDef[];