@formicoidea/labre-framework-edgy 0.37.0 → 0.38.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/actions.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { BlockStdScope } from '@formicoidea/labre-core/std';
2
2
  import { type GfxController } from '@formicoidea/labre-core/std/gfx';
3
- export type EdgyBoxKind = 'outcome' | 'object' | 'activity';
3
+ import { type EdgyBoxKind } from './presets.js';
4
4
  /**
5
5
  * Create the Enterprise Design Facets diagram centred on the viewport —
6
6
  * cropped to the circles + facet labels (no dead margins around the Venn).
package/dist/actions.js CHANGED
@@ -2,23 +2,26 @@ import { DefaultTool } 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
4
  import { createTemplateJob } from '@formicoidea/labre-core/gfx/template';
5
- import { ConnectorMode, FontFamily, PointStyle, ShapeStyle, StrokeStyle, } from '@formicoidea/labre-core/model';
5
+ import { ConnectorMode, PointStyle, StrokeStyle } from '@formicoidea/labre-core/model';
6
6
  import { translateKey } from '@formicoidea/labre-core/shared/services';
7
7
  import { Bound } from '@formicoidea/labre-core/global/gfx';
8
8
  import { GfxControllerIdentifier } from '@formicoidea/labre-core/std/gfx';
9
9
  import { CROP_LABELED } from './consts.js';
10
- import { ACTIVITY_VERTICES, INNER_FONT_SIZE, LABEL_FONT_SIZE, LABEL_GAP, NODE_FILL, NODE_LABEL, nodeLabelKey, NODE_SIZE, NODE_STROKE, NODE_STROKE_WIDTH, OUTCOME_RADIUS, } from './node/consts.js';
10
+ import { LABEL_GAP, NODE_LABEL, nodeLabelKey, NODE_SIZE, NODE_STROKE, } from './node/consts.js';
11
+ import { EDGY_LABEL_W, edgyLabelProps, edgyNodeProps, edgyNodeTextProps, FACETS_SCALE, } from './presets.js';
11
12
  import { EDGY_ROLE } from './roles.js';
12
- import { edgyDynamicTemplate } from './templates/index.js';
13
+ import { edgyDynamicTemplate } from './templates/dynamic.js';
13
14
  /** Stroke width the 24 template relations are drawn with. */
14
15
  const RELATION_STROKE_WIDTH = 2;
15
- /** Default facets-diagram size (REF aspect, scaled up so it reads on canvas). */
16
- const FACETS_SCALE = 1.5;
16
+ /**
17
+ * Standalone creation actions for the EDGY toolbox — the behaviour layer,
18
+ * lifted out of `toolbar/edgy-menu.ts` by PF3 so the menu can become a pure
19
+ * renderer over the command registry. They emit no telemetry: the single
20
+ * emission point is `runCommand` (`docs/adr/0008`).
21
+ */
17
22
  /** Default blank-board size. */
18
23
  const BOARD_W = 1600;
19
24
  const BOARD_H = 1000;
20
- /** Height of the native People free-text label. */
21
- const LABEL_H = LABEL_FONT_SIZE + 8;
22
25
  const gfxOf = (std) => std.get(GfxControllerIdentifier);
23
26
  function finish(gfx, id) {
24
27
  gfx.doc.captureSync();
@@ -26,38 +29,6 @@ function finish(gfx, id) {
26
29
  gfx.selection.set({ elements: [id], editing: false });
27
30
  // Keep the palette open (native sub-menu behaviour).
28
31
  }
29
- /**
30
- * Shared props for an EDGY node shape.
31
- *
32
- * The `role` is the PERSISTED KIND and nothing more: somebody picking "Object"
33
- * in the palette has said "this is an object", not "this is a Channel". The
34
- * twelve official elements are named by the metamodel template, which knows
35
- * which of them it is drawing; a base element created here specialises
36
- * `edgy:element` through its kind and is judged by every rule written on the
37
- * root — the overlap rule — and by none written on a leaf.
38
- */
39
- const baseShapeProps = (kind) => ({
40
- type: 'edgyNode',
41
- kind,
42
- role: EDGY_ROLE[kind],
43
- filled: true,
44
- fillColor: NODE_FILL,
45
- strokeColor: NODE_STROKE,
46
- shapeStyle: ShapeStyle.General,
47
- roughness: 0,
48
- });
49
- /** Add a native free-text label (Inter), used for the People node. */
50
- function addLabel(surface, text, x, y) {
51
- return surface.addElement({
52
- type: 'text',
53
- text,
54
- fontFamily: FontFamily.Inter,
55
- fontSize: LABEL_FONT_SIZE,
56
- color: NODE_STROKE,
57
- textAlign: 'center',
58
- xywh: new Bound(x, y, 120, LABEL_H).serialize(),
59
- });
60
- }
61
32
  /**
62
33
  * Create the Enterprise Design Facets diagram centred on the viewport —
63
34
  * cropped to the circles + facet labels (no dead margins around the Venn).
@@ -119,21 +90,13 @@ export function createEdgyBox(std, kind) {
119
90
  return;
120
91
  const { w, h } = NODE_SIZE[kind];
121
92
  const { centerX: cx, centerY: cy } = gfx.viewport;
122
- const shapeType = kind === 'activity' ? 'polygon' : 'rect';
123
93
  const id = surface.addElement({
124
- ...baseShapeProps(kind),
125
- shapeType,
126
- strokeWidth: NODE_STROKE_WIDTH,
127
- radius: kind === 'outcome' ? OUTCOME_RADIUS : 0,
128
- vertices: kind === 'activity' ? ACTIVITY_VERTICES : null,
94
+ ...edgyNodeProps(kind, {
95
+ xywh: new Bound(cx - w / 2, cy - h / 2, w, h).serialize(),
96
+ }),
129
97
  // Translated HERE and once: what a gesture writes onto the canvas is
130
98
  // document content the moment it lands (`nodeLabelKey`).
131
- text: translateKey(std, nodeLabelKey(kind), NODE_LABEL[kind]),
132
- color: NODE_STROKE,
133
- fontFamily: FontFamily.Inter,
134
- fontSize: INNER_FONT_SIZE,
135
- textAlign: 'center',
136
- xywh: new Bound(cx - w / 2, cy - h / 2, w, h).serialize(),
99
+ ...edgyNodeTextProps(translateKey(std, nodeLabelKey(kind), NODE_LABEL[kind])),
137
100
  });
138
101
  finish(gfx, id);
139
102
  }
@@ -148,14 +111,10 @@ export function createEdgyPeople(std) {
148
111
  return;
149
112
  const { w, h } = NODE_SIZE.people;
150
113
  const { centerX: cx, centerY: cy } = gfx.viewport;
151
- const nodeId = surface.addElement({
152
- ...baseShapeProps('people'),
153
- shapeType: 'ellipse',
154
- // No visible outline — People is just the glyph; the ellipse is the bound.
155
- strokeWidth: 0,
114
+ const nodeId = surface.addElement(edgyNodeProps('people', {
156
115
  xywh: new Bound(cx - w / 2, cy - h / 2, w, h).serialize(),
157
- });
158
- const labelId = addLabel(surface, translateKey(std, nodeLabelKey('people'), NODE_LABEL.people), cx - 60, cy + h / 2 + LABEL_GAP);
116
+ }));
117
+ const labelId = surface.addElement(edgyLabelProps(translateKey(std, nodeLabelKey('people'), NODE_LABEL.people), cx - EDGY_LABEL_W / 2, cy + h / 2 + LABEL_GAP));
159
118
  const [, result] = std.command.exec(createGroupCommand, {
160
119
  elements: [nodeId, labelId],
161
120
  });
package/dist/index.d.ts CHANGED
@@ -2,3 +2,4 @@ export { edgyCommandIcons, edgyCommands } from './commands.js';
2
2
  export { edgyTranslationEntries } from './translations.js';
3
3
  export { EDGY_ROLE, EDGY_ROLES, type EdgyRoleId } from './roles.js';
4
4
  export { EDGY_READING } from './reading.js';
5
+ export { EDGY_RULES } from './rules.js';
package/dist/index.js CHANGED
@@ -4,3 +4,4 @@ export { edgyTranslationEntries } from './translations.js';
4
4
  // library's own cross-framework guard test — can read without an editor.
5
5
  export { EDGY_ROLE, EDGY_ROLES } from './roles.js';
6
6
  export { EDGY_READING } from './reading.js';
7
+ export { EDGY_RULES } from './rules.js';
@@ -0,0 +1,69 @@
1
+ import { type EdgyNodeKind, FontFamily, TextAlign } from '@formicoidea/labre-core/model';
2
+ /**
3
+ * What an EDGY artefact IS, as props — the ONE description the creation sites
4
+ * and the templates both read.
5
+ *
6
+ * Split out of `actions.ts` for the reason Wardley's and BPMN's `presets.ts`
7
+ * give, and which the 2026-09-09 template audit measured on this pack: the
8
+ * palette restated by hand what a node is, and the copy drifted. The four
9
+ * single-element templates had lost the `role` the toolbox has written since
10
+ * the EDGY roles landed (so `edgy.overlapping-artefacts` saw nothing, the auto
11
+ * legend legended nothing and the info panel would not open), People arrived
12
+ * ungrouped from its name, and the label box was 16/24/+70 against the
13
+ * toolbox's 18/26/+72. Those templates are DERIVED now
14
+ * (`templateFromCommand`), and the compositions that cannot derive are built
15
+ * on the helpers below — so there is nothing left to restate.
16
+ */
17
+ /** The three kinds the palette drops as a labelled box (People is not one). */
18
+ export type EdgyBoxKind = 'outcome' | 'object' | 'activity';
19
+ /**
20
+ * Default facets-diagram size (REF aspect, scaled up so it reads on canvas).
21
+ *
22
+ * Here rather than at the creation site because the "Facets diagram" template
23
+ * used to spell the same `1.5` out a second time.
24
+ */
25
+ export declare const FACETS_SCALE = 1.5;
26
+ /**
27
+ * An EDGY node, as props.
28
+ *
29
+ * The `role` is the PERSISTED KIND and nothing more: somebody picking "Object"
30
+ * in the palette has said "this is an object", not "this is a Channel". The
31
+ * twelve official elements are named by the metamodel template, which knows
32
+ * which of them it is drawing; a base element created here specialises
33
+ * `edgy:element` through its kind and is judged by every rule written on the
34
+ * root — the overlap rule — and by none written on a leaf.
35
+ *
36
+ * No `text`: the inner words are content, added by the caller through
37
+ * {@link edgyNodeTextProps} (at placement, translated once) or overridden by an
38
+ * illustrative template.
39
+ */
40
+ export declare function edgyNodeProps(kind: EdgyNodeKind, box: {
41
+ xywh: string;
42
+ }): Record<string, unknown> & {
43
+ type: string;
44
+ };
45
+ /**
46
+ * The words INSIDE a box, as props.
47
+ *
48
+ * `text` is a plain STRING, as `Surface.addElement` takes it; a snapshot writes
49
+ * the same field as a serialized `Y.Text`, which is the one key a hand-written
50
+ * template overrides on the way in.
51
+ */
52
+ export declare function edgyNodeTextProps(text: string, fontSize?: number): {
53
+ text: string;
54
+ color: string;
55
+ fontFamily: FontFamily;
56
+ fontSize: number;
57
+ textAlign: TextAlign;
58
+ };
59
+ /** Width of the free-text label a People node travels with. */
60
+ export declare const EDGY_LABEL_W = 120;
61
+ /** Height of that label (Inter, size 18). */
62
+ export declare const EDGY_LABEL_H: number;
63
+ /**
64
+ * The NAME under a People node, as a native free-text element — the one EDGY
65
+ * artefact whose name is not stored on the shape itself.
66
+ */
67
+ export declare function edgyLabelProps(text: string, x: number, y: number): Record<string, unknown> & {
68
+ type: string;
69
+ };
@@ -0,0 +1,86 @@
1
+ import { FontFamily, ShapeStyle, TextAlign, } from '@formicoidea/labre-core/model';
2
+ import { Bound } from '@formicoidea/labre-core/global/gfx';
3
+ import { ACTIVITY_VERTICES, INNER_FONT_SIZE, LABEL_FONT_SIZE, NODE_FILL, NODE_STROKE, NODE_STROKE_WIDTH, OUTCOME_RADIUS, } from './node/consts.js';
4
+ import { EDGY_ROLE } from './roles.js';
5
+ /**
6
+ * Default facets-diagram size (REF aspect, scaled up so it reads on canvas).
7
+ *
8
+ * Here rather than at the creation site because the "Facets diagram" template
9
+ * used to spell the same `1.5` out a second time.
10
+ */
11
+ export const FACETS_SCALE = 1.5;
12
+ /**
13
+ * An EDGY node, as props.
14
+ *
15
+ * The `role` is the PERSISTED KIND and nothing more: somebody picking "Object"
16
+ * in the palette has said "this is an object", not "this is a Channel". The
17
+ * twelve official elements are named by the metamodel template, which knows
18
+ * which of them it is drawing; a base element created here specialises
19
+ * `edgy:element` through its kind and is judged by every rule written on the
20
+ * root — the overlap rule — and by none written on a leaf.
21
+ *
22
+ * No `text`: the inner words are content, added by the caller through
23
+ * {@link edgyNodeTextProps} (at placement, translated once) or overridden by an
24
+ * illustrative template.
25
+ */
26
+ export function edgyNodeProps(kind, box) {
27
+ const people = kind === 'people';
28
+ return {
29
+ type: 'edgyNode',
30
+ kind,
31
+ role: EDGY_ROLE[kind],
32
+ filled: true,
33
+ fillColor: NODE_FILL,
34
+ strokeColor: NODE_STROKE,
35
+ shapeStyle: ShapeStyle.General,
36
+ roughness: 0,
37
+ shapeType: people ? 'ellipse' : kind === 'activity' ? 'polygon' : 'rect',
38
+ // No visible outline on People — it IS the glyph the renderer draws, and
39
+ // the ellipse is only its bound.
40
+ strokeWidth: people ? 0 : NODE_STROKE_WIDTH,
41
+ // The two keys only a BOX has: People carries neither, and a key some
42
+ // kinds write and others do not is the truth about the shapes.
43
+ ...(people
44
+ ? {}
45
+ : {
46
+ radius: kind === 'outcome' ? OUTCOME_RADIUS : 0,
47
+ vertices: kind === 'activity' ? ACTIVITY_VERTICES : null,
48
+ }),
49
+ xywh: box.xywh,
50
+ };
51
+ }
52
+ /**
53
+ * The words INSIDE a box, as props.
54
+ *
55
+ * `text` is a plain STRING, as `Surface.addElement` takes it; a snapshot writes
56
+ * the same field as a serialized `Y.Text`, which is the one key a hand-written
57
+ * template overrides on the way in.
58
+ */
59
+ export function edgyNodeTextProps(text, fontSize = INNER_FONT_SIZE) {
60
+ return {
61
+ text,
62
+ color: NODE_STROKE,
63
+ fontFamily: FontFamily.Inter,
64
+ fontSize,
65
+ textAlign: TextAlign.Center,
66
+ };
67
+ }
68
+ /** Width of the free-text label a People node travels with. */
69
+ export const EDGY_LABEL_W = 120;
70
+ /** Height of that label (Inter, size 18). */
71
+ export const EDGY_LABEL_H = LABEL_FONT_SIZE + 8;
72
+ /**
73
+ * The NAME under a People node, as a native free-text element — the one EDGY
74
+ * artefact whose name is not stored on the shape itself.
75
+ */
76
+ export function edgyLabelProps(text, x, y) {
77
+ return {
78
+ type: 'text',
79
+ text,
80
+ fontFamily: FontFamily.Inter,
81
+ fontSize: LABEL_FONT_SIZE,
82
+ color: NODE_STROKE,
83
+ textAlign: TextAlign.Center,
84
+ xywh: new Bound(x, y, EDGY_LABEL_W, EDGY_LABEL_H).serialize(),
85
+ };
86
+ }
@@ -0,0 +1,26 @@
1
+ import { type Template } from '@formicoidea/labre-core/gfx/template';
2
+ /**
3
+ * The "EDGY dynamic" template — the relational metamodel drawn on the facets
4
+ * background, and the ONE template of this pack that cannot be derived from
5
+ * its command: `edgy.insertDynamic` INSERTS this very object
6
+ * (`createEdgyDynamic`), so deriving it from itself would be circular.
7
+ *
8
+ * Its own module for exactly that reason: `actions.ts` needs the template,
9
+ * `templates/index.ts` needs the command registry to derive the rest of the
10
+ * palette, and a single templates module would close the loop
11
+ * templates → commands → actions → templates.
12
+ */
13
+ /**
14
+ * Background scale (from REF coords to model coords): large enough that the
15
+ * default-size nodes breathe inside each zone. The background is cropped to
16
+ * the circles (`cropToCircles`), so its xywh covers CROP × DYN_SCALE.
17
+ */
18
+ export declare const DYN_SCALE = 4.8;
19
+ /**
20
+ * Reference coords → template model coords: the background element sits at
21
+ * (0,0) and renders the cropped circles box, so a reference point maps to
22
+ * `(p - CROP.origin) × DYN_SCALE`.
23
+ */
24
+ export declare function dynToModel(refX: number, refY: number): [number, number];
25
+ /** Exported for direct insertion from the EDGY senior toolbar menu. */
26
+ export declare const edgyDynamicTemplate: Template;
@@ -0,0 +1,106 @@
1
+ import { makeTemplateSnapshot, surfaceText, } from '@formicoidea/labre-core/gfx/template';
2
+ import { ConnectorMode, PointStyle, StrokeStyle } from '@formicoidea/labre-core/model';
3
+ import { CROP } from '../consts.js';
4
+ import { EDGY_DYNAMIC_NODES, EDGY_DYNAMIC_RELATIONS, EDGY_ZONE_FILL, edgyElementLabel, } from '../metamodel.js';
5
+ import { NODE_SIZE, NODE_STROKE } from '../node/consts.js';
6
+ import { edgyNodeProps, edgyNodeTextProps } from '../presets.js';
7
+ import { EDGY_RELATION_LABEL_DISTANCE, edgyVerbLabelXYWH } from '../relation.js';
8
+ import { EDGY_ROLE, EDGY_VERB_ROLE } from '../roles.js';
9
+ /**
10
+ * The "EDGY dynamic" template — the relational metamodel drawn on the facets
11
+ * background, and the ONE template of this pack that cannot be derived from
12
+ * its command: `edgy.insertDynamic` INSERTS this very object
13
+ * (`createEdgyDynamic`), so deriving it from itself would be circular.
14
+ *
15
+ * Its own module for exactly that reason: `actions.ts` needs the template,
16
+ * `templates/index.ts` needs the command registry to derive the rest of the
17
+ * palette, and a single templates module would close the loop
18
+ * templates → commands → actions → templates.
19
+ */
20
+ /**
21
+ * Background scale (from REF coords to model coords): large enough that the
22
+ * default-size nodes breathe inside each zone. The background is cropped to
23
+ * the circles (`cropToCircles`), so its xywh covers CROP × DYN_SCALE.
24
+ */
25
+ export const DYN_SCALE = 4.8;
26
+ /**
27
+ * Reference coords → template model coords: the background element sits at
28
+ * (0,0) and renders the cropped circles box, so a reference point maps to
29
+ * `(p - CROP.origin) × DYN_SCALE`.
30
+ */
31
+ export function dynToModel(refX, refY) {
32
+ return [(refX - CROP.x) * DYN_SCALE, (refY - CROP.y) * DYN_SCALE];
33
+ }
34
+ function dynamic() {
35
+ const out = {
36
+ bg: {
37
+ type: 'edgy',
38
+ showLabels: false,
39
+ showPictos: false,
40
+ cropToCircles: true,
41
+ // The frame a finding is attributed to. Stamped here and nowhere else in
42
+ // this template: the background is what makes the board an EDGY board.
43
+ role: EDGY_ROLE.facets,
44
+ xywh: `[0,0,${CROP.w * DYN_SCALE},${CROP.h * DYN_SCALE}]`,
45
+ },
46
+ };
47
+ for (const [key, { kind, cx, cy, w, zone }] of Object.entries(EDGY_DYNAMIC_NODES)) {
48
+ const nw = w ?? NODE_SIZE[kind].w;
49
+ const nh = NODE_SIZE[kind].h;
50
+ const [mx, my] = dynToModel(cx, cy);
51
+ const name = edgyElementLabel(key);
52
+ out[key] = {
53
+ // What an EDGY node IS — the same description the toolbox draws from.
54
+ ...edgyNodeProps(kind, {
55
+ xywh: `[${mx - nw / 2},${my - nh / 2},${nw},${nh}]`,
56
+ }),
57
+ ...edgyNodeTextProps(name),
58
+ // A snapshot stores the words as a serialized `Y.Text`, where
59
+ // `addElement` takes a plain string.
60
+ text: surfaceText(name),
61
+ fillColor: EDGY_ZONE_FILL[zone],
62
+ // The OFFICIAL element, not just its kind: this template IS the
63
+ // metamodel, so its Purpose is a Purpose and not merely an outcome.
64
+ role: EDGY_ROLE[key],
65
+ };
66
+ }
67
+ EDGY_DYNAMIC_RELATIONS.forEach(([src, dst, verb, t], i) => {
68
+ out[`rel${i}`] = {
69
+ type: 'connector',
70
+ mode: ConnectorMode.Straight,
71
+ stroke: NODE_STROKE,
72
+ strokeWidth: 2,
73
+ strokeStyle: StrokeStyle.Solid,
74
+ frontEndpointStyle: PointStyle.None,
75
+ rearEndpointStyle: PointStyle.None,
76
+ source: { id: src },
77
+ target: { id: dst },
78
+ // The verb, as a ROLE — one per canonical verb, derived from this very
79
+ // table (`../roles.ts`). Source is the subject and target the object
80
+ // (`docs/adr/0010` tier 1), which is exactly how the row above reads, so
81
+ // `edgy.non-canonical-link` finds all 24 of these sentences legal.
82
+ role: EDGY_VERB_ROLE[verb],
83
+ // Native connector label: the verb travels with the link. The x/y are
84
+ // re-centred on the path at the first layout, but the w/h ARE the label
85
+ // box — size it to the verb so the text lays out on one line. The
86
+ // distance slides the verb along the link like the reference diagram.
87
+ text: surfaceText(verb),
88
+ labelXYWH: edgyVerbLabelXYWH(verb),
89
+ labelOffset: { distance: t ?? EDGY_RELATION_LABEL_DISTANCE },
90
+ };
91
+ });
92
+ return out;
93
+ }
94
+ const NAME = 'EDGY dynamic';
95
+ /** Exported for direct insertion from the EDGY senior toolbar menu. */
96
+ export const edgyDynamicTemplate = {
97
+ name: NAME,
98
+ type: 'template',
99
+ preview: `<svg width="100%" height="100%" viewBox="0 0 135 80" xmlns="http://www.w3.org/2000/svg"><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>`,
100
+ // Stamped by hand, and it is the one `commandId` in this pack that is a
101
+ // literal rather than a derivation: importing the registry here would close
102
+ // the very loop this module exists to avoid. The coverage test pairs the two
103
+ // and fails the day the command is renamed.
104
+ commandId: 'edgy.insertDynamic',
105
+ content: makeTemplateSnapshot(dynamic(), NAME),
106
+ };
@@ -1,23 +1,10 @@
1
- import { type Template, type TemplateCategory } from '@formicoidea/labre-core/gfx/template';
1
+ import { type TemplateCategory } from '@formicoidea/labre-core/gfx/template';
2
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.
3
+ * The metamodel itself — the 12 elements and the 24 relations — lives in
4
+ * `../metamodel.ts`, and the template that draws it in `./dynamic.ts`.
5
+ * Re-exported here under the names they have always had: nothing that reads
6
+ * them had to change.
13
7
  */
14
8
  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;
9
+ export { DYN_SCALE, dynToModel, edgyDynamicTemplate } from './dynamic.js';
23
10
  export declare const edgyTemplateCategory: TemplateCategory;
@@ -1,11 +1,41 @@
1
- import { makeTemplateSnapshot, surfaceText, } from '@formicoidea/labre-core/gfx/template';
1
+ import { makeTemplateSnapshot, surfaceText, surfaceYMap, templateFromCommand, } from '@formicoidea/labre-core/gfx/template';
2
2
  import { ConnectorMode, FontFamily, PointStyle, ShapeStyle, StrokeStyle, TextAlign, } from '@formicoidea/labre-core/model';
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';
3
+ import { edgyCommands } from '../commands.js';
4
+ import { NODE_STROKE, NODE_STROKE_WIDTH } from '../node/consts.js';
5
+ import { edgyNodeProps, edgyNodeTextProps } from '../presets.js';
6
+ import { edgyDynamicTemplate } from './dynamic.js';
7
+ /**
8
+ * The EDGY palette — DERIVED from the toolbox, one template per command.
9
+ *
10
+ * Every single-artefact entry below is what its command actually draws, run
11
+ * once against a recording surface. They used to be hand-written restatements
12
+ * and had drifted exactly as far as a copy drifts (audit of 2026-09-09): the
13
+ * four base elements carried no `role` at all, so `edgy.overlapping-artefacts`
14
+ * never saw them, the auto legend never legended them and the info panel would
15
+ * not open on them; "People" arrived as two loose elements where the button
16
+ * groups the glyph with its name; the texts were English literals outside the
17
+ * translation seam; the label box was 16/24/+70 against the toolbox's
18
+ * 18/26/+72; and `1.5` was spelled out a second time instead of
19
+ * `FACETS_SCALE`. Derived, none of that can happen again — and
20
+ * `templates-parity.unit.spec.ts` re-runs each command and compares.
21
+ *
22
+ * The five COMPOSITIONS stay hand-written: a customer journey or an org chart
23
+ * is an arrangement of a dozen artefacts, which no single command draws. They
24
+ * are built on the same presets, and the same test checks their composition.
25
+ */
26
+ /** The command a derived template is the picture of. Throws rather than skips. */
27
+ function byId(id) {
28
+ const command = edgyCommands.find(entry => entry.id === id);
29
+ if (!command)
30
+ throw new Error(`[edgy] templates: no command "${id}"`);
31
+ return command;
32
+ }
8
33
  // EDGY facet palette (header / pale sub-card).
34
+ //
35
+ // NOT `EDGY_ZONE_FILL`: the metamodel's palette is ONE pastel per zone — the
36
+ // colour an official element is drawn with — where the overview needs a
37
+ // saturated header AND the pale card that sits on it. Two colours per facet is
38
+ // a different table, not a drifted copy of that one.
9
39
  const C = {
10
40
  identity: ['#1ec873', '#9fe6c2'],
11
41
  organisation: ['#4fd0ea', '#c2eef8'],
@@ -39,39 +69,28 @@ function rect(x, y, w, h, opts = {}) {
39
69
  return el;
40
70
  }
41
71
  /**
42
- * An EDGY node (kind drives the native shape): outcome/object box, people,
43
- * activity chevron.
72
+ * An EDGY node of a COMPOSITION — {@link edgyNodeProps} says what that is, and
73
+ * this adds the two things an illustration decides for itself.
44
74
  *
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.
75
+ * `role: undefined` writes nothing, and it is the point: the journey, the
76
+ * blueprint, the org chart and the facets overview are DRAWINGS the engine
77
+ * never looks at, a decision `edgy-dynamic.unit.spec.ts` pins. Only the EDGY
78
+ * dynamic template — the one that IS the metamodel — stamps its elements, and
79
+ * it does so through the same preset.
49
80
  */
50
81
  function enode(kind, x, y, w, h, opts = {}) {
51
82
  const el = {
52
- type: 'edgyNode',
53
- kind,
54
- filled: true,
55
- fillColor: opts.fill ?? NODE_FILL,
56
- strokeColor: NODE_STROKE,
57
- strokeWidth: kind === 'people' ? 0 : NODE_STROKE_WIDTH,
58
- shapeStyle: ShapeStyle.General,
59
- roughness: 0,
60
- shapeType: kind === 'people' ? 'ellipse' : kind === 'activity' ? 'polygon' : 'rect',
61
- radius: kind === 'outcome' ? OUTCOME_RADIUS : 0,
62
- xywh: `[${x},${y},${w},${h}]`,
83
+ ...edgyNodeProps(kind, { xywh: `[${x},${y},${w},${h}]` }),
84
+ role: undefined,
85
+ ...(opts.fill === undefined ? {} : { fillColor: opts.fill }),
63
86
  };
64
- if (kind === 'activity')
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;
69
87
  if (opts.text != null) {
88
+ Object.assign(el, edgyNodeTextProps(opts.text, opts.fontSize));
89
+ // A snapshot stores the words as a serialized `Y.Text`, where
90
+ // `addElement` takes a plain string.
70
91
  el.text = surfaceText(opts.text);
71
- el.color = opts.textColor ?? NODE_STROKE;
72
- el.fontFamily = FontFamily.Inter;
73
- el.fontSize = opts.fontSize ?? INNER_FONT_SIZE;
74
- el.textAlign = TextAlign.Center;
92
+ if (opts.textColor !== undefined)
93
+ el.color = opts.textColor;
75
94
  }
76
95
  return el;
77
96
  }
@@ -86,6 +105,15 @@ function label(x, y, w, h, str, opts = {}) {
86
105
  xywh: `[${x},${y},${w},${h}]`,
87
106
  };
88
107
  }
108
+ /**
109
+ * The group a node and its name travel as — what `createEdgyPeople` writes and
110
+ * what these compositions had never carried, so dragging the person out of a
111
+ * journey left the word "Customer" behind.
112
+ */
113
+ const pair = (node, name) => ({
114
+ type: 'group',
115
+ children: surfaceYMap({ [node]: true, [name]: true }),
116
+ });
89
117
  function line(x1, y1, x2, y2, opts = {}) {
90
118
  return {
91
119
  type: 'connector',
@@ -204,6 +232,10 @@ function journey() {
204
232
  return {
205
233
  cust: enode('people', 40, 120, 64, 64, { fill: '#ffffff' }),
206
234
  custL: label(20, 192, 104, 24, 'Customer', { fontSize: 14 }),
235
+ // The person and its name travel together, as `createEdgyPeople` writes
236
+ // them. A group is placed AFTER its members: the id middleware remaps
237
+ // `children` against elements it has already seen.
238
+ custG: pair('cust', 'custL'),
207
239
  band: {
208
240
  type: 'shape',
209
241
  shapeType: 'polygon',
@@ -346,85 +378,16 @@ function orgChart() {
346
378
  e6: attach('c', 'c1', { mode: ConnectorMode.Orthogonal }),
347
379
  };
348
380
  }
349
- // ── EDGY dynamic (the relational metamodel, on the facets background) ──
350
381
  /**
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.
382
+ * The metamodel itself — the 12 elements and the 24 relations — lives in
383
+ * `../metamodel.ts`, and the template that draws it in `./dynamic.ts`.
384
+ * Re-exported here under the names they have always had: nothing that reads
385
+ * them had to change.
361
386
  */
362
387
  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
- });
388
+ export { DYN_SCALE, dynToModel, edgyDynamicTemplate } from './dynamic.js';
427
389
  const ATTRS = 'width="100%" height="100%" viewBox="0 0 135 80" xmlns="http://www.w3.org/2000/svg"';
390
+ /** A hand-composed template — what is left once the artefacts are derived. */
428
391
  function tpl(name, preview, elements) {
429
392
  return {
430
393
  name,
@@ -433,8 +396,6 @@ function tpl(name, preview, elements) {
433
396
  content: makeTemplateSnapshot(elements, name),
434
397
  };
435
398
  }
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());
438
399
  export const edgyTemplateCategory = {
439
400
  name: 'EDGY',
440
401
  templates: [
@@ -442,19 +403,14 @@ export const edgyTemplateCategory = {
442
403
  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()),
443
404
  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()),
444
405
  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()),
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
- })),
406
+ templateFromCommand(byId('edgy.addFacets'), `<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>`, 'Facets diagram'),
451
407
  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
- }),
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' }))),
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' }))),
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' }))),
408
+ // The blank board had no template at all until the palette was derived —
409
+ // the coverage test is what said so.
410
+ templateFromCommand(byId('edgy.addBoard'), `<svg ${ATTRS} fill="none"><rect x="8" y="9" width="119" height="62" rx="8" fill="#ffffff" stroke="#e0e0e0" stroke-width="2"/><path d="M52 30 H84 M53 31 L66 58 M83 31 L70 58" stroke="#262626" stroke-width="1.4"/><rect x="48" y="26" width="9" height="9" fill="#00ea4e"/><circle cx="84" cy="30" r="4.5" fill="#034cee"/><path d="M61 54 h8 l4 4 -4 4 h-8 z" fill="#ff0056"/></svg>`, 'EDGY board'),
411
+ templateFromCommand(byId('edgy.addPeople'), `<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>`),
412
+ templateFromCommand(byId('edgy.addOutcome'), `<svg ${ATTRS} fill="none"><rect x="20" y="24" width="95" height="34" rx="6" stroke="#262626" stroke-width="2"/></svg>`),
413
+ templateFromCommand(byId('edgy.addObject'), `<svg ${ATTRS} fill="none"><rect x="20" y="24" width="95" height="34" stroke="#262626" stroke-width="2"/></svg>`),
414
+ templateFromCommand(byId('edgy.addActivity'), `<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>`),
459
415
  ],
460
416
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@formicoidea/labre-framework-edgy",
3
3
  "description": "Labre edgy framework for @formicoidea/labre-core.",
4
- "version": "0.37.0",
4
+ "version": "0.38.1",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "author": "lajola",
@@ -31,8 +31,8 @@
31
31
  "dist"
32
32
  ],
33
33
  "dependencies": {
34
- "@formicoidea/labre-core": "0.37.0",
35
- "@formicoidea/labre-ddd-shared": "0.37.0",
34
+ "@formicoidea/labre-core": "0.38.1",
35
+ "@formicoidea/labre-ddd-shared": "0.38.1",
36
36
  "@preact/signals-core": "^1.8.0",
37
37
  "lit": "^3.2.0",
38
38
  "yjs": "^13.6.21"