@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/actions.d.ts CHANGED
@@ -1,16 +1,7 @@
1
- import { type WardleyBgVariant } from '@formicoidea/labre-core/model';
2
- import type { GfxController } from '@formicoidea/labre-core/std/gfx';
3
- /**
4
- * Standalone creation/activation actions for the Wardley toolbox. They are
5
- * invoked from both the toolbar menu ({@link EdgelessWardleyMenu}) and the
6
- * wardley keyboard shortcuts, so they only depend on the {@link GfxController}.
7
- */
8
- /** Where the action was triggered from — feeds the telemetry payload. */
9
- export interface WardleyActionSource {
10
- segment: string;
11
- module: string;
12
- }
13
- export declare const WARDLEY_SHORTCUT_SOURCE: WardleyActionSource;
1
+ import { WardleyBackgroundElementModel, type WardleyBgVariant } from '@formicoidea/labre-core/model';
2
+ import type { BlockStdScope } from '@formicoidea/labre-core/std';
3
+ import { type GfxController } from '@formicoidea/labre-core/std/gfx';
4
+ import { type WardleyExportBoard } from './export.js';
14
5
  /**
15
6
  * The single-circle node flavours: one connectable ellipse + a label to its
16
7
  * right, grouped. The glyph itself (anchor silhouette, ecosystem hatching,
@@ -38,17 +29,24 @@ declare const NODE_PRESETS: {
38
29
  readonly label: "Component";
39
30
  };
40
31
  };
41
- export type WardleyNodeKind = keyof typeof NODE_PRESETS;
32
+ /**
33
+ * The subset of {@link WardleyNodeKind} that the single-circle presets above
34
+ * cover. Renamed away from `WardleyNodeKind` on purpose: the model declares a
35
+ * type of that name with SEVEN values, and two homonyms of different
36
+ * cardinality — one of them now the source of the semantic vocabulary — is a
37
+ * trap. `WARDLEY_ROLE[kind]` below only compiles while this stays a subset.
38
+ */
39
+ export type WardleySingleCircleKind = keyof typeof NODE_PRESETS;
42
40
  /** Create a wardley map background of the given variant, viewport-centered. */
43
- export declare function createWardleyBackground(gfx: GfxController, variant?: WardleyBgVariant, source?: WardleyActionSource): void;
41
+ export declare function createWardleyBackground(gfx: GfxController, variant?: WardleyBgVariant): void;
44
42
  /**
45
43
  * Create a single-circle node (component / anchor / ecosystem / method):
46
44
  * one connectable native ellipse + a label to its right, grouped so they
47
45
  * move together (enter the group to reposition / edit the label).
48
46
  */
49
- export declare function createWardleyNode(gfx: GfxController, kind: WardleyNodeKind, source?: WardleyActionSource): void;
47
+ export declare function createWardleyNode(gfx: GfxController, kind: WardleySingleCircleKind): void;
50
48
  /** Create an inertia bar (filled black rect). */
51
- export declare function createWardleyInertia(gfx: GfxController, source?: WardleyActionSource): void;
49
+ export declare function createWardleyInertia(gfx: GfxController): void;
52
50
  /**
53
51
  * Create a pipeline: a wide thin native rect body (white semi-transparent,
54
52
  * NON-connectable) + a node-sized square handle straddling its top edge (the
@@ -56,7 +54,7 @@ export declare function createWardleyInertia(gfx: GfxController, source?: Wardle
56
54
  * label are grouped, then grouped again with the body so the whole pipeline
57
55
  * moves as one. Pure composition of native elements — no custom type / view.
58
56
  */
59
- export declare function createWardleyPipeline(gfx: GfxController, source?: WardleyActionSource): void;
57
+ export declare function createWardleyPipeline(gfx: GfxController): void;
60
58
  /**
61
59
  * Create a market: a large thin-bordered circle (the connectable market node)
62
60
  * containing 3 small thick-bordered component nodes wired into a triangle by
@@ -64,11 +62,56 @@ export declare function createWardleyPipeline(gfx: GfxController, source?: Wardl
64
62
  * the node centers and follow on move/resize). A label sits to the right and
65
63
  * everything is grouped into one object.
66
64
  */
67
- export declare function createWardleyMarket(gfx: GfxController, source?: WardleyActionSource): void;
65
+ export declare function createWardleyMarket(gfx: GfxController): void;
66
+ /**
67
+ * Read an SVG the user picks as a SKETCH, and say what it cost.
68
+ *
69
+ * Wardley's first interchange command, and its whole implementation: the four
70
+ * steps live in {@link runInterchangeImportFile} and the reading lives in the
71
+ * declared capability, so this framework contributes a declaration and a
72
+ * label rather than a pipeline (`docs/adr/0012`, P1 and P3).
73
+ *
74
+ * It takes a `BlockStdScope` and not the `GfxController` the rest of this file
75
+ * runs on, because an import is not a drawing gesture: it opens a picker,
76
+ * writes a whole board in one undo step, moves the viewport and notifies —
77
+ * none of which a `GfxController` alone can do.
78
+ */
79
+ export declare function importWardleySvgFile(std: BlockStdScope): Promise<void>;
68
80
  /**
69
81
  * Activate the native connector tool, pre-styled for a Wardley link (grey,
70
82
  * solid, no arrow) or evolution arrow (red, dashed, FILLED triangle). The
71
83
  * user then draws from one node to another (endpoints attach to centers).
72
84
  */
73
- export declare function activateWardleyConnector(gfx: GfxController, kind: 'link' | 'arrow', source?: WardleyActionSource): void;
85
+ export declare function activateWardleyConnector(gfx: GfxController, kind: 'link' | 'arrow'): void;
86
+ /** The maps on the surface — what the export command is offered against. */
87
+ export declare function wardleyMapsOnBoard(std: BlockStdScope): WardleyBackgroundElementModel[];
88
+ /**
89
+ * Everything on the surface the exporter speaks about, in document order.
90
+ *
91
+ * The half that needs an editor, and only that half: reading the surface. The
92
+ * picking is {@link wardleyBoardFrom}, which the interchange capability calls
93
+ * with the same elements and no `std` at all (`docs/adr/0012`, P3).
94
+ */
95
+ export declare function wardleyBoardOf(std: BlockStdScope): WardleyExportBoard;
96
+ /**
97
+ * Serialize the board as an OWM document and hand it to the browser.
98
+ *
99
+ * Three steps, and only the first and the last know what an editor is: read the
100
+ * surface, run the DECLARED capability, download what it produced. The middle
101
+ * step is not re-implemented here — the document, the filename and the content
102
+ * type all come out of `WARDLEY_OWM_EXPORT.run`, so the command and the
103
+ * registry cannot describe the same map differently. There is one door, and the
104
+ * registry is the label on it.
105
+ */
106
+ export declare function exportOwmFile(std: BlockStdScope): void;
107
+ /**
108
+ * Read an `.owm` the user picks, draw it, and say what it cost.
109
+ *
110
+ * The whole of the import glue, and it is one line: the generic pipeline picks
111
+ * the file from the format's own declaration, runs the capability, mints the
112
+ * surface ids and repairs the link endpoints that named the file's names,
113
+ * brings the map into view and reports. Nothing in it is about Wardley except
114
+ * the capability handed to it.
115
+ */
116
+ export declare function importOwmFile(std: BlockStdScope): Promise<void>;
74
117
  export {};
package/dist/actions.js CHANGED
@@ -1,23 +1,45 @@
1
- import { DefaultTool } from '@formicoidea/labre-core/blocks/surface';
1
+ import { backgroundSize, DefaultTool, 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, FontFamily, PointStyle, ShapeStyle, StrokeStyle, TextFitMode, } from '@formicoidea/labre-core/model';
5
- import { EditPropsStore, TelemetryProvider, } from '@formicoidea/labre-core/shared/services';
4
+ import { ConnectorMode, FontFamily, PointStyle, ShapeStyle, StrokeStyle, TextFitMode, WardleyBackgroundElementModel, } from '@formicoidea/labre-core/model';
5
+ import { NotificationProvider, translateKey, } from '@formicoidea/labre-core/shared/services';
6
+ import { downloadBlob } from '@formicoidea/labre-core/shared/utils';
6
7
  import { Bound } from '@formicoidea/labre-core/global/gfx';
7
- import { REF_WIDTH } from './consts.js';
8
+ import { GfxControllerIdentifier } from '@formicoidea/labre-core/std/gfx';
9
+ import { WARDLEY_BACKGROUND } from './background.js';
10
+ import { WARDLEY_SVG_IMPORT } from './interchange.js';
11
+ import { wardleyBoardFrom, wardleySafeFilename, } from './export.js';
12
+ import { WARDLEY_OWM_EXPORT, WARDLEY_OWM_IMPORT } from './interchange.js';
8
13
  import { ECOSYSTEM_LABEL, ECOSYSTEM_SIZE, HANDLE_SIZE, INERTIA_COLOR, INERTIA_SIZE, LABEL_DEFAULT, LABEL_FONT_SIZE, LABEL_GAP, LINK_GREY, LINK_STROKE_WIDTH, MARKET_DOT_RING, MARKET_DOT_SIZE, MARKET_DOT_STROKE_WIDTH, MARKET_LABEL, MARKET_LINK_COLOR, MARKET_LINK_WIDTH, MARKET_SIZE, METHOD_FILL, METHOD_LABEL, METHOD_SIZE, NODE_FILL, NODE_SIZE, NODE_STROKE, NODE_STROKE_WIDTH, PIPELINE_FILL, PIPELINE_HEIGHT, PIPELINE_LABEL, PIPELINE_WIDTH, WARDLEY_RED, } from './node/consts.js';
9
- const TOOLBOX_SOURCE = {
10
- segment: 'wardley toolbox',
11
- module: 'wardley menu',
12
- };
13
- export const WARDLEY_SHORTCUT_SOURCE = {
14
- segment: 'wardley toolbox',
15
- module: 'keyboard shortcut',
16
- };
14
+ import { WARDLEY_ROLE } from './roles.js';
15
+ /**
16
+ * Standalone creation/activation actions for the Wardley toolbox — the
17
+ * BEHAVIOUR layer, shared by every surface. They only depend on the
18
+ * {@link GfxController}.
19
+ *
20
+ * They no longer emit telemetry: since PF3 the single emission point is the
21
+ * command registry's `runCommand` (`docs/adr/0008`), which is the only function
22
+ * every surface goes through. `WardleyActionSource` is gone with it — the
23
+ * segment/module discrimination it carried is now `CommandInvocation.surface`.
24
+ */
17
25
  /**
18
26
  * Per-variant default label overrides applied at creation (all remain editable
19
27
  * afterwards via the inline editor / toggles). The gradient itself is driven by
20
28
  * `variant` in the renderer.
29
+ *
30
+ * TODO(PF2 follow-up): these write ENGLISH PROSE into the document. Since PF2
31
+ * the ten label props default to `undefined` precisely so the declaration's
32
+ * i18n keys are reachable — but a map created as `opportunity` or `benefit`
33
+ * lands with "Opportunity" / "Benefit" / "Investment" already persisted as if
34
+ * the user had typed them, so those three are un-localisable for the life of
35
+ * the document. Only `classic` and `evolution-gradient` are fully localisable
36
+ * today.
37
+ *
38
+ * The fix is to make the variant part of the declaration — one axis/end-label
39
+ * set per variant, each naming its own `labelKey` — rather than a bag of prop
40
+ * overrides applied at creation. Out of scope here (it changes what a variant
41
+ * IS); duplicated verbatim in `templates/index.ts`, and both copies go away
42
+ * together.
21
43
  */
22
44
  const BACKGROUND_VARIANT_DEFAULTS = {
23
45
  classic: {},
@@ -55,14 +77,6 @@ const NODE_PRESETS = {
55
77
  // Method: the FILL color encodes the chosen method (editable).
56
78
  method: { d: METHOD_SIZE, fill: METHOD_FILL, label: METHOD_LABEL },
57
79
  };
58
- function track(gfx, source, event, element) {
59
- gfx.std.getOptional(TelemetryProvider)?.track(event, {
60
- framework: 'wardley',
61
- element,
62
- page: 'whiteboard editor',
63
- ...source,
64
- });
65
- }
66
80
  function finish(gfx, id) {
67
81
  gfx.doc.captureSync();
68
82
  gfx.tool.setTool(DefaultTool);
@@ -78,10 +92,18 @@ function group(gfx, ids) {
78
92
  return result.groupId || ids[0];
79
93
  }
80
94
  /** Add a native ellipse wardley node centred on (cx, cy). */
81
- function addEllipseNode(surface, kind, cx, cy, d, fillColor, strokeWidth = NODE_STROKE_WIDTH) {
95
+ function addEllipseNode(surface, kind, cx, cy, d, fillColor, strokeWidth = NODE_STROKE_WIDTH,
96
+ // The market's three inner dots are the GLYPH's own wiring, not artefacts the
97
+ // user placed — the same reason its triangle connectors carry no role. They
98
+ // sit inside the market circle by construction, so a role would make every
99
+ // market composite report an overlap with itself (W3).
100
+ neutral = false) {
82
101
  return surface.addElement({
83
102
  type: 'wardleyNode',
84
103
  kind,
104
+ // Semantic identity (PF1): posted next to `kind`, which stays untouched
105
+ // and keeps driving the rendering.
106
+ role: neutral ? undefined : WARDLEY_ROLE[kind],
85
107
  shapeType: 'ellipse',
86
108
  filled: true,
87
109
  fillColor,
@@ -97,6 +119,11 @@ function addLabel(surface, text, x, y, textAlign = 'left') {
97
119
  return surface.addElement({
98
120
  type: 'text',
99
121
  text,
122
+ // Semantic identity (PF1, revised in PF13.4): a Wardley label is a free
123
+ // text element like any other, so its ROLE is the only thing that tells W3
124
+ // it must not land on top of a node. A free text the user typed elsewhere
125
+ // stays neutral and is never evaluated.
126
+ role: WARDLEY_ROLE.label,
100
127
  fontFamily: FontFamily.Inter,
101
128
  fontSize: LABEL_FONT_SIZE,
102
129
  color: NODE_STROKE,
@@ -105,23 +132,34 @@ function addLabel(surface, text, x, y, textAlign = 'left') {
105
132
  });
106
133
  }
107
134
  /** Create a wardley map background of the given variant, viewport-centered. */
108
- export function createWardleyBackground(gfx, variant = 'classic', source = TOOLBOX_SOURCE) {
135
+ export function createWardleyBackground(gfx, variant = 'classic') {
109
136
  if (!gfx.surface)
110
137
  return;
111
- let width = REF_WIDTH;
138
+ // A second map matches the biggest one already on the board rather than
139
+ // shrinking beside it. The reference size and the locked 16:9 proportion are
140
+ // the declaration's (`geometry`), not this function's.
141
+ let atLeastWidth = 0;
142
+ let atLeastHeight = 0;
112
143
  for (const el of gfx.surface.getElementsByType('wardley')) {
113
144
  const [, , ew, eh] = el.deserializedXYWH;
114
- width = Math.max(width, ew, (eh * 16) / 9);
145
+ atLeastWidth = Math.max(atLeastWidth, ew);
146
+ atLeastHeight = Math.max(atLeastHeight, eh);
115
147
  }
116
- const height = (width * 9) / 16;
148
+ const { width, height } = backgroundSize(WARDLEY_BACKGROUND, atLeastWidth, atLeastHeight);
117
149
  const { centerX, centerY } = gfx.viewport;
118
150
  const id = gfx.surface.addElement({
119
- type: 'wardley',
151
+ type: WARDLEY_BACKGROUND.type,
152
+ // The map is a first-class role: validation rules position artefacts
153
+ // against `wardley:map`, never against the `wardley` element type. The
154
+ // declaration owns it, so a templated map and a hand-drawn one agree.
155
+ role: WARDLEY_BACKGROUND.role,
156
+ // A map is a frame you place things on, not a shape you nudge — the
157
+ // declaration decides, and the toolbar toggle takes over from there.
158
+ resizeEnabled: WARDLEY_BACKGROUND.geometry.resizable,
120
159
  variant,
121
160
  ...BACKGROUND_VARIANT_DEFAULTS[variant],
122
161
  xywh: new Bound(centerX - width / 2, centerY - height / 2, width, height).serialize(),
123
162
  });
124
- track(gfx, source, 'FrameworkElementAdded', `background:${variant}`);
125
163
  finish(gfx, id);
126
164
  }
127
165
  /**
@@ -129,7 +167,7 @@ export function createWardleyBackground(gfx, variant = 'classic', source = TOOLB
129
167
  * one connectable native ellipse + a label to its right, grouped so they
130
168
  * move together (enter the group to reposition / edit the label).
131
169
  */
132
- export function createWardleyNode(gfx, kind, source = TOOLBOX_SOURCE) {
170
+ export function createWardleyNode(gfx, kind) {
133
171
  const surface = gfx.surface;
134
172
  if (!surface)
135
173
  return;
@@ -137,11 +175,10 @@ export function createWardleyNode(gfx, kind, source = TOOLBOX_SOURCE) {
137
175
  const { centerX: cx, centerY: cy } = gfx.viewport;
138
176
  const nodeId = addEllipseNode(surface, kind, cx, cy, d, fill);
139
177
  const labelId = addLabel(surface, label, cx + d / 2 + LABEL_GAP, cy - LABEL_H / 2);
140
- track(gfx, source, 'FrameworkElementAdded', `node:${kind}`);
141
178
  finish(gfx, group(gfx, [nodeId, labelId]));
142
179
  }
143
180
  /** Create an inertia bar (filled black rect). */
144
- export function createWardleyInertia(gfx, source = TOOLBOX_SOURCE) {
181
+ export function createWardleyInertia(gfx) {
145
182
  if (!gfx.surface)
146
183
  return;
147
184
  const { w, h } = INERTIA_SIZE;
@@ -149,6 +186,9 @@ export function createWardleyInertia(gfx, source = TOOLBOX_SOURCE) {
149
186
  const id = gfx.surface.addElement({
150
187
  type: 'shape',
151
188
  shapeType: 'rect',
189
+ // The inertia bar has no element type of its own — it IS a plain filled
190
+ // rect — so the role is the whole of its semantics (PF13.5).
191
+ role: WARDLEY_ROLE.inertia,
152
192
  filled: true,
153
193
  fillColor: INERTIA_COLOR,
154
194
  strokeColor: INERTIA_COLOR,
@@ -160,7 +200,6 @@ export function createWardleyInertia(gfx, source = TOOLBOX_SOURCE) {
160
200
  textFitMode: TextFitMode.Overflow,
161
201
  xywh: new Bound(centerX - w / 2, centerY - h / 2, w, h).serialize(),
162
202
  });
163
- track(gfx, source, 'FrameworkElementAdded', 'node:inertia');
164
203
  finish(gfx, id);
165
204
  }
166
205
  /**
@@ -170,7 +209,7 @@ export function createWardleyInertia(gfx, source = TOOLBOX_SOURCE) {
170
209
  * label are grouped, then grouped again with the body so the whole pipeline
171
210
  * moves as one. Pure composition of native elements — no custom type / view.
172
211
  */
173
- export function createWardleyPipeline(gfx, source = TOOLBOX_SOURCE) {
212
+ export function createWardleyPipeline(gfx) {
174
213
  if (!gfx.surface)
175
214
  return;
176
215
  const { centerX: cx, centerY: cy } = gfx.viewport;
@@ -182,6 +221,7 @@ export function createWardleyPipeline(gfx, source = TOOLBOX_SOURCE) {
182
221
  const bodyId = gfx.surface.addElement({
183
222
  type: 'wardleyNode',
184
223
  kind: 'pipeline',
224
+ role: WARDLEY_ROLE.pipeline,
185
225
  shapeType: 'rect',
186
226
  filled: true,
187
227
  fillColor: PIPELINE_FILL,
@@ -197,6 +237,7 @@ export function createWardleyPipeline(gfx, source = TOOLBOX_SOURCE) {
197
237
  const handleId = gfx.surface.addElement({
198
238
  type: 'wardleyNode',
199
239
  kind: 'handle',
240
+ role: WARDLEY_ROLE.handle,
200
241
  shapeType: 'rect',
201
242
  filled: true,
202
243
  fillColor: NODE_FILL,
@@ -211,7 +252,6 @@ export function createWardleyPipeline(gfx, source = TOOLBOX_SOURCE) {
211
252
  const labelId = addLabel(gfx.surface, PIPELINE_LABEL, cx - 60, top - d / 2 - LABEL_H - LABEL_GAP, 'center');
212
253
  // Nested groups: (handle + label), then (body + that group).
213
254
  const innerId = group(gfx, [handleId, labelId]);
214
- track(gfx, source, 'FrameworkElementAdded', 'node:pipeline');
215
255
  finish(gfx, group(gfx, [bodyId, innerId]));
216
256
  }
217
257
  /**
@@ -221,7 +261,7 @@ export function createWardleyPipeline(gfx, source = TOOLBOX_SOURCE) {
221
261
  * the node centers and follow on move/resize). A label sits to the right and
222
262
  * everything is grouped into one object.
223
263
  */
224
- export function createWardleyMarket(gfx, source = TOOLBOX_SOURCE) {
264
+ export function createWardleyMarket(gfx) {
225
265
  const surface = gfx.surface;
226
266
  if (!surface)
227
267
  return;
@@ -237,7 +277,7 @@ export function createWardleyMarket(gfx, source = TOOLBOX_SOURCE) {
237
277
  [rho * sin60, rho / 2],
238
278
  [-rho * sin60, rho / 2],
239
279
  ];
240
- const dotIds = verts.map(([vx, vy]) => addEllipseNode(surface, 'component', cx + vx, cy + vy, MARKET_DOT_SIZE, NODE_FILL, MARKET_DOT_STROKE_WIDTH));
280
+ const dotIds = verts.map(([vx, vy]) => addEllipseNode(surface, 'component', cx + vx, cy + vy, MARKET_DOT_SIZE, NODE_FILL, MARKET_DOT_STROKE_WIDTH, true));
241
281
  // Triangle: 3 attached connectors (auto-route center-to-center, clipped).
242
282
  const connIds = [
243
283
  [dotIds[0], dotIds[1]],
@@ -255,35 +295,128 @@ export function createWardleyMarket(gfx, source = TOOLBOX_SOURCE) {
255
295
  rearEndpointStyle: PointStyle.None,
256
296
  }));
257
297
  const labelId = addLabel(surface, MARKET_LABEL, cx + R + LABEL_GAP, cy - LABEL_H / 2);
258
- track(gfx, source, 'FrameworkElementAdded', 'node:market');
259
298
  finish(gfx, group(gfx, [circleId, ...dotIds, ...connIds, labelId]));
260
299
  }
300
+ /**
301
+ * Read an SVG the user picks as a SKETCH, and say what it cost.
302
+ *
303
+ * Wardley's first interchange command, and its whole implementation: the four
304
+ * steps live in {@link runInterchangeImportFile} and the reading lives in the
305
+ * declared capability, so this framework contributes a declaration and a
306
+ * label rather than a pipeline (`docs/adr/0012`, P1 and P3).
307
+ *
308
+ * It takes a `BlockStdScope` and not the `GfxController` the rest of this file
309
+ * runs on, because an import is not a drawing gesture: it opens a picker,
310
+ * writes a whole board in one undo step, moves the viewport and notifies —
311
+ * none of which a `GfxController` alone can do.
312
+ */
313
+ export async function importWardleySvgFile(std) {
314
+ await runInterchangeImportFile(std, WARDLEY_SVG_IMPORT);
315
+ }
261
316
  /**
262
317
  * Activate the native connector tool, pre-styled for a Wardley link (grey,
263
318
  * solid, no arrow) or evolution arrow (red, dashed, FILLED triangle). The
264
319
  * user then draws from one node to another (endpoints attach to centers).
265
320
  */
266
- export function activateWardleyConnector(gfx, kind, source = TOOLBOX_SOURCE) {
267
- const props = kind === 'arrow'
268
- ? {
269
- mode: ConnectorMode.Straight,
270
- stroke: WARDLEY_RED,
271
- strokeStyle: StrokeStyle.Dash,
272
- strokeWidth: LINK_STROKE_WIDTH,
273
- frontEndpointStyle: PointStyle.None,
274
- rearEndpointStyle: PointStyle.Triangle,
275
- }
276
- : {
277
- mode: ConnectorMode.Straight,
278
- stroke: LINK_GREY,
279
- strokeStyle: StrokeStyle.Solid,
280
- strokeWidth: LINK_STROKE_WIDTH,
281
- frontEndpointStyle: PointStyle.None,
282
- rearEndpointStyle: PointStyle.None,
283
- };
284
- gfx.std.get(EditPropsStore).recordLastProps('connector', props);
285
- track(gfx, source, 'FrameworkToolPicked', `connector:${kind}`);
286
- gfx.tool.setTool(ConnectorTool, { mode: ConnectorMode.Straight });
321
+ export function activateWardleyConnector(gfx, kind) {
322
+ gfx.tool.setTool(ConnectorTool, {
323
+ mode: ConnectorMode.Straight,
324
+ // The value-chain link IS the "depends on" edge of a Wardley map; the
325
+ // change arrow is a movement annotation and gets a role OF ITS OWN
326
+ // (PF13.4, reversing #71 — see `roles.ts`). Two roles, never one
327
+ // specialising the other: W1 is about where an arrow points and must never
328
+ // fall on a dependency.
329
+ role: kind === 'link' ? WARDLEY_ROLE.dependency : WARDLEY_ROLE.changeArrow,
330
+ // The look rides on the activation, never through the last-props store:
331
+ // the plain connector tool must keep the user's own style (#144 M1).
332
+ style: kind === 'arrow'
333
+ ? {
334
+ stroke: WARDLEY_RED,
335
+ strokeStyle: StrokeStyle.Dash,
336
+ strokeWidth: LINK_STROKE_WIDTH,
337
+ frontEndpointStyle: PointStyle.None,
338
+ rearEndpointStyle: PointStyle.Triangle,
339
+ }
340
+ : {
341
+ stroke: LINK_GREY,
342
+ strokeStyle: StrokeStyle.Solid,
343
+ strokeWidth: LINK_STROKE_WIDTH,
344
+ frontEndpointStyle: PointStyle.None,
345
+ rearEndpointStyle: PointStyle.None,
346
+ },
347
+ });
287
348
  // The wardley palette stays open (native sub-menu behaviour): it only
288
349
  // closes on re-click of the senior button, another senior tool, or Escape.
289
350
  }
351
+ /* ── Interchange: the OWM DSL, out and in (`docs/adr/0012`) ───────────── */
352
+ /**
353
+ * The one wording this file owns: what a WRITER could not say.
354
+ *
355
+ * The import's wordings live in the pipeline that writes them
356
+ * (`affine-block-surface`, `extensions/interchange-import.ts`) — one set of
357
+ * keys for every format, with the format's own name composed into them, so
358
+ * "OWM file imported" needs no key of its own. An export's losses are the
359
+ * capability's own sentences and have nowhere else to go.
360
+ */
361
+ const EXPORT_WARNINGS_KEY = 'com.labre.commands.wardley.exportOwm.warnings';
362
+ const EXPORT_WARNINGS_FALLBACK = 'What this export could not write down';
363
+ /** The maps on the surface — what the export command is offered against. */
364
+ export function wardleyMapsOnBoard(std) {
365
+ const surface = std.get(GfxControllerIdentifier).surface;
366
+ return (surface?.elementModels ?? []).filter((model) => model instanceof WardleyBackgroundElementModel);
367
+ }
368
+ /**
369
+ * Everything on the surface the exporter speaks about, in document order.
370
+ *
371
+ * The half that needs an editor, and only that half: reading the surface. The
372
+ * picking is {@link wardleyBoardFrom}, which the interchange capability calls
373
+ * with the same elements and no `std` at all (`docs/adr/0012`, P3).
374
+ */
375
+ export function wardleyBoardOf(std) {
376
+ return wardleyBoardFrom(std.get(GfxControllerIdentifier).surface?.elementModels ?? []);
377
+ }
378
+ /**
379
+ * Serialize the board as an OWM document and hand it to the browser.
380
+ *
381
+ * Three steps, and only the first and the last know what an editor is: read the
382
+ * surface, run the DECLARED capability, download what it produced. The middle
383
+ * step is not re-implemented here — the document, the filename and the content
384
+ * type all come out of `WARDLEY_OWM_EXPORT.run`, so the command and the
385
+ * registry cannot describe the same map differently. There is one door, and the
386
+ * registry is the label on it.
387
+ */
388
+ export function exportOwmFile(std) {
389
+ const elements = std.get(GfxControllerIdentifier).surface?.elementModels ?? [];
390
+ const title = std.store.workspace.meta.getDocMeta(std.store.id)?.title;
391
+ const { text, filename, mime, warnings } = WARDLEY_OWM_EXPORT.run(elements, {
392
+ name: wardleySafeFilename(title),
393
+ });
394
+ // The charset is the browser's business, not the format's: `mime` is what an
395
+ // `.owm` IS, and this is how a blob is told to carry it.
396
+ downloadBlob(new Blob([text], { type: `${mime};charset=utf-8` }), filename);
397
+ // A warning is never an error: the file downloaded, and it is valid; what it
398
+ // could not say is what this names. A board with two maps on it, a component
399
+ // with no name, an evolution arrow that also climbs the value chain — each is
400
+ // a sentence the format has no way to write down, and the person who clicked
401
+ // Export is the one entitled to hear about it.
402
+ if (!warnings || warnings.length === 0)
403
+ return;
404
+ std.getOptional(NotificationProvider)?.notify({
405
+ title: translateKey(std, EXPORT_WARNINGS_KEY, EXPORT_WARNINGS_FALLBACK),
406
+ message: warnings.join('\n'),
407
+ accent: 'warning',
408
+ duration: 8000,
409
+ });
410
+ }
411
+ /**
412
+ * Read an `.owm` the user picks, draw it, and say what it cost.
413
+ *
414
+ * The whole of the import glue, and it is one line: the generic pipeline picks
415
+ * the file from the format's own declaration, runs the capability, mints the
416
+ * surface ids and repairs the link endpoints that named the file's names,
417
+ * brings the map into view and reports. Nothing in it is about Wardley except
418
+ * the capability handed to it.
419
+ */
420
+ export async function importOwmFile(std) {
421
+ await runInterchangeImportFile(std, WARDLEY_OWM_IMPORT);
422
+ }
@@ -0,0 +1,31 @@
1
+ import type { AuditCriterion } from '@formicoidea/labre-core/shared/services';
2
+ /**
3
+ * The three Wardley audit criteria, A1–A3, as DATA (PF14.1).
4
+ *
5
+ * ## Why these three are criteria and not rules
6
+ *
7
+ * A validation rule is decided by geometry: an element is inside a frame or it
8
+ * is not, an arrow runs with an axis or against it. The engine can settle it in
9
+ * microseconds and be right every time, so it does — that is levels 1 and 2.
10
+ *
11
+ * These three cannot be settled that way, and no amount of engineering will
12
+ * change that. "Is this component positioned for the right reason" is a
13
+ * question about the author's argument; "does this map do its job" is a question
14
+ * about a conversation. They are the questions a Wardley coach asks in a review,
15
+ * and they are exactly what an assistant with the map's facts in hand can have
16
+ * an opinion about — an OPINION, hence `audit` severity, hence invisible on the
17
+ * canvas and never a wall.
18
+ *
19
+ * They are versioned like a rule and shipped like one, because the day a
20
+ * criterion's wording changes is a day the answers before and after are no
21
+ * longer comparable, and a host pinning behaviour needs to be able to say so.
22
+ *
23
+ * ## The prompts
24
+ *
25
+ * The prompt is the criterion, in words, addressed to the assistant and never
26
+ * rendered — see {@link AuditCriterion.prompt}. They are written as review
27
+ * instructions: what to look at, and what would make the map fail. They name no
28
+ * model, no vendor and no output format: the assistant owns how it answers, the
29
+ * library owns what is asked.
30
+ */
31
+ export declare const WARDLEY_AUDIT_CRITERIA: readonly AuditCriterion[];
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The three Wardley audit criteria, A1–A3, as DATA (PF14.1).
3
+ *
4
+ * ## Why these three are criteria and not rules
5
+ *
6
+ * A validation rule is decided by geometry: an element is inside a frame or it
7
+ * is not, an arrow runs with an axis or against it. The engine can settle it in
8
+ * microseconds and be right every time, so it does — that is levels 1 and 2.
9
+ *
10
+ * These three cannot be settled that way, and no amount of engineering will
11
+ * change that. "Is this component positioned for the right reason" is a
12
+ * question about the author's argument; "does this map do its job" is a question
13
+ * about a conversation. They are the questions a Wardley coach asks in a review,
14
+ * and they are exactly what an assistant with the map's facts in hand can have
15
+ * an opinion about — an OPINION, hence `audit` severity, hence invisible on the
16
+ * canvas and never a wall.
17
+ *
18
+ * They are versioned like a rule and shipped like one, because the day a
19
+ * criterion's wording changes is a day the answers before and after are no
20
+ * longer comparable, and a host pinning behaviour needs to be able to say so.
21
+ *
22
+ * ## The prompts
23
+ *
24
+ * The prompt is the criterion, in words, addressed to the assistant and never
25
+ * rendered — see {@link AuditCriterion.prompt}. They are written as review
26
+ * instructions: what to look at, and what would make the map fail. They name no
27
+ * model, no vendor and no output format: the assistant owns how it answers, the
28
+ * library owns what is asked.
29
+ */
30
+ export const WARDLEY_AUDIT_CRITERIA = [
31
+ {
32
+ id: 'wardley.A1',
33
+ framework: 'wardley',
34
+ labelKey: 'com.labre.wardley.audit.A1',
35
+ fallback: 'Positioning is justified',
36
+ version: 1,
37
+ prompt: [
38
+ 'Assess whether each component is placed on the evolution axis for the',
39
+ 'right reason. Evolution measures how CONTEXTUALLY UBIQUITOUS and',
40
+ 'well-understood a component is to the users of THIS value chain — not',
41
+ 'how modern, sophisticated or technically mature the technology behind',
42
+ 'it is. A component built on cutting-edge technology can be a commodity',
43
+ 'to its users; a component built on decades-old technology can still be',
44
+ 'in genesis for this organisation. Flag components whose position looks',
45
+ 'argued from technical maturity, vendor age, or how recently the team',
46
+ 'adopted them, rather than from how their users perceive and consume',
47
+ 'them. Use the zone each component sits in, the roles it carries and the',
48
+ 'components it depends on. Say which component, in which zone, and what',
49
+ 'about its position does not follow from the value chain around it.',
50
+ ].join(' '),
51
+ },
52
+ {
53
+ id: 'wardley.A2',
54
+ framework: 'wardley',
55
+ labelKey: 'com.labre.wardley.audit.A2',
56
+ fallback: 'The value chain is legible',
57
+ version: 1,
58
+ prompt: [
59
+ 'Assess whether the value chain can actually be read. The map exists to',
60
+ 'be discussed, agreed on and decided from, by people who did not draw',
61
+ 'it. Follow the dependency edges from the anchor (the user and their',
62
+ 'need) down through the chain: does every component have a reason to be',
63
+ 'there that the chain itself states? Look for anchors that are missing or',
64
+ 'that name a system rather than a user need, components that hang off no',
65
+ 'chain, chains that fork into detail nobody would decide from, and',
66
+ 'dependencies whose direction says the opposite of what the layout',
67
+ 'implies. Say what a reader would fail to understand, and where.',
68
+ ].join(' '),
69
+ },
70
+ {
71
+ id: 'wardley.A3',
72
+ framework: 'wardley',
73
+ labelKey: 'com.labre.wardley.audit.A3',
74
+ fallback: 'The model applies here',
75
+ version: 1,
76
+ prompt: [
77
+ 'Assess whether Wardley mapping is the right instrument for what this map',
78
+ 'describes. The model assumes a COMPETITIVE landscape: evolution is',
79
+ 'driven by supply and demand competition, and the strategic play is',
80
+ 'against other actors who could serve the same need. It says little about',
81
+ 'a domain with no competitive pressure — an internal process nobody else',
82
+ 'could supply, a regulatory obligation with a single possible',
83
+ 'implementation, an organisational chart. Judge from the anchor, the user',
84
+ 'need it names, and the nature of the components in the chain. If the',
85
+ 'subject is not a competitive landscape, say so plainly and say which',
86
+ 'other instrument would fit — that is more useful than three careful',
87
+ 'observations about a map that should not exist.',
88
+ ].join(' '),
89
+ },
90
+ ];
@@ -0,0 +1,14 @@
1
+ import type { FrameworkBackgroundDef } from '@formicoidea/labre-core/blocks/surface';
2
+ export declare const WARDLEY_BACKGROUND: FrameworkBackgroundDef;
3
+ /**
4
+ * The props the in-place label editor is allowed to write.
5
+ *
6
+ * A closed list, not `string`: the hit test reports whatever `prop` a
7
+ * declaration names, and since #73 an element preserves keys it does not
8
+ * declare — so a typo in the declaration would happily persist a junk key onto
9
+ * every map it was double-clicked on. This is the gate between "the
10
+ * declaration says so" and "the document gets it".
11
+ */
12
+ export declare const WARDLEY_LABEL_PROPS: readonly ["xAxisTitle", "yAxisTitle", "evolutionStart", "evolutionEnd", "visibilityHigh", "visibilityLow", "phase0", "phase1", "phase2", "phase3"];
13
+ export type WardleyLabelProp = (typeof WARDLEY_LABEL_PROPS)[number];
14
+ export declare function isWardleyLabelProp(prop: string): prop is WardleyLabelProp;