@formicoidea/labre-framework-wardley 0.41.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, wardleyNodeLabelKey, 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
@@ -80,10 +81,6 @@ function backgroundVariantDefaults(gfx) {
80
81
  'evolution-gradient': {},
81
82
  };
82
83
  }
83
- // The label box, now owned by `presets.ts` — the local names stay so the four
84
- // placements below keep reading as they did.
85
- const LABEL_H = WARDLEY_LABEL_H;
86
- const LABEL_W = WARDLEY_LABEL_W;
87
84
  function finish(gfx, id) {
88
85
  gfx.doc.captureSync();
89
86
  gfx.tool.setTool(DefaultTool);
@@ -160,15 +157,15 @@ export function createWardleyNode(gfx, kind) {
160
157
  const surface = gfx.surface;
161
158
  if (!surface)
162
159
  return;
163
- const { w } = WARDLEY_NODE_SIZE[kind];
164
160
  const { centerX: cx, centerY: cy } = gfx.viewport;
165
161
  const nodeId = addNode(surface, kind, cx, cy);
162
+ const { x, y, textAlign } = wardleyLabelBoxFor(kind, cx, cy);
166
163
  const labelId = addLabel(surface,
167
164
  // Resolved HERE, once — the prompt a node nobody has named still carries,
168
165
  // exactly like a BPMN node's caption. `gfx.std` is how this file reaches
169
166
  // the translation seam: these creation sites take `GfxController`, not
170
167
  // `BlockStdScope`, directly (`WardleyView` reads the same member).
171
- translateKey(gfx.std, wardleyNodeLabelKey(kind), WARDLEY_NODE_LABEL[kind]), cx + w / 2 + LABEL_GAP, cy - LABEL_H / 2);
168
+ translateKey(gfx.std, wardleyNodeLabelKey(kind), WARDLEY_NODE_LABEL[kind]), x, y, textAlign);
172
169
  finish(gfx, group(gfx, [nodeId, labelId]));
173
170
  }
174
171
  /** Create an inertia bar (filled black rect). */
@@ -193,15 +190,14 @@ export function createWardleyPipeline(gfx) {
193
190
  if (!gfx.surface)
194
191
  return;
195
192
  const { centerX: cx, centerY: cy } = gfx.viewport;
196
- const d = HANDLE_SIZE;
197
- const top = cy - WARDLEY_NODE_SIZE.pipeline.h / 2;
198
193
  // Body: a WardleyNode rect, made non-connectable by `kind: 'pipeline'`.
199
194
  const bodyId = addNode(gfx.surface, 'pipeline', cx, cy);
200
195
  // Handle: a node-sized WardleyNode square straddling the top edge. Inherits
201
196
  // `centerAnchorOnly` so connectors attach to its center only.
202
197
  const handleId = gfx.surface.addElement(wardleyHandleProps({ xywh: wardleyHandleBox(cx, cy) }));
203
198
  // Label centered horizontally on the pipeline, sitting ABOVE the handle.
204
- const labelId = addLabel(gfx.surface, translateKey(gfx.std, wardleyNodeLabelKey('pipeline'), 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);
205
201
  // Nested groups: (handle + label), then (body + that group).
206
202
  const innerId = group(gfx, [handleId, labelId]);
207
203
  finish(gfx, group(gfx, [bodyId, innerId]));
@@ -218,14 +214,14 @@ export function createWardleyMarket(gfx) {
218
214
  if (!surface)
219
215
  return;
220
216
  const { centerX: cx, centerY: cy } = gfx.viewport;
221
- const R = WARDLEY_NODE_SIZE.market.w / 2;
222
217
  // Outer circle = the market node (connectable, center-only).
223
218
  const circleId = addNode(surface, 'market', cx, cy);
224
219
  // 3 inner component nodes (thick border, no label) at the triangle vertices.
225
220
  const dotIds = wardleyMarketDotBoxes(cx, cy).map(xywh => surface.addElement(wardleyMarketDotProps({ xywh })));
226
221
  // Triangle: 3 attached connectors (auto-route center-to-center, clipped).
227
222
  const connIds = wardleyMarketLinkPairs(dotIds).map(([a, b]) => surface.addElement(wardleyMarketLinkProps(a, b)));
228
- const labelId = addLabel(surface, translateKey(gfx.std, wardleyNodeLabelKey('market'), 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);
229
225
  finish(gfx, group(gfx, [circleId, ...dotIds, ...connIds, labelId]));
230
226
  }
231
227
  /**
@@ -282,14 +278,12 @@ export function createWardleyAccelerator(gfx, kind) {
282
278
  const surface = gfx.surface;
283
279
  if (!surface)
284
280
  return;
285
- const { w } = WARDLEY_NODE_SIZE[kind];
286
281
  const { centerX: cx, centerY: cy } = gfx.viewport;
287
282
  const nodeId = addNode(surface, kind, cx, cy);
288
- const rightwards = kind === 'accelerator';
289
- const labelId = addLabel(surface, translateKey(gfx.std, wardleyNodeLabelKey(kind), WARDLEY_NODE_LABEL[kind]),
290
- // A label box is a fixed LABEL_W wide whatever it reads, so a right-aligned
291
- // one has to start a box-width before the edge the words must end on.
292
- 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);
293
287
  finish(gfx, group(gfx, [nodeId, labelId]));
294
288
  }
295
289
  /**
@@ -321,6 +315,40 @@ export function createWardleyArea(gfx, shape) {
321
315
  lowerWardleyArea(gfx, id);
322
316
  finish(gfx, id);
323
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
+ }
324
352
  /**
325
353
  * Send one already-placed area to the depth it belongs at — see
326
354
  * {@link createWardleyArea} for why a zone is lowered at all.
@@ -391,22 +419,10 @@ export function activateWardleyConnector(gfx, kind) {
391
419
  // fall on a dependency.
392
420
  role: kind === 'link' ? WARDLEY_ROLE.dependency : WARDLEY_ROLE.changeArrow,
393
421
  // The look rides on the activation, never through the last-props store:
394
- // the plain connector tool must keep the user's own style (#144 M1).
395
- style: kind === 'arrow'
396
- ? {
397
- stroke: WARDLEY_RED,
398
- strokeStyle: StrokeStyle.Dash,
399
- strokeWidth: LINK_STROKE_WIDTH,
400
- frontEndpointStyle: PointStyle.None,
401
- rearEndpointStyle: PointStyle.Triangle,
402
- }
403
- : {
404
- stroke: LINK_GREY,
405
- strokeStyle: StrokeStyle.Solid,
406
- strokeWidth: LINK_STROKE_WIDTH,
407
- frontEndpointStyle: PointStyle.None,
408
- rearEndpointStyle: PointStyle.None,
409
- },
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] },
410
426
  });
411
427
  // The wardley palette stays open (native sub-menu behaviour): it only
412
428
  // closes on re-click of the senior button, another senior tool, or Escape.
@@ -423,33 +439,74 @@ export function activateWardleyConnector(gfx, kind) {
423
439
  */
424
440
  const EXPORT_WARNINGS_KEY = 'com.labre.commands.wardley.exportOwm.warnings';
425
441
  const EXPORT_WARNINGS_FALLBACK = 'What this export could not write down';
426
- /** The maps on the surface — what the export command is offered against. */
427
- export function wardleyMapsOnBoard(std) {
428
- const surface = std.get(GfxControllerIdentifier).surface;
429
- 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);
430
453
  }
431
454
  /**
432
- * 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.
433
457
  *
434
- * The half that needs an editor, and only that half: reading the surface. The
435
- * picking is {@link wardleyBoardFrom}, which the interchange capability calls
436
- * 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).
437
492
  */
438
493
  export function wardleyBoardOf(std) {
439
- return wardleyBoardFrom(std.get(GfxControllerIdentifier).surface?.elementModels ?? []);
494
+ return wardleyBoardFrom(wardleyExportElementsOf(std));
440
495
  }
441
496
  /**
442
- * 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.
443
498
  *
444
499
  * Three steps, and only the first and the last know what an editor is: read the
445
- * surface, run the DECLARED capability, download what it produced. The middle
446
- * step is not re-implemented here — the document, the filename and the content
447
- * type all come out of `WARDLEY_OWM_EXPORT.run`, so the command and the
448
- * registry cannot describe the same map differently. There is one door, and the
449
- * 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.
450
507
  */
451
508
  export function exportOwmFile(std) {
452
- const elements = std.get(GfxControllerIdentifier).surface?.elementModels ?? [];
509
+ const elements = wardleyExportElementsOf(std);
453
510
  const title = std.store.workspace.meta.getDocMeta(std.store.id)?.title;
454
511
  const { text, filename, mime, warnings } = WARDLEY_OWM_EXPORT.run(elements, {
455
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
  };
@@ -1,42 +1,28 @@
1
+ import type { EditableBackgroundLabel, FrameworkBackgroundDef } from '@formicoidea/labre-core/blocks/surface';
2
+ import { DeclaredBackgroundView } from '@formicoidea/labre-core/blocks/surface';
1
3
  import type { WardleyBackgroundElementModel } from '@formicoidea/labre-core/model';
2
- import type { EditorHost } from '@formicoidea/labre-core/std';
3
- import type { PointTestOptions } from '@formicoidea/labre-core/std/gfx';
4
- import { GfxElementModelView } from '@formicoidea/labre-core/std/gfx';
5
- export declare class WardleyView extends GfxElementModelView<WardleyBackgroundElementModel> {
4
+ /**
5
+ * The Wardley map's view: the shared background gesture, aimed by the
6
+ * `WARDLEY_BACKGROUND` declaration.
7
+ *
8
+ * The in-place rename shipped here first and has moved to
9
+ * `DeclaredBackgroundView` (`@labre/affine-block-surface`) so that every
10
+ * framework background gets it rather than the two that got round to copying
11
+ * it — see issue #355. Nothing about the map's behaviour changed: the hit test
12
+ * reads the same declaration, through the same catalogue, and the guard below
13
+ * is the one thing this framework adds to it.
14
+ */
15
+ export declare class WardleyView extends DeclaredBackgroundView<WardleyBackgroundElementModel> {
6
16
  static type: string;
7
- /** The in-place `<input>` used to edit a label, or null when idle. */
8
- private _labelEditor;
9
- onCreated(): void;
10
- onDestroyed(): void;
17
+ protected get def(): FrameworkBackgroundDef;
11
18
  /**
12
- * The editable label under a MODEL-space point, or null.
19
+ * The declaration names the prop; this decides whether it may be WRITTEN.
13
20
  *
14
- * Which labels exist, where they sit, what they SAY and which are editable
15
- * all come from the declaration the renderer paints — one source, resolved
16
- * through the same catalogue, so a label can never be drawn in one place
17
- * and clicked in another, nor read one thing and open on another.
18
- */
19
- private _labelAt;
20
- /**
21
- * The map is SELECTED by its border (`WardleyBackgroundElementModel`), but
22
- * its axis labels must still receive the double-click that renames them.
23
- *
24
- * So the two areas differ, and this is where they are allowed to: the pointer
25
- * router asks the VIEW (`GfxViewEventManager`), and the view adds the zones
26
- * the declaration draws its labels in. Picking is unaffected —
27
- * `getElementByPoint` still asks the model, so a click in the middle of the
28
- * map still goes to whatever the user put there.
29
- */
30
- includesPoint(x: number, y: number, options: PointTestOptions, host: EditorHost): boolean;
31
- /** Double-click on a label → edit its text in place. */
32
- private _onDblClick;
33
- /**
34
- * @param current the words currently DRAWN — which is the vocabulary, not
35
- * `model[field]`, for a label the user has never renamed. Opening on the raw
36
- * prop would show an empty box for a label that plainly reads "Evolution".
21
+ * A closed list, because since #73 an element preserves keys it does not
22
+ * declare: a typo in the declaration would otherwise persist a junk key onto
23
+ * every map it was double-clicked on.
37
24
  */
38
- private _openLabelEditor;
39
- private _closeLabelEditor;
25
+ protected labelAt(lx: number, ly: number, w: number, h: number): EditableBackgroundLabel | null;
40
26
  }
41
27
  /**
42
28
  * Resize gating, from the primitive: the handles stay hidden until