@formicoidea/labre-framework-edgy 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 (48) hide show
  1. package/dist/actions.d.ts +39 -0
  2. package/dist/actions.js +193 -0
  3. package/dist/board-renderer.d.ts +12 -0
  4. package/dist/board-renderer.js +28 -0
  5. package/dist/board-view.d.ts +11 -0
  6. package/dist/board-view.js +20 -0
  7. package/dist/commands.d.ts +4 -0
  8. package/dist/commands.js +103 -0
  9. package/dist/consts.d.ts +35 -0
  10. package/dist/consts.js +46 -0
  11. package/dist/descriptor.d.ts +8 -3
  12. package/dist/descriptor.js +6 -3
  13. package/dist/element-renderer.js +24 -13
  14. package/dist/element-view.js +9 -3
  15. package/dist/index.d.ts +2 -1
  16. package/dist/index.js +2 -1
  17. package/dist/label-layout.js +8 -2
  18. package/dist/legend.d.ts +2 -0
  19. package/dist/legend.js +109 -0
  20. package/dist/metamodel.d.ts +96 -0
  21. package/dist/metamodel.js +128 -0
  22. package/dist/nudges.d.ts +26 -0
  23. package/dist/nudges.js +54 -0
  24. package/dist/profiles.d.ts +2 -0
  25. package/dist/profiles.js +66 -0
  26. package/dist/relation-resolver.d.ts +32 -0
  27. package/dist/relation-resolver.js +101 -0
  28. package/dist/relation.d.ts +142 -0
  29. package/dist/relation.js +188 -0
  30. package/dist/roles.d.ts +108 -0
  31. package/dist/roles.js +182 -0
  32. package/dist/rules.d.ts +43 -0
  33. package/dist/rules.js +153 -0
  34. package/dist/templates/index.d.ts +22 -1
  35. package/dist/templates/index.js +303 -52
  36. package/dist/toolbar/config.d.ts +33 -0
  37. package/dist/toolbar/config.js +82 -15
  38. package/dist/toolbar/edgy-menu.d.ts +7 -25
  39. package/dist/toolbar/edgy-menu.js +7 -186
  40. package/dist/toolbar/edgy-senior-button.js +8 -2
  41. package/dist/toolbar/icons.d.ts +12 -0
  42. package/dist/toolbar/icons.js +31 -0
  43. package/dist/toolbar/senior-tool.js +1 -0
  44. package/dist/translations.d.ts +24 -0
  45. package/dist/translations.js +29 -0
  46. package/dist/view.d.ts +18 -0
  47. package/dist/view.js +95 -8
  48. package/package.json +6 -3
package/dist/rules.js ADDED
@@ -0,0 +1,153 @@
1
+ import { EDGY_DYNAMIC_RELATIONS } from './metamodel.js';
2
+ import { EDGY_ROLE, EDGY_ROLES, EDGY_VERB_ROLE, } from './roles.js';
3
+ /**
4
+ * EDGY validation rules (WS1).
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 an EDGY rule is adding an entry to this array.
9
+ *
10
+ * Registered from the flag-gated `EdgyViewExtension`, so switching the EDGY
11
+ * flag off removes the rules with the rest of the tooling — boards already
12
+ * drawn keep rendering, they simply stop being checked (`docs/adr/0009`).
13
+ *
14
+ * ## Two rules, and the ones deliberately left out
15
+ *
16
+ * The PO arbitration of 26/08/2026 put EDGY's two JUDGEMENT controls — "every
17
+ * intersection element is linked to both its parent facets" and "every element
18
+ * wears its facet's colour" — in `./nudges.ts` rather than here. Neither is
19
+ * decidable: the first would have to guess which circle an element belongs to
20
+ * from where somebody dropped it, and the second would indict a board whose
21
+ * author uses their own palette. A checklist can ask them; an algorithm cannot
22
+ * answer them.
23
+ *
24
+ * ## Debt: `tree-mixed-kinds`
25
+ *
26
+ * EDGY's own notation has TREES — a capability tree, an organisation tree —
27
+ * whose levels must all be the same kind of element. There is no tree on this
28
+ * canvas yet: nothing draws one, nothing stores one, and no role names one. The
29
+ * rule is therefore deferred rather than written against an artefact that does
30
+ * not exist. When a tree lands, it comes with its own role and this is where its
31
+ * rule goes.
32
+ */
33
+ /**
34
+ * The 24 sanctioned sentences, DERIVED from the metamodel.
35
+ *
36
+ * One triplet per row of `EDGY_DYNAMIC_RELATIONS`, read exactly as
37
+ * `docs/adr/0010` reads a typed edge: source is the subject, target the object.
38
+ * The matrix is never restated — a relation added to the metamodel is drawn by
39
+ * the template, gets a role from `roles.ts` and becomes legal here, all from the
40
+ * one table. `requires` contributes three triplets under one role, which is what
41
+ * it means for a verb to be spoken of three different pairs.
42
+ */
43
+ export const EDGY_ALLOWED_RELATIONS = EDGY_DYNAMIC_RELATIONS.map(([source, target, verb]) => ({
44
+ source: EDGY_ROLE[source],
45
+ edge: EDGY_VERB_ROLE[verb],
46
+ target: EDGY_ROLE[target],
47
+ }));
48
+ /**
49
+ * **E1** — a relation says something the EDGY metamodel does not.
50
+ *
51
+ * EDGY is a language before it is a diagram: its 24 relations are the sentences
52
+ * the method sanctions, and a link drawn between the wrong two elements — or
53
+ * drawn the right way round between the right two but carrying the wrong verb —
54
+ * is a modelling mistake no amount of dragging fixes. "A journey traverses a
55
+ * channel" is EDGY; "a channel traverses a journey" is a sentence nobody can
56
+ * act on.
57
+ *
58
+ * ## What stays silent
59
+ *
60
+ * Everything the `relation-endpoints` family already keeps quiet about, and one
61
+ * of them matters more here than anywhere: **a plain connector carries no role
62
+ * and is never judged**. Free links are how an EDGY workshop actually runs —
63
+ * somebody draws an arrow to say "these two have something to do with each
64
+ * other" and names it later — and the day the tool starts indicting that
65
+ * gesture is the day it gets switched off. Only the typed relations the toolbox
66
+ * and the templates stamp are read, and only when BOTH ends are official
67
+ * elements: a relation onto a bare sticky, onto a People node or onto an
68
+ * element of another framework is outside the alphabet, hence outside the
69
+ * conversation.
70
+ *
71
+ * Severity `warning` and not `blocking-overridable`: nothing in this library
72
+ * refuses a gesture, so declaring the blocking level would be data claiming an
73
+ * effect that does not exist (the note `wardley/rules.ts` carries at length).
74
+ * The profiles demote it to `audit` on a sketch.
75
+ */
76
+ const nonCanonicalLink = {
77
+ id: 'edgy.non-canonical-link',
78
+ framework: 'edgy',
79
+ family: 'relation-endpoints',
80
+ severity: 'warning',
81
+ // No `appliesTo`: the subject is a RELATION, and the role that names it is
82
+ // declared where the family reads it — naming one of the three indicted
83
+ // elements here would be data that lies.
84
+ roles: EDGY_ROLES,
85
+ messageKey: 'com.labre.edgy.validation.non-canonical-link',
86
+ messageFallback: 'This relation is not one the EDGY metamodel declares between these two elements.',
87
+ suggestionKey: 'com.labre.edgy.validation.non-canonical-link.suggestion',
88
+ suggestionFallback: 'Read the link out loud — source, verb, target. Reverse it, re-point an end, or use the verb EDGY gives these two elements.',
89
+ version: 1,
90
+ provenance: {
91
+ source: 'recommendation',
92
+ reference: 'EDGY (Enterprise Design) — the 24 relations its metamodel sanctions',
93
+ },
94
+ // Not a frame the rule measures against — a sentence is right or wrong
95
+ // wherever it is written — but the facets diagram or board a finding is
96
+ // ATTRIBUTED to, so the arbitration "ignore this rule on the whole board" has
97
+ // somewhere to live (the `no-overlap` pattern).
98
+ backgroundRole: EDGY_ROLE.background,
99
+ endpoints: {
100
+ // The PARENT role: every verb specialises it, so the rule reads all 22 of
101
+ // them and no free connector.
102
+ edgeRole: EDGY_ROLE.relation,
103
+ allowed: EDGY_ALLOWED_RELATIONS,
104
+ },
105
+ };
106
+ /**
107
+ * **E2** — two artefacts must not sit on top of each other.
108
+ *
109
+ * A readability rule, the same one Wardley ships and for the same reason: a
110
+ * board you cannot read is useless, while a momentary overlap during a drag is
111
+ * not a mistake. It matters more on an EDGY facets diagram than almost
112
+ * anywhere, because WHERE an element sits is what says which facet it belongs
113
+ * to: an artefact hidden under another has lost its meaning, not just its
114
+ * legibility.
115
+ *
116
+ * ONE declared pair, `element × element`, because `roleIsA` covers the whole
117
+ * subtree — the four kinds and the twelve official elements — from the root.
118
+ * Backgrounds are outside that subtree by construction, so an element sitting on
119
+ * the diagram it belongs to is never a collision.
120
+ *
121
+ * `minPenetration: 4` model units, the calibration Wardley's W3 arrived at on
122
+ * the recette of 01/08/2026: under a twentieth of an EDGY node's 80-unit height
123
+ * and about twice a connector's stroke, so two boxes whose corners share a hair
124
+ * are silent while a box genuinely covering another is not. Verified against
125
+ * every layout this package ships: the twelve nodes of the EDGY dynamic
126
+ * template — the only stamped layout, hence the only one this rule can see —
127
+ * clear each other by 80 model units at the closest, twenty times the
128
+ * threshold.
129
+ */
130
+ const overlappingArtefacts = {
131
+ id: 'edgy.overlapping-artefacts',
132
+ framework: 'edgy',
133
+ family: 'no-overlap',
134
+ severity: 'warning',
135
+ // No `appliesTo`: the subjects of a `no-overlap` rule are the pairs below.
136
+ roles: EDGY_ROLES,
137
+ messageKey: 'com.labre.edgy.validation.overlapping-artefacts',
138
+ messageFallback: 'These two overlap and make the board harder to read.',
139
+ suggestionKey: 'com.labre.edgy.validation.overlapping-artefacts.suggestion',
140
+ suggestionFallback: 'Move one of them aside — on a facets diagram, where an element sits is what says which facet it belongs to.',
141
+ version: 1,
142
+ provenance: {
143
+ source: 'labre-convention',
144
+ reference: 'Labre readability convention — no EDGY rule speaks of overlapping ink',
145
+ },
146
+ backgroundRole: EDGY_ROLE.background,
147
+ overlap: [[EDGY_ROLE.element, EDGY_ROLE.element]],
148
+ minPenetration: 4,
149
+ };
150
+ export const EDGY_RULES = [
151
+ nonCanonicalLink,
152
+ overlappingArtefacts,
153
+ ];
@@ -1,2 +1,23 @@
1
- import { type TemplateCategory } from '@formicoidea/labre-core/gfx/template';
1
+ import { type Template, type TemplateCategory } from '@formicoidea/labre-core/gfx/template';
2
+ /**
3
+ * Background scale (from REF coords to model coords): large enough that the
4
+ * default-size nodes breathe inside each zone. The background is cropped to
5
+ * the circles (`cropToCircles`), so its xywh covers CROP × DYN_SCALE.
6
+ */
7
+ export declare const DYN_SCALE = 4.8;
8
+ /**
9
+ * The metamodel itself — the 12 elements and the 24 relations — now lives in
10
+ * `../metamodel.ts`, so the role vocabulary can DERIVE from it without this
11
+ * module and that one importing each other. Re-exported here under the names
12
+ * they have always had: nothing that reads them had to change.
13
+ */
14
+ export { EDGY_DYNAMIC_NODES, EDGY_DYNAMIC_RELATIONS, type EdgyElementName, } from '../metamodel.js';
15
+ /**
16
+ * Reference coords → template model coords: the background element sits at
17
+ * (0,0) and renders the cropped circles box, so a reference point maps to
18
+ * `(p - CROP.origin) × DYN_SCALE`.
19
+ */
20
+ export declare function dynToModel(refX: number, refY: number): [number, number];
21
+ /** Exported for direct insertion from the EDGY senior toolbar menu. */
22
+ export declare const edgyDynamicTemplate: Template;
2
23
  export declare const edgyTemplateCategory: TemplateCategory;
@@ -1,7 +1,10 @@
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
- import { REF_H, REF_W } from '../consts.js';
4
- import { ACTIVITY_VERTICES, INNER_FONT_SIZE, NODE_FILL, NODE_STROKE, NODE_STROKE_WIDTH, OUTCOME_RADIUS, } from '../node/consts.js';
3
+ import { CROP, CROP_LABELED } from '../consts.js';
4
+ import { EDGY_DYNAMIC_NODES, EDGY_DYNAMIC_RELATIONS, EDGY_ZONE_FILL, edgyElementLabel, } from '../metamodel.js';
5
+ import { EDGY_RELATION_LABEL_DISTANCE, edgyVerbLabelXYWH } from '../relation.js';
6
+ import { EDGY_ROLE, EDGY_VERB_ROLE } from '../roles.js';
7
+ import { ACTIVITY_VERTICES, INNER_FONT_SIZE, NODE_FILL, NODE_SIZE, NODE_STROKE, NODE_STROKE_WIDTH, OUTCOME_RADIUS, } from '../node/consts.js';
5
8
  // EDGY facet palette (header / pale sub-card).
6
9
  const C = {
7
10
  identity: ['#1ec873', '#9fe6c2'],
@@ -35,7 +38,15 @@ function rect(x, y, w, h, opts = {}) {
35
38
  }
36
39
  return el;
37
40
  }
38
- /** An EDGY node (kind drives the native shape): outcome/object box, people, activity chevron. */
41
+ /**
42
+ * An EDGY node (kind drives the native shape): outcome/object box, people,
43
+ * activity chevron.
44
+ *
45
+ * `role` is optional and writes NOTHING when absent: the illustrative templates
46
+ * (journey, blueprint, org chart) stay neutral drawings the engine never looks
47
+ * at, exactly as they were before roles existed. Only the EDGY dynamic template
48
+ * — the one that IS the metamodel — stamps its elements.
49
+ */
39
50
  function enode(kind, x, y, w, h, opts = {}) {
40
51
  const el = {
41
52
  type: 'edgyNode',
@@ -52,6 +63,9 @@ function enode(kind, x, y, w, h, opts = {}) {
52
63
  };
53
64
  if (kind === 'activity')
54
65
  el.vertices = ACTIVITY_VERTICES;
66
+ // `undefined` writes nothing: a neutral node keeps no `role` key.
67
+ if (opts.role !== undefined)
68
+ el.role = opts.role;
55
69
  if (opts.text != null) {
56
70
  el.text = surfaceText(opts.text);
57
71
  el.color = opts.textColor ?? NODE_STROKE;
@@ -85,22 +99,76 @@ function line(x1, y1, x2, y2, opts = {}) {
85
99
  target: { position: [x2, y2] },
86
100
  };
87
101
  }
102
+ /**
103
+ * Connector ATTACHED to two template elements (by key): endpoints clip to the
104
+ * element edges and follow moves — unlike `line()`, whose free positions are
105
+ * only right until the user drags something.
106
+ */
107
+ function attach(src, dst, opts = {}) {
108
+ return {
109
+ type: 'connector',
110
+ mode: opts.mode ?? ConnectorMode.Straight,
111
+ stroke: NODE_STROKE,
112
+ strokeWidth: 2,
113
+ strokeStyle: StrokeStyle.Solid,
114
+ frontEndpointStyle: PointStyle.None,
115
+ rearEndpointStyle: opts.arrow ? PointStyle.Triangle : PointStyle.None,
116
+ source: { id: src },
117
+ target: { id: dst },
118
+ };
119
+ }
88
120
  // ── Facets overview (six facets, sub-cards, facets/intersections) ─────
89
121
  function facetsOverview() {
90
122
  const out = {};
91
123
  const tall = (key, x, name, cards) => {
92
124
  const [hdr, sub] = C[key];
93
- out[`${key}F`] = rect(x, 140, 192, 460, { fill: hdr, stroke: hdr, radius: 16 });
94
- out[`${key}N`] = label(x, 168, 192, 28, name, { color: '#ffffff', fontSize: 18 });
95
- out[`${key}1`] = rect(x + 16, 208, 160, 80, { fill: sub, stroke: sub, radius: 6, text: cards[0], fontSize: 16 });
96
- out[`${key}2`] = rect(x + 16, 300, 160, 80, { fill: sub, stroke: sub, radius: 6, text: cards[1], fontSize: 16 });
97
- out[`${key}3`] = enode('activity', x + 16, 392, 160, 84, { fill: sub, text: cards[2], fontSize: 16 });
125
+ out[`${key}F`] = rect(x, 140, 192, 460, {
126
+ fill: hdr,
127
+ stroke: hdr,
128
+ radius: 16,
129
+ });
130
+ out[`${key}N`] = label(x, 168, 192, 28, name, {
131
+ color: '#ffffff',
132
+ fontSize: 18,
133
+ });
134
+ out[`${key}1`] = rect(x + 16, 208, 160, 80, {
135
+ fill: sub,
136
+ stroke: sub,
137
+ radius: 6,
138
+ text: cards[0],
139
+ fontSize: 16,
140
+ });
141
+ out[`${key}2`] = rect(x + 16, 300, 160, 80, {
142
+ fill: sub,
143
+ stroke: sub,
144
+ radius: 6,
145
+ text: cards[1],
146
+ fontSize: 16,
147
+ });
148
+ out[`${key}3`] = enode('activity', x + 16, 392, 160, 84, {
149
+ fill: sub,
150
+ text: cards[2],
151
+ fontSize: 16,
152
+ });
98
153
  };
99
154
  const low = (key, x, name) => {
100
155
  const [hdr, sub] = C[key];
101
- out[`${key}F`] = rect(x, 300, 192, 424, { fill: hdr, stroke: hdr, radius: 16 });
102
- out[`${key}1`] = rect(x + 16, 360, 160, 92, { fill: sub, stroke: sub, radius: 6, text: name, fontSize: 16 });
103
- out[`${key}N`] = label(x, 686, 192, 28, name, { color: '#ffffff', fontSize: 18 });
156
+ out[`${key}F`] = rect(x, 300, 192, 424, {
157
+ fill: hdr,
158
+ stroke: hdr,
159
+ radius: 16,
160
+ });
161
+ out[`${key}1`] = rect(x + 16, 360, 160, 92, {
162
+ fill: sub,
163
+ stroke: sub,
164
+ radius: 6,
165
+ text: name,
166
+ fontSize: 16,
167
+ });
168
+ out[`${key}N`] = label(x, 686, 192, 28, name, {
169
+ color: '#ffffff',
170
+ fontSize: 18,
171
+ });
104
172
  };
105
173
  // lower facets first (drawn behind the tall ones at the overlaps)
106
174
  low('organisation', 240, 'Organisation');
@@ -117,39 +185,114 @@ function facetsOverview() {
117
185
  }
118
186
  // ── Customer journey ──────────────────────────────────────────────────
119
187
  function journey() {
120
- const step = (i, x) => enode('activity', x, 132, 224, 116, { fill: JOURNEY_PINK, text: `Journey step ${i}`, textColor: '#ffffff', fontSize: 18 });
121
- const ch = (x, n) => enode('object', x, 392, 184, 96, { fill: JOURNEY_PINK, text: `Channel ${n}`, fontSize: 16 });
122
- const tk = (x, n) => enode('object', x, 540, 168, 92, { fill: JOURNEY_PINK, text: `Task ${n}`, fontSize: 16 });
188
+ const step = (i, x) => enode('activity', x, 132, 224, 116, {
189
+ fill: JOURNEY_PINK,
190
+ text: `Journey step ${i}`,
191
+ textColor: '#ffffff',
192
+ fontSize: 18,
193
+ });
194
+ const ch = (x, n) => enode('object', x, 392, 184, 96, {
195
+ fill: JOURNEY_PINK,
196
+ text: `Channel ${n}`,
197
+ fontSize: 16,
198
+ });
199
+ const tk = (x, n) => enode('object', x, 540, 168, 92, {
200
+ fill: JOURNEY_PINK,
201
+ text: `Task ${n}`,
202
+ fontSize: 16,
203
+ });
123
204
  return {
124
205
  cust: enode('people', 40, 120, 64, 64, { fill: '#ffffff' }),
125
206
  custL: label(20, 192, 104, 24, 'Customer', { fontSize: 14 }),
126
- band: { type: 'shape', shapeType: 'polygon', vertices: [[0, 0], [0.86, 0], [1, 0.5], [0.86, 1], [0, 1]], filled: true, fillColor: JOURNEY_PINK, strokeColor: JOURNEY_PINK, strokeWidth: 1, shapeStyle: ShapeStyle.General, roughness: 0, xywh: '[150,108,860,164]' },
127
- bandL: label(170, 116, 120, 24, 'Journey', { color: '#ffffff', fontSize: 16, align: TextAlign.Left }),
207
+ band: {
208
+ type: 'shape',
209
+ shapeType: 'polygon',
210
+ vertices: [
211
+ [0, 0],
212
+ [0.86, 0],
213
+ [1, 0.5],
214
+ [0.86, 1],
215
+ [0, 1],
216
+ ],
217
+ filled: true,
218
+ fillColor: JOURNEY_PINK,
219
+ strokeColor: JOURNEY_PINK,
220
+ strokeWidth: 1,
221
+ shapeStyle: ShapeStyle.General,
222
+ roughness: 0,
223
+ xywh: '[150,108,860,164]',
224
+ },
225
+ bandL: label(170, 116, 120, 24, 'Journey', {
226
+ color: '#ffffff',
227
+ fontSize: 16,
228
+ align: TextAlign.Left,
229
+ }),
128
230
  s1: step(1, 240),
129
231
  s2: step(2, 500),
130
232
  s3: step(3, 760),
131
- rightTask: enode('object', 1060, 132, 168, 92, { text: 'Task', fontSize: 16 }),
132
- c1: ch(258, 'X'), c2: ch(518, 'Y'), c3: ch(778, 'Z'),
133
- t1: tk(266, 'A'), t2: tk(526, 'B'), t3: tk(786, 'C'),
134
- trav1: label(258, 350, 184, 20, 'traverses', { fontSize: 13, color: '#5f6368' }),
135
- trav2: label(518, 350, 184, 20, 'traverses', { fontSize: 13, color: '#5f6368' }),
136
- trav3: label(778, 350, 184, 20, 'traverses', { fontSize: 13, color: '#5f6368' }),
233
+ rightTask: enode('object', 1060, 132, 168, 92, {
234
+ text: 'Task',
235
+ fontSize: 16,
236
+ }),
237
+ c1: ch(258, 'X'),
238
+ c2: ch(518, 'Y'),
239
+ c3: ch(778, 'Z'),
240
+ t1: tk(266, 'A'),
241
+ t2: tk(526, 'B'),
242
+ t3: tk(786, 'C'),
243
+ trav1: label(258, 350, 184, 20, 'traverses', {
244
+ fontSize: 13,
245
+ color: '#5f6368',
246
+ }),
247
+ trav2: label(518, 350, 184, 20, 'traverses', {
248
+ fontSize: 13,
249
+ color: '#5f6368',
250
+ }),
251
+ trav3: label(778, 350, 184, 20, 'traverses', {
252
+ fontSize: 13,
253
+ color: '#5f6368',
254
+ }),
137
255
  use1: label(258, 504, 184, 20, 'uses', { fontSize: 13, color: '#5f6368' }),
138
256
  use2: label(518, 504, 184, 20, 'uses', { fontSize: 13, color: '#5f6368' }),
139
257
  use3: label(778, 504, 184, 20, 'uses', { fontSize: 13, color: '#5f6368' }),
140
- l1: line(350, 248, 350, 392), l2: line(610, 248, 610, 392), l3: line(870, 248, 870, 392),
141
- l4: line(350, 488, 350, 540), l5: line(610, 488, 610, 540), l6: line(870, 488, 870, 540),
258
+ l1: attach('s1', 'c1'),
259
+ l2: attach('s2', 'c2'),
260
+ l3: attach('s3', 'c3'),
261
+ l4: attach('c1', 't1'),
262
+ l5: attach('c2', 't2'),
263
+ l6: attach('c3', 't3'),
142
264
  };
143
265
  }
144
266
  // ── Service blueprint (six swimlanes) ─────────────────────────────────
145
267
  function blueprint() {
146
- const lanes = ['Physical Evidence', 'Customer Actions', 'On-stage Actions', 'Back-stage Actions', 'Support Processes', 'Support Systems'];
147
- const laneFill = ['#fdeef2', '#fbd5e0', '#dff0fb', '#d4e9f8', '#d4e9f8', '#d4e9f8'];
268
+ const lanes = [
269
+ 'Physical Evidence',
270
+ 'Customer Actions',
271
+ 'On-stage Actions',
272
+ 'Back-stage Actions',
273
+ 'Support Processes',
274
+ 'Support Systems',
275
+ ];
276
+ const laneFill = [
277
+ '#fdeef2',
278
+ '#fbd5e0',
279
+ '#dff0fb',
280
+ '#d4e9f8',
281
+ '#d4e9f8',
282
+ '#d4e9f8',
283
+ ];
148
284
  const out = {};
149
285
  lanes.forEach((name, i) => {
150
286
  const y = 40 + i * 150;
151
- out[`lane${i}`] = rect(36, y, 1280, 150, { fill: laneFill[i], stroke: laneFill[i], sw: 0 });
152
- out[`laneL${i}`] = label(52, y + 12, 260, 22, name, { fontSize: 15, align: TextAlign.Left });
287
+ out[`lane${i}`] = rect(36, y, 1280, 150, {
288
+ fill: laneFill[i],
289
+ stroke: laneFill[i],
290
+ sw: 0,
291
+ });
292
+ out[`laneL${i}`] = label(52, y + 12, 260, 22, name, {
293
+ fontSize: 15,
294
+ align: TextAlign.Left,
295
+ });
153
296
  });
154
297
  const chev = (x, y, fill, t) => enode('activity', x, y, 200, 60, { fill, text: t, fontSize: 14 });
155
298
  const box = (x, y, fill, t) => enode('object', x, y, 200, 60, { fill, text: t, fontSize: 14 });
@@ -157,19 +300,27 @@ function blueprint() {
157
300
  Object.assign(out, {
158
301
  af: rect(280, 64, 220, 70, { text: 'Admission Form' }),
159
302
  cf: rect(1010, 64, 220, 70, { text: 'Confirmation' }),
160
- sf: chev(280, 214, P, 'Send Form'), fs: chev(600, 214, P, 'Follow status'), rd: chev(1010, 214, P, 'Receive decision'),
161
- fh: chev(280, 364, B, 'Form handling'), cs: chev(600, 364, B, 'Customer support'), ct: chev(1010, 364, B, 'Contact'),
162
- csv: chev(280, 514, B, 'Customer service'), cfu: box(1010, 514, B, 'Customer follow-up'),
163
- pf: chev(600, 664, B, 'Process Form'), dec: chev(900, 664, B, 'Decision [Accept|Reject]'),
164
- crm: box(280, 814, B, 'CRM Application'), cms: box(600, 814, B, 'Case Management'), pay: box(900, 814, B, 'Payments System'),
165
- a1: line(380, 134, 380, 214, { arrow: true }),
166
- a2: line(480, 244, 600, 244, { arrow: true }),
167
- a3: line(800, 244, 1010, 244, { arrow: true }),
168
- a4: line(320, 274, 320, 364, { arrow: true }),
169
- a5: line(320, 424, 320, 514, { arrow: true }),
170
- a6: line(700, 724, 900, 694, { arrow: true }),
171
- a7: line(380, 874, 600, 844, { arrow: true }),
172
- a8: line(800, 844, 900, 844, { arrow: true }),
303
+ sf: chev(280, 214, P, 'Send Form'),
304
+ fs: chev(600, 214, P, 'Follow status'),
305
+ rd: chev(1010, 214, P, 'Receive decision'),
306
+ fh: chev(280, 364, B, 'Form handling'),
307
+ cs: chev(600, 364, B, 'Customer support'),
308
+ ct: chev(1010, 364, B, 'Contact'),
309
+ csv: chev(280, 514, B, 'Customer service'),
310
+ cfu: box(1010, 514, B, 'Customer follow-up'),
311
+ pf: chev(600, 664, B, 'Process Form'),
312
+ dec: chev(900, 664, B, 'Decision [Accept|Reject]'),
313
+ crm: box(280, 814, B, 'CRM Application'),
314
+ cms: box(600, 814, B, 'Case Management'),
315
+ pay: box(900, 814, B, 'Payments System'),
316
+ a1: attach('af', 'sf', { arrow: true }),
317
+ a2: attach('sf', 'fs', { arrow: true }),
318
+ a3: attach('fs', 'rd', { arrow: true }),
319
+ a4: attach('sf', 'fh', { arrow: true }),
320
+ a5: attach('fh', 'csv', { arrow: true }),
321
+ a6: attach('pf', 'dec', { arrow: true }),
322
+ a7: attach('crm', 'cms', { arrow: true }),
323
+ a8: attach('cms', 'pay', { arrow: true }),
173
324
  });
174
325
  return out;
175
326
  }
@@ -179,20 +330,111 @@ function orgChart() {
179
330
  const u = (x, y, w, t) => enode('object', x, y, w, 64, { fill: cyan, text: t, fontSize: 16 });
180
331
  return {
181
332
  org: u(420, 40, 180, 'Organisation'),
182
- a: u(120, 200, 200, 'Business Unit A'), b: u(410, 200, 200, 'Business Unit B'), c: u(700, 200, 200, 'Business Unit C'),
183
- a1: u(60, 360, 170, 'Group A-1'), a2: u(260, 360, 170, 'Group A-2'), c1: u(715, 360, 170, 'Group C-1'),
184
- e1: line(510, 104, 510, 150), e2: line(220, 150, 800, 150),
185
- e3: line(220, 150, 220, 200), e4: line(510, 150, 510, 200), e5: line(800, 150, 800, 200),
186
- e6: line(220, 264, 220, 320), e7: line(145, 320, 345, 320),
187
- e8: line(145, 320, 145, 360), e9: line(345, 320, 345, 360),
188
- e10: line(800, 264, 800, 360),
333
+ a: u(120, 200, 200, 'Business Unit A'),
334
+ b: u(410, 200, 200, 'Business Unit B'),
335
+ c: u(700, 200, 200, 'Business Unit C'),
336
+ a1: u(60, 360, 170, 'Group A-1'),
337
+ a2: u(260, 360, 170, 'Group A-2'),
338
+ c1: u(715, 360, 170, 'Group C-1'),
339
+ // Attached orthogonal connectors: the router draws the org-chart elbows
340
+ // and the links follow when units are moved around.
341
+ e1: attach('org', 'a', { mode: ConnectorMode.Orthogonal }),
342
+ e2: attach('org', 'b', { mode: ConnectorMode.Orthogonal }),
343
+ e3: attach('org', 'c', { mode: ConnectorMode.Orthogonal }),
344
+ e4: attach('a', 'a1', { mode: ConnectorMode.Orthogonal }),
345
+ e5: attach('a', 'a2', { mode: ConnectorMode.Orthogonal }),
346
+ e6: attach('c', 'c1', { mode: ConnectorMode.Orthogonal }),
189
347
  };
190
348
  }
191
- const single = (el) => ({ a: el });
349
+ // ── EDGY dynamic (the relational metamodel, on the facets background) ──
350
+ /**
351
+ * Background scale (from REF coords to model coords): large enough that the
352
+ * default-size nodes breathe inside each zone. The background is cropped to
353
+ * the circles (`cropToCircles`), so its xywh covers CROP × DYN_SCALE.
354
+ */
355
+ export const DYN_SCALE = 4.8;
356
+ /**
357
+ * The metamodel itself — the 12 elements and the 24 relations — now lives in
358
+ * `../metamodel.ts`, so the role vocabulary can DERIVE from it without this
359
+ * module and that one importing each other. Re-exported here under the names
360
+ * they have always had: nothing that reads them had to change.
361
+ */
362
+ export { EDGY_DYNAMIC_NODES, EDGY_DYNAMIC_RELATIONS, } from '../metamodel.js';
363
+ /**
364
+ * Reference coords → template model coords: the background element sits at
365
+ * (0,0) and renders the cropped circles box, so a reference point maps to
366
+ * `(p - CROP.origin) × DYN_SCALE`.
367
+ */
368
+ export function dynToModel(refX, refY) {
369
+ return [(refX - CROP.x) * DYN_SCALE, (refY - CROP.y) * DYN_SCALE];
370
+ }
371
+ function dynamic() {
372
+ const out = {
373
+ bg: {
374
+ type: 'edgy',
375
+ showLabels: false,
376
+ showPictos: false,
377
+ cropToCircles: true,
378
+ // The frame a finding is attributed to. Stamped here and nowhere else in
379
+ // this template: the background is what makes the board an EDGY board.
380
+ role: EDGY_ROLE.facets,
381
+ xywh: `[0,0,${CROP.w * DYN_SCALE},${CROP.h * DYN_SCALE}]`,
382
+ },
383
+ };
384
+ for (const [key, { kind, cx, cy, w, zone }] of Object.entries(EDGY_DYNAMIC_NODES)) {
385
+ const nw = w ?? NODE_SIZE[kind].w;
386
+ const nh = NODE_SIZE[kind].h;
387
+ const [mx, my] = dynToModel(cx, cy);
388
+ const name = edgyElementLabel(key);
389
+ out[key] = enode(kind, mx - nw / 2, my - nh / 2, nw, nh, {
390
+ text: name,
391
+ fill: EDGY_ZONE_FILL[zone],
392
+ // The OFFICIAL element, not just its kind: this template IS the
393
+ // metamodel, so its Purpose is a Purpose and not merely an outcome.
394
+ role: EDGY_ROLE[key],
395
+ });
396
+ }
397
+ EDGY_DYNAMIC_RELATIONS.forEach(([src, dst, verb, t], i) => {
398
+ out[`rel${i}`] = {
399
+ type: 'connector',
400
+ mode: ConnectorMode.Straight,
401
+ stroke: NODE_STROKE,
402
+ strokeWidth: 2,
403
+ strokeStyle: StrokeStyle.Solid,
404
+ frontEndpointStyle: PointStyle.None,
405
+ rearEndpointStyle: PointStyle.None,
406
+ source: { id: src },
407
+ target: { id: dst },
408
+ // The verb, as a ROLE — one per canonical verb, derived from this very
409
+ // table (`../roles.ts`). Source is the subject and target the object
410
+ // (`docs/adr/0010` tier 1), which is exactly how the row above reads, so
411
+ // `edgy.non-canonical-link` finds all 24 of these sentences legal.
412
+ role: EDGY_VERB_ROLE[verb],
413
+ // Native connector label: the verb travels with the link. The x/y are
414
+ // re-centred on the path at the first layout, but the w/h ARE the label
415
+ // box — size it to the verb so the text lays out on one line. The
416
+ // distance slides the verb along the link like the reference diagram.
417
+ text: surfaceText(verb),
418
+ labelXYWH: edgyVerbLabelXYWH(verb),
419
+ labelOffset: { distance: t ?? EDGY_RELATION_LABEL_DISTANCE },
420
+ };
421
+ });
422
+ return out;
423
+ }
424
+ const single = (el) => ({
425
+ a: el,
426
+ });
192
427
  const ATTRS = 'width="100%" height="100%" viewBox="0 0 135 80" xmlns="http://www.w3.org/2000/svg"';
193
428
  function tpl(name, preview, elements) {
194
- return { name, type: 'template', preview, content: makeTemplateSnapshot(elements, name) };
429
+ return {
430
+ name,
431
+ type: 'template',
432
+ preview,
433
+ content: makeTemplateSnapshot(elements, name),
434
+ };
195
435
  }
436
+ /** Exported for direct insertion from the EDGY senior toolbar menu. */
437
+ export const edgyDynamicTemplate = tpl('EDGY dynamic', `<svg ${ATTRS}><circle cx="55" cy="34" r="18" fill="#00ea4e" opacity="0.9"/><circle cx="80" cy="34" r="18" fill="#034cee" opacity="0.9"/><circle cx="67" cy="54" r="18" fill="#ff0056" opacity="0.9"/><path d="M55 30 H80 M56 31 L66 52 M79 31 L69 52" stroke="#fff" stroke-width="2"/><rect x="51" y="26" width="8" height="8" fill="#fff"/><circle cx="80" cy="30" r="4" fill="#fff"/><path d="M62 49 h6 l3 3 -3 3 h-6 z" fill="#fff"/></svg>`, dynamic());
196
438
  export const edgyTemplateCategory = {
197
439
  name: 'EDGY',
198
440
  templates: [
@@ -200,8 +442,17 @@ export const edgyTemplateCategory = {
200
442
  tpl('Customer journey', `<svg ${ATTRS} fill="none"><path d="M20 24 H110 L122 40 L110 56 H20 Z" fill="#f3a3c0"/><rect x="26" y="30" width="22" height="20" fill="none" stroke="#fff"/><rect x="54" y="30" width="22" height="20" fill="none" stroke="#fff"/><rect x="82" y="30" width="22" height="20" fill="none" stroke="#fff"/></svg>`, journey()),
201
443
  tpl('Service blueprint', `<svg ${ATTRS} fill="none"><rect x="8" y="14" width="119" height="16" fill="#fbd5e0"/><rect x="8" y="32" width="119" height="34" fill="#d4e9f8"/><rect x="20" y="18" width="22" height="9" fill="#f5246e" opacity="0.5"/><rect x="20" y="40" width="22" height="9" fill="#2f6ff0" opacity="0.4"/><rect x="60" y="40" width="22" height="9" fill="#2f6ff0" opacity="0.4"/></svg>`, blueprint()),
202
444
  tpl('Organisation chart', `<svg ${ATTRS} fill="none"><rect x="52" y="12" width="32" height="14" rx="2" fill="#4fd0ea"/><rect x="14" y="38" width="32" height="14" rx="2" fill="#4fd0ea"/><rect x="52" y="38" width="32" height="14" rx="2" fill="#4fd0ea"/><rect x="90" y="38" width="32" height="14" rx="2" fill="#4fd0ea"/><path d="M68 26 V32 M30 32 H106 M30 32 V38 M68 32 V38 M106 32 V38" stroke="#262626"/></svg>`, orgChart()),
203
- tpl('Facets diagram', `<svg ${ATTRS}><circle cx="55" cy="34" r="18" fill="#00ea4e" opacity="0.9"/><circle cx="80" cy="34" r="18" fill="#034cee" opacity="0.9"/><circle cx="67" cy="54" r="18" fill="#ff0056" opacity="0.9"/></svg>`, single({ type: 'edgy', xywh: `[0,0,${REF_W * 1.5},${REF_H * 1.5}]` })),
204
- tpl('People', `<svg ${ATTRS} fill="#262626"><circle cx="67" cy="32" r="9" fill="none" stroke="#262626" stroke-width="2.4"/><path d="M50 60 a17 17 0 0 1 34 0" fill="none" stroke="#262626" stroke-width="2.4"/></svg>`, { n: enode('people', 0, 0, 64, 64), l: label(-28, 70, 120, 24, 'People', { fontSize: 16 }) }),
445
+ tpl('Facets diagram', `<svg ${ATTRS}><circle cx="55" cy="34" r="18" fill="#00ea4e" opacity="0.9"/><circle cx="80" cy="34" r="18" fill="#034cee" opacity="0.9"/><circle cx="67" cy="54" r="18" fill="#ff0056" opacity="0.9"/></svg>`, single({
446
+ type: 'edgy',
447
+ cropToCircles: true,
448
+ role: EDGY_ROLE.facets,
449
+ xywh: `[0,0,${CROP_LABELED.w * 1.5},${CROP_LABELED.h * 1.5}]`,
450
+ })),
451
+ edgyDynamicTemplate,
452
+ tpl('People', `<svg ${ATTRS} fill="#262626"><circle cx="67" cy="32" r="9" fill="none" stroke="#262626" stroke-width="2.4"/><path d="M50 60 a17 17 0 0 1 34 0" fill="none" stroke="#262626" stroke-width="2.4"/></svg>`, {
453
+ n: enode('people', 0, 0, 64, 64),
454
+ l: label(-28, 70, 120, 24, 'People', { fontSize: 16 }),
455
+ }),
205
456
  tpl('Outcome', `<svg ${ATTRS} fill="none"><rect x="20" y="24" width="95" height="34" rx="6" stroke="#262626" stroke-width="2"/></svg>`, single(enode('outcome', 0, 0, 130, 80, { text: 'Outcome' }))),
206
457
  tpl('Object', `<svg ${ATTRS} fill="none"><rect x="20" y="24" width="95" height="34" stroke="#262626" stroke-width="2"/></svg>`, single(enode('object', 0, 0, 130, 80, { text: 'Object' }))),
207
458
  tpl('Activity', `<svg ${ATTRS} fill="none"><path d="M20 24 H98 L116 41 H116 L98 58 H20 Z" stroke="#262626" stroke-width="2" stroke-linejoin="round"/></svg>`, single(enode('activity', 0, 0, 140, 80, { text: 'Activity' }))),