@formicoidea/labre-framework-wardley 0.40.0 → 0.42.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.
package/dist/actions.js CHANGED
@@ -1,17 +1,18 @@
1
- import { backgroundSize, DefaultTool, indexOverBackgrounds, runInterchangeImportFile, } from '@formicoidea/labre-core/blocks/surface';
1
+ import { backgroundSize, containingFrame, DefaultTool, indexOverBackgrounds, runInterchangeImportFile, } from '@formicoidea/labre-core/blocks/surface';
2
2
  import { ConnectorTool } from '@formicoidea/labre-core/gfx/connector';
3
3
  import { createGroupCommand } from '@formicoidea/labre-core/gfx/group';
4
- import { ConnectorMode, FontWeight, FrameworkBackgroundElementModel, PointStyle, StrokeStyle, WardleyBackgroundElementModel, } from '@formicoidea/labre-core/model';
4
+ import { PolygonTool } from '@formicoidea/labre-core/gfx/shape';
5
+ import { ConnectorMode, FontWeight, FrameworkBackgroundElementModel, TextElementModel, WardleyBackgroundElementModel, } from '@formicoidea/labre-core/model';
5
6
  import { NotificationProvider, translateKey, } from '@formicoidea/labre-core/shared/services';
6
7
  import { downloadBlob } from '@formicoidea/labre-core/shared/utils';
7
8
  import { Bound } from '@formicoidea/labre-core/global/gfx';
8
- import { GfxControllerIdentifier } from '@formicoidea/labre-core/std/gfx';
9
+ import { GfxControllerIdentifier, } from '@formicoidea/labre-core/std/gfx';
9
10
  import { WARDLEY_BACKGROUND } from './background.js';
10
11
  import { WARDLEY_SVG_IMPORT } from './interchange.js';
11
12
  import { wardleyBoardFrom, wardleySafeFilename, } from './export.js';
12
13
  import { WARDLEY_OWM_EXPORT, WARDLEY_OWM_IMPORT } from './interchange.js';
13
- import { HANDLE_SIZE, INERTIA_SIZE, LABEL_GAP, LINK_GREY, LINK_STROKE_WIDTH, PORTER_DEFAULT_LETTER, WARDLEY_RED, } from './node/consts.js';
14
- import { wardleyAreaBox, wardleyAreaProps, wardleyCanonicalBox, wardleyHandleBox, wardleyHandleProps, wardleyInertiaProps, WARDLEY_LABEL_H, WARDLEY_LABEL_W, wardleyLabelProps, wardleyMarketDotBoxes, wardleyMarketDotProps, wardleyMarketLinkPairs, wardleyMarketLinkProps, WARDLEY_NODE_LABEL, WARDLEY_NODE_SIZE, wardleyNodeProps, wardleyPorterArrowProps, wardleyPorterArrows, wardleyPorterLetterProps, } from './presets.js';
14
+ import { INERTIA_SIZE, PORTER_DEFAULT_LETTER } from './node/consts.js';
15
+ import { wardleyAreaBox, wardleyAreaProps, wardleyCanonicalBox, WARDLEY_EDGE_STYLE, wardleyHandleBox, wardleyHandleProps, wardleyInertiaProps, wardleyLabelBoxFor, wardleyLabelProps, wardleyMarketDotBoxes, wardleyMarketDotProps, wardleyMarketLinkPairs, wardleyMarketLinkProps, WARDLEY_NODE_LABEL, wardleyNodeLabelKey, wardleyNodeProps, wardleyPorterArrowProps, wardleyPorterArrows, wardleyPorterLetterProps, } from './presets.js';
15
16
  import { WARDLEY_ROLE } from './roles.js';
16
17
  /**
17
18
  * Standalone creation/activation actions for the Wardley toolbox — the
@@ -24,48 +25,62 @@ import { WARDLEY_ROLE } from './roles.js';
24
25
  * segment/module discrimination it carried is now `CommandInvocation.surface`.
25
26
  */
26
27
  /**
27
- * Per-variant default label overrides applied at creation (all remain editable
28
- * afterwards via the inline editor / toggles). The gradient itself is driven by
29
- * `variant` in the renderer.
30
- *
31
- * TODO(PF2 follow-up): these write ENGLISH PROSE into the document. Since PF2
32
- * the ten label props default to `undefined` precisely so the declaration's
33
- * i18n keys are reachable — but a map created as `opportunity` or `benefit`
34
- * lands with "Opportunity" / "Benefit" / "Investment" already persisted as if
35
- * the user had typed them, so those three are un-localisable for the life of
36
- * the document. Only `classic` and `evolution-gradient` are fully localisable
37
- * today.
38
- *
39
- * The fix is to make the variant part of the declaration — one axis/end-label
40
- * set per variant, each naming its own `labelKey` — rather than a bag of prop
41
- * overrides applied at creation. Out of scope here (it changes what a variant
42
- * IS). This is now the ONLY copy: the four background templates are derived
43
- * from these four commands, so the table goes away in one place.
28
+ * The three axis words a translated variant's background is seeded with —
29
+ * declared once, so the resolver below and the manifest
30
+ * (`../translations.ts`) read the very same fallback.
44
31
  */
45
- const BACKGROUND_VARIANT_DEFAULTS = {
46
- classic: {},
47
- // The Y axis becomes "Opportunity"; phase labels keep the classic defaults.
32
+ export const WARDLEY_AXIS_SEED = {
48
33
  opportunity: {
49
- yAxisTitle: 'Opportunity',
50
- showVisibilityLabels: false,
51
- showCornerLabels: false,
34
+ key: 'com.labre.wardley.seed.axis-opportunity',
35
+ fallback: 'Opportunity',
52
36
  },
53
- // The Y axis splits into Benefit (top) / Investment (bottom) around a zero
54
- // line drawn by the renderer.
55
37
  benefit: {
56
- yAxisTitle: '',
57
- visibilityHigh: 'Benefit',
58
- visibilityLow: 'Investment',
59
- showCornerLabels: false,
38
+ key: 'com.labre.wardley.seed.axis-benefit',
39
+ fallback: 'Benefit',
40
+ },
41
+ investment: {
42
+ key: 'com.labre.wardley.seed.axis-investment',
43
+ fallback: 'Investment',
60
44
  },
61
- // Keeps the classic labels (Value Chain / Uncharted / Industrialized…); only
62
- // the grey gradient differs.
63
- 'evolution-gradient': {},
64
45
  };
65
- // The label box, now owned by `presets.ts` — the local names stay so the four
66
- // placements below keep reading as they did.
67
- const LABEL_H = WARDLEY_LABEL_H;
68
- const LABEL_W = WARDLEY_LABEL_W;
46
+ /**
47
+ * Per-variant default label overrides applied at creation (all remain editable
48
+ * afterwards via the inline editor / toggles). The gradient itself is driven by
49
+ * `variant` in the renderer.
50
+ *
51
+ * Resolved through the translation seam since the i18n pass (#278): the ten
52
+ * label props still default to `undefined` on the declaration itself, but
53
+ * `opportunity` and `benefit` are the two variants whose Y axis is PROSE the
54
+ * gesture bakes into the document at creation — `WARDLEY_AXIS_SEED` above is
55
+ * what a host's catalogue is offered for the three words, resolved HERE, once,
56
+ * exactly like every other seed in this file. `classic` and
57
+ * `evolution-gradient` keep the classic labels (Value Chain / Uncharted /
58
+ * Industrialized…), which are declaration `labelKey`s and were already fully
59
+ * localisable — this function names no override for them.
60
+ */
61
+ function backgroundVariantDefaults(gfx) {
62
+ const axis = (def) => translateKey(gfx.std, def.key, def.fallback);
63
+ return {
64
+ classic: {},
65
+ // The Y axis becomes "Opportunity"; phase labels keep the classic defaults.
66
+ opportunity: {
67
+ yAxisTitle: axis(WARDLEY_AXIS_SEED.opportunity),
68
+ showVisibilityLabels: false,
69
+ showCornerLabels: false,
70
+ },
71
+ // The Y axis splits into Benefit (top) / Investment (bottom) around a zero
72
+ // line drawn by the renderer.
73
+ benefit: {
74
+ yAxisTitle: '',
75
+ visibilityHigh: axis(WARDLEY_AXIS_SEED.benefit),
76
+ visibilityLow: axis(WARDLEY_AXIS_SEED.investment),
77
+ showCornerLabels: false,
78
+ },
79
+ // Keeps the classic labels (Value Chain / Uncharted / Industrialized…); only
80
+ // the grey gradient differs.
81
+ 'evolution-gradient': {},
82
+ };
83
+ }
69
84
  function finish(gfx, id) {
70
85
  gfx.doc.captureSync();
71
86
  gfx.tool.setTool(DefaultTool);
@@ -128,7 +143,7 @@ export function createWardleyBackground(gfx, variant = 'classic') {
128
143
  // declaration decides, and the toolbar toggle takes over from there.
129
144
  resizeEnabled: WARDLEY_BACKGROUND.geometry.resizable,
130
145
  variant,
131
- ...BACKGROUND_VARIANT_DEFAULTS[variant],
146
+ ...backgroundVariantDefaults(gfx)[variant],
132
147
  xywh: new Bound(centerX - width / 2, centerY - height / 2, width, height).serialize(),
133
148
  });
134
149
  finish(gfx, id);
@@ -142,10 +157,15 @@ export function createWardleyNode(gfx, kind) {
142
157
  const surface = gfx.surface;
143
158
  if (!surface)
144
159
  return;
145
- const { w } = WARDLEY_NODE_SIZE[kind];
146
160
  const { centerX: cx, centerY: cy } = gfx.viewport;
147
161
  const nodeId = addNode(surface, kind, cx, cy);
148
- const labelId = addLabel(surface, WARDLEY_NODE_LABEL[kind], cx + w / 2 + LABEL_GAP, cy - LABEL_H / 2);
162
+ const { x, y, textAlign } = wardleyLabelBoxFor(kind, cx, cy);
163
+ const labelId = addLabel(surface,
164
+ // Resolved HERE, once — the prompt a node nobody has named still carries,
165
+ // exactly like a BPMN node's caption. `gfx.std` is how this file reaches
166
+ // the translation seam: these creation sites take `GfxController`, not
167
+ // `BlockStdScope`, directly (`WardleyView` reads the same member).
168
+ translateKey(gfx.std, wardleyNodeLabelKey(kind), WARDLEY_NODE_LABEL[kind]), x, y, textAlign);
149
169
  finish(gfx, group(gfx, [nodeId, labelId]));
150
170
  }
151
171
  /** Create an inertia bar (filled black rect). */
@@ -170,15 +190,14 @@ export function createWardleyPipeline(gfx) {
170
190
  if (!gfx.surface)
171
191
  return;
172
192
  const { centerX: cx, centerY: cy } = gfx.viewport;
173
- const d = HANDLE_SIZE;
174
- const top = cy - WARDLEY_NODE_SIZE.pipeline.h / 2;
175
193
  // Body: a WardleyNode rect, made non-connectable by `kind: 'pipeline'`.
176
194
  const bodyId = addNode(gfx.surface, 'pipeline', cx, cy);
177
195
  // Handle: a node-sized WardleyNode square straddling the top edge. Inherits
178
196
  // `centerAnchorOnly` so connectors attach to its center only.
179
197
  const handleId = gfx.surface.addElement(wardleyHandleProps({ xywh: wardleyHandleBox(cx, cy) }));
180
198
  // Label centered horizontally on the pipeline, sitting ABOVE the handle.
181
- const labelId = addLabel(gfx.surface, WARDLEY_NODE_LABEL.pipeline, cx - 60, top - d / 2 - LABEL_H - LABEL_GAP, 'center');
199
+ const { x, y, textAlign } = wardleyLabelBoxFor('pipeline', cx, cy);
200
+ const labelId = addLabel(gfx.surface, translateKey(gfx.std, wardleyNodeLabelKey('pipeline'), WARDLEY_NODE_LABEL.pipeline), x, y, textAlign);
182
201
  // Nested groups: (handle + label), then (body + that group).
183
202
  const innerId = group(gfx, [handleId, labelId]);
184
203
  finish(gfx, group(gfx, [bodyId, innerId]));
@@ -195,14 +214,14 @@ export function createWardleyMarket(gfx) {
195
214
  if (!surface)
196
215
  return;
197
216
  const { centerX: cx, centerY: cy } = gfx.viewport;
198
- const R = WARDLEY_NODE_SIZE.market.w / 2;
199
217
  // Outer circle = the market node (connectable, center-only).
200
218
  const circleId = addNode(surface, 'market', cx, cy);
201
219
  // 3 inner component nodes (thick border, no label) at the triangle vertices.
202
220
  const dotIds = wardleyMarketDotBoxes(cx, cy).map(xywh => surface.addElement(wardleyMarketDotProps({ xywh })));
203
221
  // Triangle: 3 attached connectors (auto-route center-to-center, clipped).
204
222
  const connIds = wardleyMarketLinkPairs(dotIds).map(([a, b]) => surface.addElement(wardleyMarketLinkProps(a, b)));
205
- const labelId = addLabel(surface, WARDLEY_NODE_LABEL.market, cx + R + LABEL_GAP, cy - LABEL_H / 2);
223
+ const { x, y, textAlign } = wardleyLabelBoxFor('market', cx, cy);
224
+ const labelId = addLabel(surface, translateKey(gfx.std, wardleyNodeLabelKey('market'), WARDLEY_NODE_LABEL.market), x, y, textAlign);
206
225
  finish(gfx, group(gfx, [circleId, ...dotIds, ...connIds, labelId]));
207
226
  }
208
227
  /**
@@ -259,14 +278,12 @@ export function createWardleyAccelerator(gfx, kind) {
259
278
  const surface = gfx.surface;
260
279
  if (!surface)
261
280
  return;
262
- const { w } = WARDLEY_NODE_SIZE[kind];
263
281
  const { centerX: cx, centerY: cy } = gfx.viewport;
264
282
  const nodeId = addNode(surface, kind, cx, cy);
265
- const rightwards = kind === 'accelerator';
266
- const labelId = addLabel(surface, WARDLEY_NODE_LABEL[kind],
267
- // A label box is a fixed LABEL_W wide whatever it reads, so a right-aligned
268
- // one has to start a box-width before the edge the words must end on.
269
- rightwards ? cx + w / 2 + LABEL_GAP : cx - w / 2 - LABEL_GAP - LABEL_W, cy - LABEL_H / 2, rightwards ? 'left' : 'right', FontWeight.SemiBold);
283
+ // Which side, and which alignment, are the placement's answer: the
284
+ // decelerator's mirrored label is one of the two rules `presets.ts` states.
285
+ const { x, y, textAlign } = wardleyLabelBoxFor(kind, cx, cy);
286
+ const labelId = addLabel(surface, translateKey(gfx.std, wardleyNodeLabelKey(kind), WARDLEY_NODE_LABEL[kind]), x, y, textAlign, FontWeight.SemiBold);
270
287
  finish(gfx, group(gfx, [nodeId, labelId]));
271
288
  }
272
289
  /**
@@ -298,6 +315,40 @@ export function createWardleyArea(gfx, shape) {
298
315
  lowerWardleyArea(gfx, id);
299
316
  finish(gfx, id);
300
317
  }
318
+ /**
319
+ * Arm the interactive polygon tool to draw a ZONE, corner by corner.
320
+ *
321
+ * The polygonal half of the kind is a TOOL and the rectangular half an artefact,
322
+ * and the asymmetry is the gesture rather than an inconsistency: a rectangle has
323
+ * one shape whatever its size, so placing one and dragging its handles is the
324
+ * whole of drawing it — while a polygon IS its corners, and a prefabricated
325
+ * pentagon parked in the middle of the map is never the outline anybody wanted
326
+ * (PO decision of 2026-09-16). The author clicks each corner and closes on the
327
+ * first one; Escape cancels and fewer than three corners draws nothing, both of
328
+ * which the tool already decides.
329
+ *
330
+ * What comes back is a Wardley area and not a plain polygon, because the tool
331
+ * creates the element from the props handed here — same fill, rim, role and
332
+ * inner-text settings as {@link createWardleyArea} writes, from the same
333
+ * description — and then lowers it the way that function does.
334
+ */
335
+ export function activateWardleyAreaPolygon(gfx) {
336
+ const { centerX: cx, centerY: cy } = gfx.viewport;
337
+ gfx.tool.setTool(PolygonTool, {
338
+ // The box and the default pentagon in here are a placeholder: the tool
339
+ // spreads this bag before the outline the author drew, so both are
340
+ // overwritten. They are still stated rather than stripped, because what a
341
+ // zone IS has exactly one description (`presets.ts`) and taking two keys
342
+ // out of it here would be a second one.
343
+ props: wardleyAreaProps('polygon', {
344
+ xywh: wardleyAreaBox('polygon', cx, cy),
345
+ }),
346
+ // A zone is lowered the moment it exists, for the reason
347
+ // {@link createWardleyArea} gives at length: drawn last it would paint over
348
+ // every component it groups and intercept their clicks.
349
+ onCreated: lowerWardleyArea,
350
+ });
351
+ }
301
352
  /**
302
353
  * Send one already-placed area to the depth it belongs at — see
303
354
  * {@link createWardleyArea} for why a zone is lowered at all.
@@ -368,22 +419,10 @@ export function activateWardleyConnector(gfx, kind) {
368
419
  // fall on a dependency.
369
420
  role: kind === 'link' ? WARDLEY_ROLE.dependency : WARDLEY_ROLE.changeArrow,
370
421
  // The look rides on the activation, never through the last-props store:
371
- // the plain connector tool must keep the user's own style (#144 M1).
372
- style: kind === 'arrow'
373
- ? {
374
- stroke: WARDLEY_RED,
375
- strokeStyle: StrokeStyle.Dash,
376
- strokeWidth: LINK_STROKE_WIDTH,
377
- frontEndpointStyle: PointStyle.None,
378
- rearEndpointStyle: PointStyle.Triangle,
379
- }
380
- : {
381
- stroke: LINK_GREY,
382
- strokeStyle: StrokeStyle.Solid,
383
- strokeWidth: LINK_STROKE_WIDTH,
384
- frontEndpointStyle: PointStyle.None,
385
- rearEndpointStyle: PointStyle.None,
386
- },
422
+ // the plain connector tool must keep the user's own style (#144 M1). The
423
+ // style itself is {@link WARDLEY_EDGE_STYLE}, so the legend row this tool
424
+ // subscribes pictures the very line the tool draws.
425
+ style: { ...WARDLEY_EDGE_STYLE[kind] },
387
426
  });
388
427
  // The wardley palette stays open (native sub-menu behaviour): it only
389
428
  // closes on re-click of the senior button, another senior tool, or Escape.
@@ -400,33 +439,74 @@ export function activateWardleyConnector(gfx, kind) {
400
439
  */
401
440
  const EXPORT_WARNINGS_KEY = 'com.labre.commands.wardley.exportOwm.warnings';
402
441
  const EXPORT_WARNINGS_FALLBACK = 'What this export could not write down';
403
- /** The maps on the surface — what the export command is offered against. */
404
- export function wardleyMapsOnBoard(std) {
405
- const surface = std.get(GfxControllerIdentifier).surface;
406
- return (surface?.elementModels ?? []).filter((model) => model instanceof WardleyBackgroundElementModel);
442
+ /**
443
+ * The Wardley maps in the SELECTION — what the export is offered against.
444
+ *
445
+ * No read-only filter, for `c4BoardsForExport`'s reason: an export writes
446
+ * nothing, and a map published read-only is precisely the one somebody wants to
447
+ * take away.
448
+ */
449
+ export function wardleyMapsSelected(std) {
450
+ return std
451
+ .get(GfxControllerIdentifier)
452
+ .selection.selectedElements.filter((model) => model instanceof WardleyBackgroundElementModel);
407
453
  }
408
454
  /**
409
- * Everything on the surface the exporter speaks about, in document order.
455
+ * The elements the export speaks about, in DOCUMENT order: the selected map(s)
456
+ * and what they wholly contain.
410
457
  *
411
- * The half that needs an editor, and only that half: reading the surface. The
412
- * picking is {@link wardleyBoardFrom}, which the interchange capability calls
413
- * with the same elements and no `std` at all (`docs/adr/0012`, P3).
458
+ * Membership is `containingFrame` on the map's element bound — whole
459
+ * containment, ties to the smaller id — which is the one answer the validation
460
+ * engine and the legend already give (PF2.4). The candidates are EVERY map on
461
+ * the surface and not just the selected ones, so an artefact inside two
462
+ * overlapping maps is attributed here exactly as the engine attributes it.
463
+ */
464
+ export function wardleyExportElementsOf(std) {
465
+ const elements = std.get(GfxControllerIdentifier).surface?.elementModels ?? [];
466
+ const allMaps = elements.filter((model) => model instanceof WardleyBackgroundElementModel);
467
+ const selected = new Set(wardleyMapsSelected(std));
468
+ return elements.filter(element => {
469
+ if (element instanceof WardleyBackgroundElementModel) {
470
+ return selected.has(element);
471
+ }
472
+ // ponytail: a label is the NAME of a node, not an artefact of its own, so it
473
+ // crosses UNSCOPED. `matchLabels` (export.ts) only binds one within
474
+ // LABEL_MATCH_TOLERANCE of a kept node and drops the rest, while a long name
475
+ // beside a node near the right edge legitimately hangs over the map bound —
476
+ // scoping labels by containment would export that node unnamed.
477
+ if (element instanceof TextElementModel &&
478
+ element.role === WARDLEY_ROLE.label) {
479
+ return true;
480
+ }
481
+ const map = containingFrame(element.elementBound, allMaps, candidate => candidate.elementBound);
482
+ return map !== null && selected.has(map);
483
+ });
484
+ }
485
+ /**
486
+ * What the exporter speaks about, as a picked board.
487
+ *
488
+ * The half that needs an editor, and only that half: reading the surface and
489
+ * scoping it to the selection. The picking is {@link wardleyBoardFrom}, which
490
+ * the interchange capability calls with the same elements and no `std` at all
491
+ * (`docs/adr/0012`, P3).
414
492
  */
415
493
  export function wardleyBoardOf(std) {
416
- return wardleyBoardFrom(std.get(GfxControllerIdentifier).surface?.elementModels ?? []);
494
+ return wardleyBoardFrom(wardleyExportElementsOf(std));
417
495
  }
418
496
  /**
419
- * Serialize the board as an OWM document and hand it to the browser.
497
+ * Serialize the selected map as an OWM document and hand it to the browser.
420
498
  *
421
499
  * Three steps, and only the first and the last know what an editor is: read the
422
- * surface, run the DECLARED capability, download what it produced. The middle
423
- * step is not re-implemented here — the document, the filename and the content
424
- * type all come out of `WARDLEY_OWM_EXPORT.run`, so the command and the
425
- * registry cannot describe the same map differently. There is one door, and the
426
- * registry is the label on it.
500
+ * selected map's perimeter, run the DECLARED capability, download what it
501
+ * produced. The middle step is not re-implemented here — the document, the
502
+ * filename and the content type all come out of `WARDLEY_OWM_EXPORT.run`, so
503
+ * the command and the registry cannot describe the same map differently.
504
+ *
505
+ * Selecting SEVERAL maps still produces one file: an OWM document is one map,
506
+ * and `exportWardleyOwmWithWarnings` says so out loud in its warnings.
427
507
  */
428
508
  export function exportOwmFile(std) {
429
- const elements = std.get(GfxControllerIdentifier).surface?.elementModels ?? [];
509
+ const elements = wardleyExportElementsOf(std);
430
510
  const title = std.store.workspace.meta.getDocMeta(std.store.id)?.title;
431
511
  const { text, filename, mime, warnings } = WARDLEY_OWM_EXPORT.run(elements, {
432
512
  name: wardleySafeFilename(title),
package/dist/commands.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { GfxControllerIdentifier } from '@formicoidea/labre-core/std/gfx';
2
- import { activateWardleyConnector, createWardleyAccelerator, createWardleyArea, createWardleyBackground, createWardleyInertia, createWardleyMarket, createWardleyNode, createWardleyPipeline, createWardleyPorter, exportOwmFile, importOwmFile, importWardleySvgFile, wardleyMapsOnBoard, } from './actions.js';
2
+ import { activateWardleyAreaPolygon, activateWardleyConnector, createWardleyAccelerator, createWardleyArea, createWardleyBackground, createWardleyInertia, createWardleyMarket, createWardleyNode, createWardleyPipeline, createWardleyPorter, exportOwmFile, importOwmFile, importWardleySvgFile, wardleyMapsSelected, } from './actions.js';
3
+ import { WARDLEY_LEGEND_BOX, wardleyLegendEntry, } from './legend.js';
3
4
  import { WARDLEY_ROLE, WARDLEY_ROLES } from './roles.js';
4
- import { wardleyAcceleratorIcon, wardleyAnchorIcon, wardleyAreaPolygonIcon, wardleyAreaRectIcon, wardleyArrowIcon, wardleyBackgroundIcon, wardleyDeceleratorIcon, wardleyBenefitIcon, wardleyComponentIcon, wardleyEcosystemIcon, wardleyEvolutionGradientIcon, wardleyExportOwmIcon, wardleyImportOwmIcon, wardleyImportSvgIcon, wardleyInertiaIcon, wardleyLinkIcon, wardleyMarketIcon, wardleyMethodIcon, wardleyOpportunityIcon, wardleyPipelineIcon, wardleyPorterIcon, } from './toolbar/icons.js';
5
+ import { wardleyAcceleratorIcon, wardleyAnchorIcon, wardleyAreaPolygonIcon, wardleyAreaRectIcon, wardleyArrowIcon, wardleyBackgroundIcon, wardleyDeceleratorIcon, wardleyBenefitIcon, wardleyComponentIcon, wardleyEcosystemIcon, wardleyEvolutionGradientIcon, wardleyExportOwmIcon, wardleyImportOwmIcon, wardleyImportSvgIcon, wardleyInertiaIcon, wardleyLinkIcon, wardleyMarketIcon, wardleyMethodIcon, wardleyOpportunityIcon, wardleyPipelineIcon, wardleyPorterIcon, wardleyToolbarIcon, } from './toolbar/icons.js';
5
6
  const SPECS = [
6
7
  {
7
8
  id: 'addBackground',
@@ -47,6 +48,7 @@ const SPECS = [
47
48
  },
48
49
  {
49
50
  id: 'addComponent',
51
+ legendRole: WARDLEY_ROLE.component,
50
52
  label: 'Component',
51
53
  labelKey: 'com.labre.keyboardShortcuts.wardley.addComponent',
52
54
  key: 'c',
@@ -58,6 +60,7 @@ const SPECS = [
58
60
  },
59
61
  {
60
62
  id: 'addMethod',
63
+ legendRole: WARDLEY_ROLE.method,
61
64
  label: 'Component + method',
62
65
  labelKey: 'com.labre.keyboardShortcuts.wardley.addMethod',
63
66
  key: 'm',
@@ -69,6 +72,7 @@ const SPECS = [
69
72
  },
70
73
  {
71
74
  id: 'addMarket',
75
+ legendRole: WARDLEY_ROLE.market,
72
76
  label: 'Market',
73
77
  iconKey: 'wardley.market',
74
78
  category: 'nodes',
@@ -78,6 +82,7 @@ const SPECS = [
78
82
  },
79
83
  {
80
84
  id: 'addEcosystem',
85
+ legendRole: WARDLEY_ROLE.ecosystem,
81
86
  label: 'Ecosystem',
82
87
  iconKey: 'wardley.ecosystem',
83
88
  category: 'nodes',
@@ -87,6 +92,7 @@ const SPECS = [
87
92
  },
88
93
  {
89
94
  id: 'addAnchor',
95
+ legendRole: WARDLEY_ROLE.anchor,
90
96
  label: 'Anchor',
91
97
  key: 'a',
92
98
  iconKey: 'wardley.anchor',
@@ -97,6 +103,7 @@ const SPECS = [
97
103
  },
98
104
  {
99
105
  id: 'addPipeline',
106
+ legendRole: WARDLEY_ROLE.pipeline,
100
107
  label: 'Pipeline',
101
108
  labelKey: 'com.labre.keyboardShortcuts.wardley.addPipeline',
102
109
  key: 'p',
@@ -108,6 +115,7 @@ const SPECS = [
108
115
  },
109
116
  {
110
117
  id: 'linkTool',
118
+ legendRole: WARDLEY_ROLE.dependency,
111
119
  label: 'Link',
112
120
  labelKey: 'com.labre.keyboardShortcuts.wardley.linkTool',
113
121
  key: 'l',
@@ -120,6 +128,7 @@ const SPECS = [
120
128
  },
121
129
  {
122
130
  id: 'evolutionArrow',
131
+ legendRole: WARDLEY_ROLE.changeArrow,
123
132
  label: 'Arrow (evolution)',
124
133
  labelKey: 'com.labre.keyboardShortcuts.wardley.evolutionArrow',
125
134
  key: 'e',
@@ -132,6 +141,7 @@ const SPECS = [
132
141
  },
133
142
  {
134
143
  id: 'addInertia',
144
+ legendRole: WARDLEY_ROLE.inertia,
135
145
  label: 'Inertia',
136
146
  labelKey: 'com.labre.keyboardShortcuts.wardley.addInertia',
137
147
  key: 'i',
@@ -148,6 +158,7 @@ const SPECS = [
148
158
  // a framework binds past that by host override rather than by default.
149
159
  {
150
160
  id: 'addPorter',
161
+ legendRole: WARDLEY_ROLE.porter,
151
162
  label: "Porter's forces",
152
163
  iconKey: 'wardley.porter',
153
164
  category: 'nodes',
@@ -162,6 +173,7 @@ const SPECS = [
162
173
  // past that by host override.
163
174
  {
164
175
  id: 'addAccelerator',
176
+ legendRole: WARDLEY_ROLE.accelerator,
165
177
  label: 'Accelerator',
166
178
  iconKey: 'wardley.accelerator',
167
179
  category: 'nodes',
@@ -171,6 +183,7 @@ const SPECS = [
171
183
  },
172
184
  {
173
185
  id: 'addDecelerator',
186
+ legendRole: WARDLEY_ROLE.decelerator,
174
187
  label: 'Decelerator',
175
188
  iconKey: 'wardley.decelerator',
176
189
  category: 'nodes',
@@ -182,12 +195,14 @@ const SPECS = [
182
195
  // around a chain that already exists, so it is the last gesture of a map and
183
196
  // reads as the last entry of the row. Two entries and ONE kind — the shape is
184
197
  // the choice, and offering it as two buttons is what saves the author a trip
185
- // through "switch type" for the commonest case.
198
+ // through "switch type" for the commonest case. One kind, but two GESTURES:
199
+ // the rectangle is placed, the polygon is drawn corner by corner.
186
200
  //
187
201
  // Keyless, like the porter and the two climate arrows: the `w` chord seats
188
202
  // eight artefacts and a framework binds past that by host override.
189
203
  {
190
204
  id: 'addAreaRect',
205
+ legendRole: WARDLEY_ROLE.area,
191
206
  label: 'Area (rectangle)',
192
207
  iconKey: 'wardley.area-rect',
193
208
  category: 'areas',
@@ -197,12 +212,20 @@ const SPECS = [
197
212
  },
198
213
  {
199
214
  id: 'addAreaPolygon',
215
+ legendRole: WARDLEY_ROLE.area,
200
216
  label: 'Area (polygon)',
201
217
  iconKey: 'wardley.area-polygon',
202
218
  category: 'areas',
203
- kind: 'artefact',
219
+ // A TOOL, and the only entry of this row that is one: a polygon IS its
220
+ // corners, so the author draws them rather than receiving a pentagon to
221
+ // reshape (PO decision of 2026-09-16, #343). `element` is untouched — the
222
+ // artefact this gesture produces is the same one — and the change of kind
223
+ // is what moves its event from `FrameworkElementAdded` to
224
+ // `FrameworkToolPicked`, which is the truth about a tool: it is picked, and
225
+ // the drawing may still be abandoned with Escape.
226
+ kind: 'tool',
204
227
  element: 'node:area-polygon',
205
- run: gfx => createWardleyArea(gfx, 'polygon'),
228
+ run: activateWardleyAreaPolygon,
206
229
  },
207
230
  ];
208
231
  /**
@@ -239,6 +262,13 @@ const toolboxCommands = SPECS.map((spec, order) => ({
239
262
  availability: 'always',
240
263
  run: std => spec.run(std.get(GfxControllerIdentifier)),
241
264
  telemetry: { framework: 'wardley', element: spec.element, board: spec.board },
265
+ // The legend, SUBSCRIBED rather than tabulated (`docs/adr/0009`'s sibling):
266
+ // the command that draws the artefact names the row that pictures it, and
267
+ // the sheet's own command carries the box the rows are drawn in. Which
268
+ // sub-title a row files under is `category`, so the sections are the very
269
+ // groups the catalogue already shows.
270
+ ...(spec.legendRole ? { legend: wardleyLegendEntry(spec.legendRole) } : {}),
271
+ ...(spec.id === 'addBackground' ? { legendBox: WARDLEY_LEGEND_BOX } : {}),
242
272
  }));
243
273
  /**
244
274
  * The IMPORT — the first Wardley command whose subject is a whole map rather
@@ -290,30 +320,18 @@ const importCommand = {
290
320
  /**
291
321
  * The EXPORT — the other direction of the same format.
292
322
  *
293
- * It declines `'senior-menu'`, and the asymmetry with the import above is the
294
- * ruling BPMN already carries: the sub-menu is where a board COMES FROM, and an
295
- * export is what you do to a board you already have. It keeps `'catalogue'`,
296
- * which is the registry's own invariant rather than a category claim — a
297
- * command missing from the catalogue is unreachable the moment its framework
298
- * overflows the fourteen slots — plus the palette and the agent. No
299
- * `'contextual-toolbar'` either, and that is a declaration rather than an
300
- * oversight: a contextual-toolbar surface is rendered by an element's own
301
- * `ToolbarModuleConfig`, and declaring one nothing invokes would put an entry
302
- * in the manifest that no toolbar draws.
323
+ * It mirrors `bpmn.exportXml` and `c4.exportMermaid` (R5 of
324
+ * `docs/add-a-framework/02-framework-rules.md`): an import is where a board
325
+ * comes FROM, so it sits in the sub-menu; an export is what you do to a board
326
+ * you already have, so it sits in the selected map's "⋮" (`toolbar/config.ts`).
303
327
  *
304
- * `'always'` with a `when` on the BOARD, and the pair is deliberate. An export
305
- * READS: it needs no selection, and it is offered on a locked map and on a
306
- * read-only document — which is precisely the board somebody wants to take
307
- * away. What it DOES need is a plot to measure coordinates against, and that is
308
- * a fact about the surface rather than about the selection: a Wardley node has
309
- * no `visibility` prop, so its position on the plot IS its coordinate, and with
310
- * no map there is nothing to invert. `'selection'` would be BPMN's shape copied
311
- * for the look of it — that command's precondition genuinely is a selected
312
- * pool, and this one's is not.
328
+ * An export READS, so `availability: 'selection'` with a `when` on the selected
329
+ * MAP is the whole precondition: it is offered on a locked map and on a
330
+ * read-only document — precisely the map somebody wants to take away.
313
331
  *
314
- * **v1 writes one map.** An OWM document is one map; a board holding several is
315
- * written against the first in document order and the export says so out loud
316
- * in its warnings.
332
+ * **Scope: the selected map's perimeter.** The file holds that map and what it
333
+ * wholly contains ({@link wardleyExportElementsOf}). Selecting several maps
334
+ * still writes one map, and the export says so out loud in its warnings.
317
335
  */
318
336
  const exportCommand = {
319
337
  id: 'wardley.exportOwm',
@@ -322,17 +340,17 @@ const exportCommand = {
322
340
  labelKey: 'com.labre.commands.wardley.exportOwm',
323
341
  labelFallback: 'Export Wardley map (OWM)',
324
342
  descriptionKey: 'com.labre.commands.wardley.exportOwm.description',
325
- descriptionFallback: 'Download the map as an OnlineWardleyMaps .owm file, ready to open in any Wardley mapping tool.',
343
+ descriptionFallback: 'Download the selected map as an OnlineWardleyMaps .owm file — the components inside its perimeter, ready to open in any Wardley mapping tool.',
326
344
  category: 'interchange',
327
345
  iconKey: 'wardley.export-owm',
328
- surfaces: ['catalogue', 'palette', 'agent'],
346
+ surfaces: ['catalogue', 'contextual-toolbar', 'palette', 'agent'],
329
347
  order: SPECS.length + 1,
330
348
  scope: 'edgeless',
331
349
  defaultKeys: { mac: [], other: [] },
332
- availability: 'always',
350
+ availability: 'selection',
333
351
  run: exportOwmFile,
334
352
  telemetry: { framework: 'wardley', element: 'board:export-owm' },
335
- when: std => wardleyMapsOnBoard(std).length > 0,
353
+ when: std => wardleyMapsSelected(std).length > 0,
336
354
  };
337
355
  /**
338
356
  * The SVG FALLBACK import — the visual tier, named as such before the picker
@@ -439,4 +457,7 @@ export const wardleyCommandIcons = {
439
457
  'wardley.import-owm': wardleyImportOwmIcon,
440
458
  'wardley.export-owm': wardleyExportOwmIcon,
441
459
  'wardley.import-svg': wardleyImportSvgIcon,
460
+ // The senior button's 56×56 glyph, so `FrameworkDescriptor.iconKey` resolves
461
+ // through `getCommandIcon`.
462
+ 'wardley.toolbar': wardleyToolbarIcon,
442
463
  };