@formicoidea/labre-framework-wardley 0.31.0 → 0.32.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 (46) hide show
  1. package/dist/actions.d.ts +14 -18
  2. package/dist/actions.js +79 -38
  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 +5 -0
  8. package/dist/commands.js +190 -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/gradient.d.ts +6 -11
  18. package/dist/gradient.js +58 -47
  19. package/dist/index.d.ts +9 -1
  20. package/dist/index.js +9 -1
  21. package/dist/legend.js +8 -0
  22. package/dist/natures.d.ts +50 -0
  23. package/dist/natures.js +93 -0
  24. package/dist/nudges.d.ts +41 -0
  25. package/dist/nudges.js +69 -0
  26. package/dist/profiles.d.ts +2 -0
  27. package/dist/profiles.js +87 -0
  28. package/dist/reading.d.ts +3 -0
  29. package/dist/reading.js +129 -0
  30. package/dist/roles.d.ts +50 -0
  31. package/dist/roles.js +132 -0
  32. package/dist/rules.d.ts +2 -0
  33. package/dist/rules.js +270 -0
  34. package/dist/templates/index.js +51 -7
  35. package/dist/templates/maps.js +121 -17
  36. package/dist/toolbar/wardley-menu.d.ts +8 -15
  37. package/dist/toolbar/wardley-menu.js +8 -136
  38. package/dist/translations.d.ts +16 -0
  39. package/dist/translations.js +24 -0
  40. package/dist/view.d.ts +17 -0
  41. package/dist/view.js +116 -13
  42. package/package.json +2 -2
  43. package/dist/label-layout.d.ts +0 -20
  44. package/dist/label-layout.js +0 -72
  45. package/dist/shortcuts.d.ts +0 -2
  46. package/dist/shortcuts.js +0 -37
package/dist/rules.js ADDED
@@ -0,0 +1,270 @@
1
+ import { WARDLEY_BACKGROUND } from './background.js';
2
+ import { WARDLEY_ROLE, WARDLEY_ROLES } from './roles.js';
3
+ /**
4
+ * Wardley validation rules (PF13.4 / PF13.5 / PF13.6).
5
+ *
6
+ * DATA owned by the framework, versioned per rule: the engine
7
+ * (`@labre/affine-block-surface`) knows how to evaluate a FAMILY, never a
8
+ * concrete rule. Adding a Wardley rule is adding an entry to this array.
9
+ *
10
+ * Registered from the flag-gated `WardleyViewExtension`, so switching the
11
+ * Wardley flag off removes the rules with the rest of the tooling — already
12
+ * drawn maps keep rendering, they simply stop being checked (see
13
+ * `docs/adr/0009`).
14
+ *
15
+ * ## The pilot rule is gone
16
+ *
17
+ * `wardley.component-outside-map` was the tracer bullet's rule: it existed to
18
+ * prove the engine, the affordance, the exceptions and the profiles could work
19
+ * end to end, never because a Wardley practitioner asked for it. Parking a node
20
+ * in the margin while you think is normal work, and PO decision of 01/08/2026
21
+ * removed it the moment there were real rules for the machinery to carry.
22
+ * Nothing was left behind: every test that exercised the pipework through it
23
+ * now exercises it through W1, W2 or W3.
24
+ *
25
+ * ## Severity, and a hole that is named rather than papered over
26
+ *
27
+ * W1 and W2 are the two the architect would put at `blocking-overridable`. They
28
+ * are declared `warning`, because NOTHING downstream implements a blocking
29
+ * level: no gesture is refused anywhere in this library, so shipping the value
30
+ * would be data claiming an effect that does not exist. The severity moves to
31
+ * `blocking-overridable` in the profiles, in one line each, the day the gesture
32
+ * refusal lands. Building it was explicitly out of this slice.
33
+ */
34
+ /**
35
+ * **W1** — a change arrow may not point against the sense of evolution.
36
+ *
37
+ * The whole grammar of a Wardley map is that things move left to right: a red
38
+ * arrow pointing back towards Genesis says the opposite of what the map means.
39
+ * Written on `wardley:change-arrow`, the role PF13.4 puts on the arrow at
40
+ * creation — value-chain links are a different role and are never touched.
41
+ *
42
+ * The 30° dead zone is deliberate. An arrow drawn straight up a value chain is
43
+ * neither with evolution nor against it, and one drawn slightly up-and-back is
44
+ * a hand that slipped, not a claim about the world. Only an arrow genuinely
45
+ * heading back — more than 120° off the axis' forward sense — is one.
46
+ */
47
+ const changeArrowAgainstEvolution = {
48
+ id: 'wardley.change-arrow-against-evolution',
49
+ framework: 'wardley',
50
+ family: 'orientation-against-axis',
51
+ severity: 'warning',
52
+ appliesTo: WARDLEY_ROLE.changeArrow,
53
+ roles: WARDLEY_ROLES,
54
+ messageKey: 'com.labre.wardley.validation.change-arrow-against-evolution',
55
+ messageFallback: 'This change arrow points against evolution.',
56
+ suggestionKey: 'com.labre.wardley.validation.change-arrow-against-evolution.suggestion',
57
+ suggestionFallback: 'Evolution runs left to right — turn the arrow towards the commodity end, or draw a dependency instead.',
58
+ version: 1,
59
+ backgroundRole: WARDLEY_ROLE.map,
60
+ // The frame's own declaration, carried as data exactly like `roles` is: it is
61
+ // where "which way does evolution run" is written, and the engine reads it
62
+ // rather than knowing anything about Wardley.
63
+ background: WARDLEY_BACKGROUND,
64
+ against: { axis: 'evolution', toleranceDeg: 30 },
65
+ };
66
+ /**
67
+ * **W2** — the inertia bar straddles a phase transition.
68
+ *
69
+ * > "The horizontal position of an inertia bar is only valid if it is ASTRIDE
70
+ * > two evolution phases, that is, superimposed on a dashed vertical axis."
71
+ * > — the PO, spelling the rule out on the recette of 02/08/2026
72
+ *
73
+ * Inertia is resistance to crossing a frontier. The bar is the frontier being
74
+ * refused, so it is drawn ON the divider; a bar parked in the middle of a phase
75
+ * marks nothing at all and becomes a black rectangle.
76
+ *
77
+ * ## What this rule used to ask, and no longer does
78
+ *
79
+ * Until this version it ALSO demanded a dependency under the bar, and reported
80
+ * that half first ("This inertia bar is not drawn on a dependency."). That was
81
+ * our reading of inertia, not the PO's rule, and it was wrong in both
82
+ * directions: a bar alone on a divider — a perfectly ordinary way to say "this
83
+ * whole column is stuck" — was flagged, while nothing in the sentence pointed at
84
+ * the position that actually decides the verdict. The carrier condition is gone
85
+ * entirely, with the second message that existed only to tell the two halves
86
+ * apart. One condition, one sentence.
87
+ *
88
+ * ## "Astride", as geometry
89
+ *
90
+ * The engine takes the bar's own horizontal EXTENT and asks whether it
91
+ * intersects the transition band — the divider widened by the map's declared
92
+ * `transitionBandWidth` (see {@link AttachmentDef.boundaryAxis}). That single
93
+ * overlap test says both halves of "superimposed on the axis": a bar wide enough
94
+ * to cover the divider genuinely has the line running through it, and a bar too
95
+ * thin to cover anything (the toolbox draws it eight units wide) is accepted
96
+ * inside the band the map itself declares around the frontier — Wardley's zone
97
+ * of punctuated equilibrium, where inertia lives.
98
+ *
99
+ * Measured on the extent rather than on the centre because "superimposed on the
100
+ * line" is a statement about ink; measured against a band declared as a RATIO of
101
+ * the plot because the same gesture must get the same verdict on a map somebody
102
+ * resized (the lesson of the 01/08/2026 recette).
103
+ */
104
+ const inertiaOffTransition = {
105
+ id: 'wardley.inertia-off-transition',
106
+ framework: 'wardley',
107
+ family: 'attachment',
108
+ severity: 'warning',
109
+ appliesTo: WARDLEY_ROLE.inertia,
110
+ roles: WARDLEY_ROLES,
111
+ // ONE sentence, because there is now one condition. The key is the rule's own
112
+ // id: the two keys it replaces named halves of a rule that no longer has any.
113
+ messageKey: 'com.labre.wardley.validation.inertia-off-transition',
114
+ messageFallback: 'This inertia bar sits inside a phase, not astride a phase transition.',
115
+ suggestionKey: 'com.labre.wardley.validation.inertia-off-transition.suggestion',
116
+ suggestionFallback: 'Inertia bites at a frontier — slide the bar sideways until it sits astride the dashed line between two evolution phases.',
117
+ // 3: the carrier condition is gone and the position is judged on the bar's
118
+ // extent — a different verdict on the same map, so a new version.
119
+ version: 3,
120
+ backgroundRole: WARDLEY_ROLE.map,
121
+ background: WARDLEY_BACKGROUND,
122
+ attachment: {
123
+ boundaryAxis: 'evolution',
124
+ },
125
+ };
126
+ /**
127
+ * **W3** — nodes and labels must not sit on top of each other.
128
+ *
129
+ * A readability rule, not a semantic one, which is why it is the softest of the
130
+ * three: a map you cannot read is useless, but a momentary overlap while you
131
+ * drag things into place is not a mistake.
132
+ *
133
+ * The four declared combinations are the ones that actually make a map
134
+ * illegible. Node/node hides an artefact outright; label/label and label/node
135
+ * make a name unreadable or attach it to the wrong thing; label/link is the one
136
+ * everybody hits, a name crossed out by the dependency running under it.
137
+ *
138
+ * Link/link is deliberately ABSENT: dependencies cross all the time on a real
139
+ * value chain, and that is the map working, not the map broken.
140
+ *
141
+ * ## Calibration (PO acceptance, 01/08/2026)
142
+ *
143
+ * Shipped, the rule was noisy in two ways the corpus could not see, because its
144
+ * fixtures were boxes rather than words:
145
+ *
146
+ * 1. a link crossing the EMPTY MARGIN of a label raised label/link. A label is
147
+ * created 120–200 units wide whatever it says, so a short name left-aligned
148
+ * in one leaves most of its box blank. Fixed where the mistake was: the
149
+ * label role is `kind: 'text'`, so the engine measures the ink and not the
150
+ * box (`textInkBound`).
151
+ * 2. two labels whose WORDS are nowhere near each other raised label/label,
152
+ * for the same reason and with the same fix.
153
+ *
154
+ * `minPenetration` is what remains: not every touch is a collision. **4 model
155
+ * units** — under a quarter of the 18-unit node, a sixth of the 26-unit label
156
+ * line, and about the width of a dependency's own stroke. A link grazing the
157
+ * top of a name and two names whose last and first letter share a hair are
158
+ * silent; a link through the middle of a name scores half its line height (13)
159
+ * and a name written across a node scores the height of the letters, so both
160
+ * are still reported with room to spare.
161
+ *
162
+ * It also absorbs the declared imprecision of the width approximation, which is
163
+ * on the same scale — measured against the real renderer over 28 names, the
164
+ * engine's per-character table reads between 11 % narrow and dead on, never
165
+ * wide — and points the same way, towards silence.
166
+ *
167
+ * IN RESERVE, if label/link is still noisy after this (PO, 01/08/2026): an
168
+ * ANGLE criterion — only a link crossing a name roughly PERPENDICULARLY strikes
169
+ * it out, one running along it at a shallow angle mostly runs beside it. It
170
+ * would be declared here as data, exactly like the threshold, and evaluated by
171
+ * the family. Not built: nobody has yet seen the noise it would remove.
172
+ */
173
+ const overlappingArtefacts = {
174
+ id: 'wardley.overlapping-artefacts',
175
+ framework: 'wardley',
176
+ family: 'no-overlap',
177
+ severity: 'warning',
178
+ // No `appliesTo`: this rule has no single subject role — the subjects are the
179
+ // pairs below, and naming one of them here would be data that lies.
180
+ roles: WARDLEY_ROLES,
181
+ messageKey: 'com.labre.wardley.validation.overlapping-artefacts',
182
+ messageFallback: 'These two overlap and make the map harder to read.',
183
+ suggestionKey: 'com.labre.wardley.validation.overlapping-artefacts.suggestion',
184
+ suggestionFallback: 'Move one of them aside.',
185
+ // 2: measured on the ink of a label rather than on its creation box, and
186
+ // calibrated with a penetration threshold — the same mistakes, fewer of the
187
+ // things that were never mistakes.
188
+ version: 2,
189
+ // Not a frame the rule measures against — an overlap is an overlap wherever
190
+ // it happens — but the map a finding is ATTRIBUTED to, so the arbitration
191
+ // "ignore this rule on the whole map" has one map to be written on.
192
+ backgroundRole: WARDLEY_ROLE.map,
193
+ overlap: [
194
+ [WARDLEY_ROLE.component, WARDLEY_ROLE.component],
195
+ [WARDLEY_ROLE.label, WARDLEY_ROLE.label],
196
+ [WARDLEY_ROLE.label, WARDLEY_ROLE.component],
197
+ [WARDLEY_ROLE.label, WARDLEY_ROLE.dependency],
198
+ ],
199
+ // How deep a collision has to be before it is one. See the header.
200
+ minPenetration: 4,
201
+ };
202
+ /**
203
+ * **W4** — a provider may not be positioned higher than its consumer.
204
+ *
205
+ * The value chain is the whole grammar of the map: the user sits at the top,
206
+ * each component rests on the components below it, and value flows back up. A
207
+ * dependency drawn from a component to something ABOVE it says the opposite of
208
+ * what the map means — either the link was drawn the wrong way round, or one of
209
+ * the two nodes is in the wrong place.
210
+ *
211
+ * ## The rule this one could not have been before `docs/adr/0010`
212
+ *
213
+ * W4 reads the persisted `source → target` pair of the edge, and that pair only
214
+ * became a STATEMENT the day the three mechanisms of that ADR landed: the link
215
+ * tool announces which way to drag (M1), a typed edge shows its orientation on
216
+ * hover and selection (M2), and the user can reverse it in one gesture (M3).
217
+ * Before them the direction was a by-product of which end the finger landed on
218
+ * first, and a rule on top of it would have spent the validation platform's
219
+ * credibility on its first false positive.
220
+ *
221
+ * The alternative — deriving the direction from the y coordinates — is not a
222
+ * cheaper W4, it is no W4 at all: the rule would compare the layout against
223
+ * itself and could never fire.
224
+ *
225
+ * ## The violation IS the affordance
226
+ *
227
+ * Nothing normalises the direction at creation, deliberately. An edge drawn
228
+ * upside-down raises this finding on the spot, and the user resolves it their
229
+ * way: drag the node, or reverse the relation. Either resolution is theirs.
230
+ *
231
+ * ## Tolerance
232
+ *
233
+ * 2% of the map's height, as a ratio and never as a number of units (the lesson
234
+ * of the 01/08/2026 recette, already learned by the equilibrium zone). Two
235
+ * components drawn level are not a mistake — a chain gets lined up before it
236
+ * gets spread out — so the rule only speaks when one is genuinely under the
237
+ * other. On the 900-high reference map that is 18 units, about the diameter of
238
+ * a component node.
239
+ */
240
+ const providerAboveConsumer = {
241
+ id: 'wardley.provider-above-consumer',
242
+ framework: 'wardley',
243
+ family: 'relative-order-along-axis',
244
+ severity: 'warning',
245
+ // No `appliesTo`: the subject of this rule is a RELATION, and the role that
246
+ // names it is declared where the family reads it — naming one of the three
247
+ // indicted elements here would be data that lies.
248
+ roles: WARDLEY_ROLES,
249
+ messageKey: 'com.labre.wardley.validation.provider-above-consumer',
250
+ messageFallback: 'This component sits above the one that depends on it.',
251
+ suggestionKey: 'com.labre.wardley.validation.provider-above-consumer.suggestion',
252
+ suggestionFallback: 'Needs run downwards on a Wardley map: move the provider below its consumer — or, if the link was drawn the wrong way round, reverse it.',
253
+ version: 1,
254
+ backgroundRole: WARDLEY_ROLE.map,
255
+ background: WARDLEY_BACKGROUND,
256
+ relativeOrder: {
257
+ edgeRole: WARDLEY_ROLE.dependency,
258
+ axis: 'value-chain',
259
+ // Tier 2 of ADR 0010: the verb of `wardley:dependency` is "depends on", so
260
+ // its source is the CONSUMER and sits higher on the visibility axis.
261
+ expect: 'source-ahead',
262
+ toleranceRatio: 0.02,
263
+ },
264
+ };
265
+ export const WARDLEY_RULES = [
266
+ changeArrowAgainstEvolution,
267
+ inertiaOffTransition,
268
+ overlappingArtefacts,
269
+ providerAboveConsumer,
270
+ ];
@@ -1,8 +1,15 @@
1
1
  import { makeTemplateSnapshot, surfaceText, } from '@formicoidea/labre-core/gfx/template';
2
2
  import { ConnectorMode, FontFamily, PointStyle, ShapeStyle, StrokeStyle, TextAlign, } from '@formicoidea/labre-core/model';
3
3
  import { REF_WIDTH } from '../consts.js';
4
+ import { WARDLEY_ROLE } from '../roles.js';
4
5
  import { wardleyMaps } from './maps.js';
5
6
  import { ECOSYSTEM_SIZE, HANDLE_SIZE, INERTIA_COLOR, INERTIA_SIZE, LABEL_FONT_SIZE, LINK_GREY, LINK_STROKE_WIDTH, MARKET_DOT_RING, MARKET_DOT_SIZE, MARKET_DOT_STROKE_WIDTH, MARKET_LINK_COLOR, MARKET_LINK_WIDTH, MARKET_SIZE, METHOD_FILL, METHOD_SIZE, NODE_FILL, NODE_SIZE, NODE_STROKE, NODE_STROKE_WIDTH, PIPELINE_FILL, PIPELINE_HEIGHT, PIPELINE_WIDTH, WARDLEY_RED, } from '../node/consts.js';
7
+ /**
8
+ * Verbatim copy of `BACKGROUND_VARIANT_DEFAULTS` in `../actions.ts` — see the
9
+ * TODO there: both copies write English prose into the document for the two
10
+ * value-chain variants, and both go away together when the variant becomes
11
+ * part of the declaration.
12
+ */
6
13
  const VARIANT_DEFAULTS = {
7
14
  classic: {},
8
15
  opportunity: {
@@ -23,10 +30,16 @@ const bg = (variant, w = REF_WIDTH) => {
23
30
  return { type: 'wardley', variant, ...VARIANT_DEFAULTS[variant], xywh: `[0,0,${w},${h}]` };
24
31
  };
25
32
  /** A wardley node ellipse positioned by top-left. */
26
- function node(kind, x, y, d = NODE_SIZE, fill = NODE_FILL, strokeWidth = NODE_STROKE_WIDTH) {
33
+ function node(kind, x, y, d = NODE_SIZE, fill = NODE_FILL, strokeWidth = NODE_STROKE_WIDTH,
34
+ // Neutral for the market's inner dots, exactly as `createWardleyMarket` does:
35
+ // glyph wiring, not artefacts.
36
+ neutral = false) {
27
37
  return {
28
38
  type: 'wardleyNode',
29
39
  kind,
40
+ // A template must produce the same typed artefacts as the toolbox, so a
41
+ // map started from a preset validates like a hand-drawn one.
42
+ role: neutral ? undefined : WARDLEY_ROLE[kind],
30
43
  shapeType: 'ellipse',
31
44
  filled: true,
32
45
  fillColor: fill,
@@ -41,6 +54,7 @@ function label(x, y, str, align = 'left') {
41
54
  return {
42
55
  type: 'text',
43
56
  text: surfaceText(str),
57
+ role: WARDLEY_ROLE.label,
44
58
  color: NODE_STROKE,
45
59
  fontFamily: FontFamily.Inter,
46
60
  fontSize: LABEL_FONT_SIZE,
@@ -48,15 +62,35 @@ function label(x, y, str, align = 'left') {
48
62
  xywh: `[${x},${y},140,26]`,
49
63
  };
50
64
  }
65
+ /**
66
+ * A Wardley connector for the palette samples.
67
+ *
68
+ * `evolution` is the ONE predicate that decides what the stroke means, here and
69
+ * in `maps.ts` alike — `docs/adr/0010` § Compatibility. The two kits used to
70
+ * test different things (`opts.red` here, `o.arrow` there), which was a style
71
+ * inconsistency until W4 started reading these edges and became a SEMANTIC one:
72
+ * what a rule governs must not depend on which authoring helper a template
73
+ * borrowed. Colour is a consequence of the meaning, never its source.
74
+ *
75
+ * `typed: false` drops the role altogether — for a sample that makes no claim
76
+ * about anything (see the "Link" swatch below).
77
+ */
51
78
  function connect(source, target, opts = {}) {
79
+ const role = opts.evolution
80
+ ? WARDLEY_ROLE.changeArrow
81
+ : WARDLEY_ROLE.dependency;
52
82
  return {
53
83
  type: 'connector',
54
84
  mode: ConnectorMode.Straight,
55
- stroke: opts.red ? WARDLEY_RED : LINK_GREY,
56
- strokeStyle: opts.red ? StrokeStyle.Dash : StrokeStyle.Solid,
85
+ // A template must produce the same typed artefacts as the toolbox, or a map
86
+ // started from a preset would validate differently from a hand-drawn one.
87
+ // `undefined` writes nothing: a neutral stroke keeps no `role` key.
88
+ role: opts.typed === false ? undefined : role,
89
+ stroke: opts.evolution ? WARDLEY_RED : LINK_GREY,
90
+ strokeStyle: opts.evolution ? StrokeStyle.Dash : StrokeStyle.Solid,
57
91
  strokeWidth: LINK_STROKE_WIDTH,
58
92
  frontEndpointStyle: PointStyle.None,
59
- rearEndpointStyle: opts.red ? PointStyle.Triangle : PointStyle.None,
93
+ rearEndpointStyle: opts.evolution ? PointStyle.Triangle : PointStyle.None,
60
94
  source,
61
95
  target,
62
96
  };
@@ -64,6 +98,7 @@ function connect(source, target, opts = {}) {
64
98
  const inertia = (x = 0, y = 0) => ({
65
99
  type: 'shape',
66
100
  shapeType: 'rect',
101
+ role: WARDLEY_ROLE.inertia,
67
102
  filled: true,
68
103
  fillColor: INERTIA_COLOR,
69
104
  strokeColor: INERTIA_COLOR,
@@ -80,6 +115,7 @@ function pipeline() {
80
115
  body: {
81
116
  type: 'wardleyNode',
82
117
  kind: 'pipeline',
118
+ role: WARDLEY_ROLE.pipeline,
83
119
  shapeType: 'rect',
84
120
  filled: true,
85
121
  fillColor: PIPELINE_FILL,
@@ -93,6 +129,7 @@ function pipeline() {
93
129
  handle: {
94
130
  type: 'wardleyNode',
95
131
  kind: 'handle',
132
+ role: WARDLEY_ROLE.handle,
96
133
  shapeType: 'rect',
97
134
  filled: true,
98
135
  fillColor: NODE_FILL,
@@ -116,7 +153,9 @@ function market() {
116
153
  [rho * sin60, rho / 2],
117
154
  [-rho * sin60, rho / 2],
118
155
  ];
119
- const dotAt = (vx, vy) => node('component', c + vx - MARKET_DOT_SIZE / 2, c + vy - MARKET_DOT_SIZE / 2, MARKET_DOT_SIZE, NODE_FILL, MARKET_DOT_STROKE_WIDTH);
156
+ const dotAt = (vx, vy) => node('component', c + vx - MARKET_DOT_SIZE / 2, c + vy - MARKET_DOT_SIZE / 2, MARKET_DOT_SIZE, NODE_FILL, MARKET_DOT_STROKE_WIDTH, true);
157
+ // Neutral on purpose: the triangle is the market glyph's own wiring, not a
158
+ // dependency the user drew (same rule as `createWardleyMarket`).
120
159
  const tri = (a, b) => ({
121
160
  type: 'connector',
122
161
  mode: ConnectorMode.Straight,
@@ -165,7 +204,12 @@ export const wardleyTemplateCategory = {
165
204
  tpl('Pipeline', `<svg ${ATTRS} fill="none"><rect x="34" y="40" width="66" height="14" fill="#fff" stroke="#1f2328"/><rect x="60" y="33" width="14" height="14" fill="#fff" stroke="#1f2328"/></svg>`, pipeline()),
166
205
  tpl('Market', `<svg ${ATTRS} fill="none"><circle cx="67" cy="40" r="16" fill="#fff" stroke="#1f2328"/><circle cx="67" cy="30" r="3.5" fill="#fff" stroke="#1f2328" stroke-width="1.5"/><circle cx="75" cy="46" r="3.5" fill="#fff" stroke="#1f2328" stroke-width="1.5"/><circle cx="59" cy="46" r="3.5" fill="#fff" stroke="#1f2328" stroke-width="1.5"/><path d="M67 30 L75 46 L59 46 Z" stroke="#1f2328" stroke-width="0.8" fill="none"/></svg>`, market()),
167
206
  tpl('Inertia', `<svg ${ATTRS} fill="none"><rect x="63" y="22" width="8" height="36" fill="#1f2328"/></svg>`, single(inertia())),
168
- tpl('Link', `<svg ${ATTRS} fill="none"><path d="M24 40 H110" stroke="#666" stroke-width="2.4"/></svg>`, single(connect({ position: [0, 0] }, { position: [160, 0] }))),
169
- tpl('Evolution arrow', `<svg ${ATTRS} fill="none"><path d="M24 40 H100" stroke="#d6455d" stroke-width="2.4" stroke-dasharray="6 4"/><path d="M98 33 L112 40 L98 47 Z" fill="#d6455d"/></svg>`, single(connect({ position: [0, 0] }, { position: [160, 0] }, { red: true }))),
207
+ // NEUTRAL on purpose (`docs/adr/0010` § Compatibility). This is a horizontal
208
+ // stroke bound to nothing a sample of a STYLE, in a palette. It was typed
209
+ // `wardley:dependency` only because the helper defaults to that role, and a
210
+ // sample of a stroke makes no claim about who depends on whom. Same call the
211
+ // market glyph's own wiring already makes.
212
+ tpl('Link', `<svg ${ATTRS} fill="none"><path d="M24 40 H110" stroke="#666" stroke-width="2.4"/></svg>`, single(connect({ position: [0, 0] }, { position: [160, 0] }, { typed: false }))),
213
+ tpl('Evolution arrow', `<svg ${ATTRS} fill="none"><path d="M24 40 H100" stroke="#d6455d" stroke-width="2.4" stroke-dasharray="6 4"/><path d="M98 33 L112 40 L98 47 Z" fill="#d6455d"/></svg>`, single(connect({ position: [0, 0] }, { position: [160, 0] }, { evolution: true, typed: false }))),
170
214
  ],
171
215
  };
@@ -1,6 +1,9 @@
1
1
  import { makeTemplateSnapshot, surfaceText, } from '@formicoidea/labre-core/gfx/template';
2
2
  import { ConnectorMode, FontFamily, PointStyle, ShapeStyle, StrokeStyle, TextAlign, } from '@formicoidea/labre-core/model';
3
3
  import { INERTIA_COLOR, LABEL_FONT_SIZE, LINK_GREY, LINK_STROKE_WIDTH, NODE_FILL, NODE_SIZE, NODE_STROKE, WARDLEY_RED, } from '../node/consts.js';
4
+ import { backgroundPlot, backgroundZoneBoundaries, } from '@formicoidea/labre-core/blocks/surface';
5
+ import { WARDLEY_BACKGROUND } from '../background.js';
6
+ import { WARDLEY_ROLE } from '../roles.js';
4
7
  /**
5
8
  * Authoring kit for canonical Wardley maps. Positions are given as
6
9
  * (evolution 0..1, value 0..1) and mapped into the plot interior of a fixed
@@ -11,19 +14,69 @@ import { INERTIA_COLOR, LABEL_FONT_SIZE, LINK_GREY, LINK_STROKE_WIDTH, NODE_FILL
11
14
  * arrow is the red dashed connector. Legends are produced by the editor's
12
15
  * auto-legend action rather than baked into the template.
13
16
  */
14
- const W = 1600;
15
- const H = 900;
16
- const PL = { x: 70, y: 56, w: 1470, h: 786 };
17
+ const W = WARDLEY_BACKGROUND.geometry.width;
18
+ const H = WARDLEY_BACKGROUND.geometry.height;
19
+ /**
20
+ * The plot these templates lay their nodes out in — THE DECLARATION'S, read off
21
+ * `geometry.margin` rather than copied.
22
+ *
23
+ * It used to be a hand-written `{x:70, y:56, w:1470, h:786}`, inset further than
24
+ * the drawn plot (`x 40 → 1570, y 30 → 862`), with a comment calling the drift
25
+ * "harmless (everything lands inside the map) but not the same number".
26
+ *
27
+ * It stopped being harmless the moment a RULE measured against the plot. W2
28
+ * asks whether an inertia bar sits on a declared phase transition, and a
29
+ * transition is a ratio OF THE PLOT: two plots means an evolution of `0.7` in a
30
+ * template lands 25 units away from the `0.7` the background draws. The
31
+ * templates were laid out in one frame of reference and judged in another.
32
+ *
33
+ * Deriving it removes the possibility. The nodes of every preset move by a few
34
+ * units, which is a visual change to FACTORY CONTENT — acceptable, and the
35
+ * reason this could be fixed at the source rather than worked around: no user
36
+ * document is touched, because a template is data we ship, not data they wrote.
37
+ */
38
+ const PLOT = backgroundPlot(WARDLEY_BACKGROUND, W, H);
39
+ const PL = { x: PLOT.x0, y: PLOT.y0, w: PLOT.width, h: PLOT.height };
17
40
  const ex = (e) => PL.x + e * PL.w;
18
41
  const vy = (v) => PL.y + (1 - v) * PL.h;
19
42
  const D = NODE_SIZE; // 18
20
- const bg = (variant = 'classic') => ({ type: 'wardley', variant, xywh: `[0,0,${W},${H}]` });
43
+ /** The evolution transitions, as plot ratios, straight from the declaration. */
44
+ const PHASES = backgroundZoneBoundaries(WARDLEY_BACKGROUND).x;
45
+ /**
46
+ * Where the segment `from → to` crosses the evolution transition at `PHASES[i]`,
47
+ * in (evolution, value) coordinates.
48
+ *
49
+ * This is where an inertia bar belongs: astride the boundary the thing refuses
50
+ * to cross — which is all W2 asks since the PO spelled it out (02/08/2026) — and
51
+ * on the dependency that would have to move, which the rule no longer demands
52
+ * and a well-drawn map still shows. Computed rather than eyeballed, so the
53
+ * symbol stays on both whatever the declaration says either of them is.
54
+ */
55
+ function crossing(from, to, i) {
56
+ const at = PHASES[i];
57
+ const t = (at - from[0]) / (to[0] - from[0]);
58
+ return [at, from[1] + t * (to[1] - from[1])];
59
+ }
60
+ // The map carries `wardley:map`: rules position artefacts against the ROLE, so
61
+ // a templated map is a first-class frame like a hand-drawn one — and since
62
+ // PF13.4 every artefact these presets lay on it (nodes, labels, links, change
63
+ // arrows, inertia bars) carries the same role the toolbox writes.
64
+ const bg = (variant = 'classic') => ({
65
+ type: WARDLEY_BACKGROUND.type,
66
+ role: WARDLEY_BACKGROUND.role,
67
+ resizeEnabled: WARDLEY_BACKGROUND.geometry.resizable,
68
+ variant,
69
+ xywh: `[0,0,${W},${H}]`,
70
+ });
21
71
  function dot(e, v, sw, stroke = NODE_STROKE, fill = NODE_FILL) {
22
72
  const cx = ex(e);
23
73
  const cy = vy(v);
24
74
  return {
25
75
  type: 'wardleyNode',
26
76
  kind: 'component',
77
+ // Templates carry the same semantic roles as the toolbox, so a map started
78
+ // from a preset validates exactly like a hand-drawn one.
79
+ role: WARDLEY_ROLE.component,
27
80
  shapeType: 'ellipse',
28
81
  filled: true,
29
82
  fillColor: fill,
@@ -43,6 +96,7 @@ function stake(e, v) {
43
96
  return {
44
97
  type: 'wardleyNode',
45
98
  kind: 'anchor',
99
+ role: WARDLEY_ROLE.anchor,
46
100
  shapeType: 'ellipse',
47
101
  filled: true,
48
102
  fillColor: NODE_FILL,
@@ -64,6 +118,10 @@ function lbl(e, v, text, o = {}) {
64
118
  return {
65
119
  type: 'text',
66
120
  text: surfaceText(text),
121
+ // The NAME of an artefact, so it carries the label role W3 is written on.
122
+ // The free texts these presets also use for notes and legends stay neutral:
123
+ // they name nothing and nothing measures them.
124
+ role: WARDLEY_ROLE.label,
67
125
  color: o.color ?? NODE_STROKE,
68
126
  fontFamily: FontFamily.Inter,
69
127
  fontSize: o.size ?? LABEL_FONT_SIZE,
@@ -71,15 +129,38 @@ function lbl(e, v, text, o = {}) {
71
129
  xywh: `[${x},${cy + dy},${w},26]`,
72
130
  };
73
131
  }
132
+ /**
133
+ * A link between two nodes of a shipped map.
134
+ *
135
+ * `evolution` is the ONE predicate that decides what the stroke MEANS — the
136
+ * same flag `templates/index.ts` reads, aligned by `docs/adr/0010`
137
+ * § Compatibility. It used to be `arrow` here and `red` there, so a red SOLID
138
+ * Kodak link was a typed dependency while a red sample in the palette was not:
139
+ * two answers to "is this a dependency?" in one framework, in neighbouring
140
+ * files. Harmless as a style inconsistency, semantic the moment W4 reads these
141
+ * edges.
142
+ *
143
+ * `red` stays, and stays orthogonal: it colours a DEPENDENCY red (Kodak's
144
+ * future chain) without changing what it is. Colour is never what decides a
145
+ * relation's type.
146
+ *
147
+ * The direction is meaning, not decoration: `a` is the consumer, `b` is what it
148
+ * needs, and every one of the twelve links these presets ship respects it
149
+ * (a corpus test walks them and fails loudly the day one does not).
150
+ */
74
151
  function link(a, b, o = {}) {
75
152
  return {
76
153
  type: 'connector',
77
154
  mode: ConnectorMode.Straight,
78
- stroke: o.red ? WARDLEY_RED : LINK_GREY,
79
- strokeStyle: o.arrow ? StrokeStyle.Dash : StrokeStyle.Solid,
155
+ // An evolution arrow is a movement annotation, not a dependency — same
156
+ // split as the two Wardley connector tools, and since PF13.4 both sides of
157
+ // that split carry a role.
158
+ role: o.evolution ? WARDLEY_ROLE.changeArrow : WARDLEY_ROLE.dependency,
159
+ stroke: o.red || o.evolution ? WARDLEY_RED : LINK_GREY,
160
+ strokeStyle: o.evolution ? StrokeStyle.Dash : StrokeStyle.Solid,
80
161
  strokeWidth: LINK_STROKE_WIDTH,
81
162
  frontEndpointStyle: PointStyle.None,
82
- rearEndpointStyle: o.arrow ? PointStyle.Triangle : PointStyle.None,
163
+ rearEndpointStyle: o.evolution ? PointStyle.Triangle : PointStyle.None,
83
164
  source: { id: a },
84
165
  target: { id: b },
85
166
  };
@@ -90,6 +171,7 @@ function inertia(e, v) {
90
171
  return {
91
172
  type: 'shape',
92
173
  shapeType: 'rect',
174
+ role: WARDLEY_ROLE.inertia,
93
175
  filled: true,
94
176
  fillColor: INERTIA_COLOR,
95
177
  strokeColor: INERTIA_COLOR,
@@ -199,7 +281,10 @@ function teaShop() {
199
281
  powerL: lbl(0.7, 0.1, 'Power', { align: 'right', dy: 6 }),
200
282
  powerFut: future(0.88, 0.1),
201
283
  powerFutL: lbl(0.88, 0.1, 'Power'),
202
- limitedBy: lbl(0.56, 0.43, 'limited by', { align: 'center', w: 120, size: 13 }),
284
+ // ABOVE the link it annotates, not across it. Written on the line it reads
285
+ // as a label nobody can read — which is the finding W3 raises, and it was
286
+ // raising it on the map that ships as the canonical example.
287
+ limitedBy: lbl(0.56, 0.43, 'limited by', { align: 'center', w: 120, size: 13, dy: -34 }),
203
288
  ann1a: ann(0.5, 0.385), ann1t: annTxt(0.5, 0.385, '1'),
204
289
  ann2a: ann(0.84, 0.45), ann2t: annTxt(0.84, 0.45, '2'),
205
290
  l1: link('business', 'cupOfTea'),
@@ -210,34 +295,53 @@ function teaShop() {
210
295
  l6: link('hotWater', 'water'),
211
296
  l7: link('hotWater', 'kettle'),
212
297
  l8: link('kettle', 'power'),
213
- a1: link('kettle', 'electric', { red: true, arrow: true }),
214
- a2: link('power', 'powerFut', { red: true, arrow: true }),
298
+ a1: link('kettle', 'electric', { evolution: true }),
299
+ a2: link('power', 'powerFut', { evolution: true }),
215
300
  };
216
301
  }
217
302
  // ── Kodak inertia (2005) ──────────────────────────────────────────────
218
303
  function kodak() {
304
+ // The future dependency `capture → storage` is the movement Kodak resisted,
305
+ // and the inertia bar belongs where that dependency crosses into commodity —
306
+ // the boundary the capability refused to cross. It used to sit 105 units away
307
+ // from any transition: the template named after inertia was the
308
+ // counter-example to the inertia rule. Computed from the two node positions
309
+ // and the declared transitions, so it cannot drift again — and the bar comes
310
+ // out centred on the divider, which is exactly what W2 asks of it now that
311
+ // "astride the transition" is the whole of the rule.
312
+ const CAPTURE = [0.53, 0.8];
313
+ const STORAGE = [0.84, 0.4];
314
+ const [barE, barV] = crossing(CAPTURE, STORAGE, 2);
219
315
  return {
220
316
  bg: bg(),
221
317
  title: title("Wardley map of Kodak's 2005 inertia to digital"),
222
318
  user: stake(0.54, 0.92),
223
319
  userL: lbl(0.54, 0.92, 'User'),
224
- capture: dot(0.53, 0.8, 3),
225
- captureL: lbl(0.53, 0.8, 'Capture a moment'),
320
+ capture: dot(CAPTURE[0], CAPTURE[1], 3),
321
+ // To the LEFT, like the other two capability names: to the right of this
322
+ // node runs the future dependency towards digital storage, and a name
323
+ // written across the line that carries the whole argument is exactly the
324
+ // case W3 exists for.
325
+ captureL: lbl(CAPTURE[0], CAPTURE[1], 'Capture a moment', {
326
+ align: 'right',
327
+ }),
226
328
  film: comp(0.52, 0.62),
227
329
  filmL: lbl(0.52, 0.62, 'Film camera', { align: 'right' }),
228
330
  digital: future(0.74, 0.62),
229
331
  digitalL: lbl(0.74, 0.62, 'Digital camera', { color: WARDLEY_RED }),
230
332
  roll: comp(0.52, 0.4),
231
333
  rollL: lbl(0.52, 0.4, 'Photographic film', { align: 'right' }),
232
- storage: future(0.84, 0.4),
233
- storageL: lbl(0.84, 0.4, 'Digital storage', { color: WARDLEY_RED }),
234
- inertiaBar: inertia(0.78, 0.4),
334
+ storage: future(STORAGE[0], STORAGE[1]),
335
+ storageL: lbl(STORAGE[0], STORAGE[1], 'Digital storage', {
336
+ color: WARDLEY_RED,
337
+ }),
338
+ inertiaBar: inertia(barE, barV),
235
339
  l1: link('user', 'capture'),
236
340
  l2: link('capture', 'film'),
237
341
  l3: link('film', 'roll'),
238
342
  r1: link('capture', 'storage', { red: true }),
239
- a1: link('film', 'digital', { red: true, arrow: true }),
240
- a2: link('roll', 'storage', { red: true, arrow: true }),
343
+ a1: link('film', 'digital', { evolution: true }),
344
+ a2: link('roll', 'storage', { evolution: true }),
241
345
  };
242
346
  }
243
347
  export const wardleyMaps = [
@@ -1,20 +1,13 @@
1
1
  import { EmptyTool } from '@formicoidea/labre-core/gfx/pointer';
2
- import { LitElement } from 'lit';
3
- declare const EdgelessWardleyMenu_base: typeof LitElement & import("@formicoidea/labre-core/_pkgs/global/utils").Constructor<import("@formicoidea/labre-core/_pkgs/affine-widget-edgeless-toolbar").EdgelessToolbarToolClass>;
2
+ import { EdgelessCommandMenu } from '@formicoidea/labre-core/widgets/edgeless-toolbar';
4
3
  /**
5
- * The popover that opens above the toolbar for the Wardley toolbox. Each item
6
- * creates a pre-formatted Wardley object (see `../actions.ts` — the same
7
- * actions back the wardley keyboard shortcuts).
4
+ * The popover that opens above the toolbar for the Wardley toolbox.
5
+ *
6
+ * Since PF3 it holds no artefact list of its own: {@link EdgelessCommandMenu}
7
+ * renders whatever `wardleyCommands` declares for the `senior-menu` surface, so
8
+ * the menu and the shortcut manifest can no longer drift (they did: 13 vs 7).
8
9
  */
9
- export declare class EdgelessWardleyMenu extends EdgelessWardleyMenu_base {
10
- static styles: import("lit").CSSResult;
10
+ export declare class EdgelessWardleyMenu extends EdgelessCommandMenu {
11
+ protected owner: "wardley";
11
12
  type: typeof EmptyTool;
12
- private _createBackground;
13
- private _createNode;
14
- private readonly _createInertia;
15
- private readonly _createPipeline;
16
- private readonly _createMarket;
17
- private _activateConnector;
18
- render(): import("lit-html").TemplateResult<1>;
19
13
  }
20
- export {};