@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
@@ -0,0 +1,142 @@
1
+ import { ConnectorElementModel } from '@formicoidea/labre-core/model';
2
+ import type { XYWH } from '@formicoidea/labre-core/global/gfx';
3
+ import type { SurfaceBlockModel } from '@formicoidea/labre-core/std/gfx';
4
+ import { type EdgyRoleId } from './roles.js';
5
+ /**
6
+ * **A relation drawn by hand names itself.**
7
+ *
8
+ * Until this module the 24 typed relations of the metamodel were born of the
9
+ * "EDGY dynamic" template and nowhere else: a user who wanted "this Process
10
+ * requires that Asset" on their own board had a plain connector, carrying no
11
+ * role, saying nothing, read by nothing (PO recette of 26/08/2026).
12
+ *
13
+ * ## Why ONE menu entry and not twenty-two
14
+ *
15
+ * The metamodel's 24 rows are 24 DISTINCT ordered pairs of elements, so the
16
+ * verb is entirely determined by the pair: there is exactly one thing a link
17
+ * from a Journey to a Channel can say, and it is "traverses". A palette of
18
+ * twenty-two verbs would therefore be twenty-two ways of asking the user a
19
+ * question the metamodel already answers — and twenty-one ways of getting it
20
+ * wrong. The toolbox arms ONE tool, stamped with the generic parent role
21
+ * `edgy:relation`, and this resolver reads the pair the user actually attached
22
+ * and writes the verb.
23
+ *
24
+ * ## What it writes, and what it refuses to write
25
+ *
26
+ * - **pair in the metamodel** → the verb's role (`EDGY_VERB_ROLE`) and the verb
27
+ * as the link's visible label, in the same shape the template lays one down
28
+ * (`text` + `labelXYWH` + `labelOffset`). The sentence is now legal, so E1
29
+ * says nothing.
30
+ * - **pair known only the OTHER way round** → the role of the reversed match is
31
+ * written anyway, and the edge is NOT flipped. This is deliberate: silently
32
+ * turning the arrow round would overrule a gesture the user made on purpose,
33
+ * and leaving the edge generic would hide the mistake behind the vaguer "not
34
+ * a sentence of the metamodel". Naming it makes E1 report the precise
35
+ * sentence — "a channel traverses a journey" — and `docs/adr/0010`'s M3
36
+ * (`edge.invert-direction`) is one click away on the contextual toolbar. The
37
+ * violation IS the affordance.
38
+ * - **pair in neither direction** → nothing is written. The edge keeps
39
+ * `edgy:relation`, and E1 reports it as a relation the metamodel does not
40
+ * declare between these two elements, which is exactly what it is.
41
+ * - **an end outside the alphabet** — a People node, a base Object, a plain
42
+ * sticky, an element of another framework — → nothing is written and nothing
43
+ * is said. Same contract as the `relation-endpoints` family: outside the
44
+ * alphabet is outside the conversation (PRD principle 8).
45
+ *
46
+ * ## Once, and only from the generic towards the verb
47
+ *
48
+ * A verb role already written is never rewritten — not by this module, not
49
+ * ever. Re-resolution is allowed only while the edge still carries the generic
50
+ * role, which is what makes "drop the end on the wrong element, then move it"
51
+ * work without the tool fighting the hand. And nothing here ever writes
52
+ * `edgy:relation` back over a verb: the resolution runs one way.
53
+ */
54
+ /**
55
+ * Recompute delay. The connector tool rewrites `target` on every pointer move
56
+ * of the drag, so resolving on the spot would name the relation after the FIRST
57
+ * element the cursor passed over — and, the naming being write-once, would then
58
+ * refuse to name it after the one the user actually dropped it on. Debouncing
59
+ * means the pair that gets read is the pair that ended up on the board.
60
+ *
61
+ * Same value as the validation engine's, and for the same reason: it is the
62
+ * shortest wait a human does not perceive at the end of a gesture.
63
+ */
64
+ export declare const EDGY_RELATION_DELAY_MS = 120;
65
+ /** Where the verb sits along the link. The templates' own default. */
66
+ export declare const EDGY_RELATION_LABEL_DISTANCE = 0.5;
67
+ /**
68
+ * The label BOX for a verb — `x`/`y` are re-centred on the path at the first
69
+ * layout, but the `w`/`h` are the box, so it has to be sized to the word or the
70
+ * verb wraps mid-syllable on a two-word relation like "is part of".
71
+ *
72
+ * Exported and read by `templates/index.ts` as well: the 24 relations the
73
+ * template draws and the ones drawn by hand have to look identical, and two
74
+ * copies of `verb.length * 9 + 24` is one copy too many.
75
+ */
76
+ export declare function edgyVerbLabelXYWH(verb: string): XYWH;
77
+ /** What the metamodel calls the relation between two elements. */
78
+ export interface EdgyRelationNaming {
79
+ /** The verb's own role: `edgy:traverses`. */
80
+ role: EdgyRoleId;
81
+ /** The verb, which also becomes the link's visible label. */
82
+ verb: string;
83
+ /**
84
+ * Whether the metamodel knows this pair only the OTHER way round. The edge is
85
+ * named all the same and never flipped — see the note at the top of the file.
86
+ */
87
+ reversed: boolean;
88
+ }
89
+ /**
90
+ * The verb the metamodel gives an ordered pair of element roles, direct match
91
+ * first, reversed match second, `null` when it knows neither.
92
+ *
93
+ * Pure, and exported for the unit spec: it is the whole opinion of this module,
94
+ * and a function taking two strings is testable without a surface, an editor or
95
+ * a DI container around it.
96
+ */
97
+ export declare function edgyRelationNaming(sourceRole: string | undefined, targetRole: string | undefined): EdgyRelationNaming | null;
98
+ /**
99
+ * Whether this edge is a generic EDGY relation with both ends ATTACHED — the
100
+ * one state this module acts on.
101
+ *
102
+ * `role === EDGY_ROLE.relation` exactly, never `roleIsA`: a verb role
103
+ * specialises the parent, and treating it as a candidate is precisely how the
104
+ * resolver would start rewriting its own work. An end released over empty
105
+ * canvas relates nothing to nothing, so it is not a candidate either — it
106
+ * becomes one the moment the user drags it onto something.
107
+ *
108
+ * A LOCKED edge is left alone, the guard `edge.invert-direction` applies for
109
+ * the same reason: locking is the user saying "do not touch this", and a
110
+ * resolver that wrote to one anyway would be the one gesture on this canvas
111
+ * that ignores it.
112
+ */
113
+ export declare function isUnnamedEdgyRelation(edge: ConnectorElementModel): boolean;
114
+ /**
115
+ * The naming this edge has earned from the elements it is attached to, or
116
+ * `null` when there is nothing to write. Reads only; the writes are below.
117
+ */
118
+ export declare function edgeRelationNaming(surface: SurfaceBlockModel, edge: ConnectorElementModel): EdgyRelationNaming | null;
119
+ /**
120
+ * Write the verb onto one edge.
121
+ *
122
+ * Through `surface.updateElement` and never through `EdgelessCRUDIdentifier`,
123
+ * for the reason `edge.invert-direction` spells out at length: `crud`'s wrapper
124
+ * calls `recordLastProps`, which would make this relation's label geometry the
125
+ * DEFAULT for every connector drawn afterwards. Naming one relation is a
126
+ * statement about that relation, never a style preference.
127
+ */
128
+ export declare function writeEdgyRelationName(surface: SurfaceBlockModel, edge: ConnectorElementModel, naming: EdgyRelationNaming): void;
129
+ /**
130
+ * Name every edge among `ids` that has earned a name, in ONE undo step.
131
+ *
132
+ * The seam, and the only function here that writes. Two phases on purpose: the
133
+ * namings are decided BEFORE anything is captured, so a flush that finds
134
+ * nothing to say costs no undo entry at all — an empty capture would turn every
135
+ * stray connector into a stop on the user's way back through their own history.
136
+ *
137
+ * Read-only is checked here rather than trusted: `surface.updateElement`
138
+ * THROWS on a read-only store, so a resolver that did not ask would turn
139
+ * opening a shared board in read-only into an exception per connector drawn on
140
+ * it.
141
+ */
142
+ export declare function resolveEdgyRelations(surface: SurfaceBlockModel, ids: Iterable<string>): EdgyRelationNaming[];
@@ -0,0 +1,188 @@
1
+ import { ConnectorElementModel } from '@formicoidea/labre-core/model';
2
+ import * as Y from 'yjs';
3
+ import { EDGY_PAIR_TO_VERB, EDGY_ROLE, EDGY_VERB_ROLE, edgyPairKey, } from './roles.js';
4
+ /**
5
+ * **A relation drawn by hand names itself.**
6
+ *
7
+ * Until this module the 24 typed relations of the metamodel were born of the
8
+ * "EDGY dynamic" template and nowhere else: a user who wanted "this Process
9
+ * requires that Asset" on their own board had a plain connector, carrying no
10
+ * role, saying nothing, read by nothing (PO recette of 26/08/2026).
11
+ *
12
+ * ## Why ONE menu entry and not twenty-two
13
+ *
14
+ * The metamodel's 24 rows are 24 DISTINCT ordered pairs of elements, so the
15
+ * verb is entirely determined by the pair: there is exactly one thing a link
16
+ * from a Journey to a Channel can say, and it is "traverses". A palette of
17
+ * twenty-two verbs would therefore be twenty-two ways of asking the user a
18
+ * question the metamodel already answers — and twenty-one ways of getting it
19
+ * wrong. The toolbox arms ONE tool, stamped with the generic parent role
20
+ * `edgy:relation`, and this resolver reads the pair the user actually attached
21
+ * and writes the verb.
22
+ *
23
+ * ## What it writes, and what it refuses to write
24
+ *
25
+ * - **pair in the metamodel** → the verb's role (`EDGY_VERB_ROLE`) and the verb
26
+ * as the link's visible label, in the same shape the template lays one down
27
+ * (`text` + `labelXYWH` + `labelOffset`). The sentence is now legal, so E1
28
+ * says nothing.
29
+ * - **pair known only the OTHER way round** → the role of the reversed match is
30
+ * written anyway, and the edge is NOT flipped. This is deliberate: silently
31
+ * turning the arrow round would overrule a gesture the user made on purpose,
32
+ * and leaving the edge generic would hide the mistake behind the vaguer "not
33
+ * a sentence of the metamodel". Naming it makes E1 report the precise
34
+ * sentence — "a channel traverses a journey" — and `docs/adr/0010`'s M3
35
+ * (`edge.invert-direction`) is one click away on the contextual toolbar. The
36
+ * violation IS the affordance.
37
+ * - **pair in neither direction** → nothing is written. The edge keeps
38
+ * `edgy:relation`, and E1 reports it as a relation the metamodel does not
39
+ * declare between these two elements, which is exactly what it is.
40
+ * - **an end outside the alphabet** — a People node, a base Object, a plain
41
+ * sticky, an element of another framework — → nothing is written and nothing
42
+ * is said. Same contract as the `relation-endpoints` family: outside the
43
+ * alphabet is outside the conversation (PRD principle 8).
44
+ *
45
+ * ## Once, and only from the generic towards the verb
46
+ *
47
+ * A verb role already written is never rewritten — not by this module, not
48
+ * ever. Re-resolution is allowed only while the edge still carries the generic
49
+ * role, which is what makes "drop the end on the wrong element, then move it"
50
+ * work without the tool fighting the hand. And nothing here ever writes
51
+ * `edgy:relation` back over a verb: the resolution runs one way.
52
+ */
53
+ /**
54
+ * Recompute delay. The connector tool rewrites `target` on every pointer move
55
+ * of the drag, so resolving on the spot would name the relation after the FIRST
56
+ * element the cursor passed over — and, the naming being write-once, would then
57
+ * refuse to name it after the one the user actually dropped it on. Debouncing
58
+ * means the pair that gets read is the pair that ended up on the board.
59
+ *
60
+ * Same value as the validation engine's, and for the same reason: it is the
61
+ * shortest wait a human does not perceive at the end of a gesture.
62
+ */
63
+ export const EDGY_RELATION_DELAY_MS = 120;
64
+ /** Where the verb sits along the link. The templates' own default. */
65
+ export const EDGY_RELATION_LABEL_DISTANCE = 0.5;
66
+ /**
67
+ * The label BOX for a verb — `x`/`y` are re-centred on the path at the first
68
+ * layout, but the `w`/`h` are the box, so it has to be sized to the word or the
69
+ * verb wraps mid-syllable on a two-word relation like "is part of".
70
+ *
71
+ * Exported and read by `templates/index.ts` as well: the 24 relations the
72
+ * template draws and the ones drawn by hand have to look identical, and two
73
+ * copies of `verb.length * 9 + 24` is one copy too many.
74
+ */
75
+ export function edgyVerbLabelXYWH(verb) {
76
+ return [0, 0, verb.length * 9 + 24, 26];
77
+ }
78
+ /**
79
+ * The verb the metamodel gives an ordered pair of element roles, direct match
80
+ * first, reversed match second, `null` when it knows neither.
81
+ *
82
+ * Pure, and exported for the unit spec: it is the whole opinion of this module,
83
+ * and a function taking two strings is testable without a surface, an editor or
84
+ * a DI container around it.
85
+ */
86
+ export function edgyRelationNaming(sourceRole, targetRole) {
87
+ if (sourceRole === undefined || targetRole === undefined)
88
+ return null;
89
+ const direct = EDGY_PAIR_TO_VERB[edgyPairKey(sourceRole, targetRole)];
90
+ if (direct !== undefined) {
91
+ return { role: EDGY_VERB_ROLE[direct], verb: direct, reversed: false };
92
+ }
93
+ const reversed = EDGY_PAIR_TO_VERB[edgyPairKey(targetRole, sourceRole)];
94
+ if (reversed !== undefined) {
95
+ return { role: EDGY_VERB_ROLE[reversed], verb: reversed, reversed: true };
96
+ }
97
+ return null;
98
+ }
99
+ /**
100
+ * Whether this edge is a generic EDGY relation with both ends ATTACHED — the
101
+ * one state this module acts on.
102
+ *
103
+ * `role === EDGY_ROLE.relation` exactly, never `roleIsA`: a verb role
104
+ * specialises the parent, and treating it as a candidate is precisely how the
105
+ * resolver would start rewriting its own work. An end released over empty
106
+ * canvas relates nothing to nothing, so it is not a candidate either — it
107
+ * becomes one the moment the user drags it onto something.
108
+ *
109
+ * A LOCKED edge is left alone, the guard `edge.invert-direction` applies for
110
+ * the same reason: locking is the user saying "do not touch this", and a
111
+ * resolver that wrote to one anyway would be the one gesture on this canvas
112
+ * that ignores it.
113
+ */
114
+ export function isUnnamedEdgyRelation(edge) {
115
+ return (edge.role === EDGY_ROLE.relation &&
116
+ Boolean(edge.source?.id) &&
117
+ Boolean(edge.target?.id) &&
118
+ !edge.isLocked());
119
+ }
120
+ /**
121
+ * The naming this edge has earned from the elements it is attached to, or
122
+ * `null` when there is nothing to write. Reads only; the writes are below.
123
+ */
124
+ export function edgeRelationNaming(surface, edge) {
125
+ if (!isUnnamedEdgyRelation(edge))
126
+ return null;
127
+ const source = surface.getElementById(edge.source.id);
128
+ const target = surface.getElementById(edge.target.id);
129
+ if (!source || !target)
130
+ return null;
131
+ return edgyRelationNaming(source.role, target.role);
132
+ }
133
+ /**
134
+ * Write the verb onto one edge.
135
+ *
136
+ * Through `surface.updateElement` and never through `EdgelessCRUDIdentifier`,
137
+ * for the reason `edge.invert-direction` spells out at length: `crud`'s wrapper
138
+ * calls `recordLastProps`, which would make this relation's label geometry the
139
+ * DEFAULT for every connector drawn afterwards. Naming one relation is a
140
+ * statement about that relation, never a style preference.
141
+ */
142
+ export function writeEdgyRelationName(surface, edge, naming) {
143
+ surface.updateElement(edge.id, {
144
+ role: naming.role,
145
+ // The verb travels with the link as a label, exactly as the template lays
146
+ // it down — the role is what the engine reads, the label is what the user
147
+ // reads out loud to notice a sentence running backwards.
148
+ text: new Y.Text(naming.verb),
149
+ labelXYWH: edgyVerbLabelXYWH(naming.verb),
150
+ labelOffset: { distance: EDGY_RELATION_LABEL_DISTANCE },
151
+ });
152
+ }
153
+ /**
154
+ * Name every edge among `ids` that has earned a name, in ONE undo step.
155
+ *
156
+ * The seam, and the only function here that writes. Two phases on purpose: the
157
+ * namings are decided BEFORE anything is captured, so a flush that finds
158
+ * nothing to say costs no undo entry at all — an empty capture would turn every
159
+ * stray connector into a stop on the user's way back through their own history.
160
+ *
161
+ * Read-only is checked here rather than trusted: `surface.updateElement`
162
+ * THROWS on a read-only store, so a resolver that did not ask would turn
163
+ * opening a shared board in read-only into an exception per connector drawn on
164
+ * it.
165
+ */
166
+ export function resolveEdgyRelations(surface, ids) {
167
+ if (surface.store.readonly)
168
+ return [];
169
+ const planned = [];
170
+ for (const id of ids) {
171
+ const edge = surface.getElementById(id);
172
+ if (!(edge instanceof ConnectorElementModel))
173
+ continue;
174
+ const naming = edgeRelationNaming(surface, edge);
175
+ if (naming)
176
+ planned.push([edge, naming]);
177
+ }
178
+ if (planned.length === 0)
179
+ return [];
180
+ // BEFORE the writes: `Store.transact` is no undo boundary, so without this
181
+ // the naming would be undone TOGETHER with the drag that produced it and the
182
+ // user would lose the link they just drew to take back a word.
183
+ surface.store.captureSync();
184
+ for (const [edge, naming] of planned) {
185
+ writeEdgyRelationName(surface, edge, naming);
186
+ }
187
+ return planned.map(([, naming]) => naming);
188
+ }
@@ -0,0 +1,108 @@
1
+ import type { EdgyNodeKind } from '@formicoidea/labre-core/model';
2
+ import type { RoleDefs } from '@formicoidea/labre-core/std/gfx';
3
+ import { type EdgyElementName } from './metamodel.js';
4
+ /**
5
+ * EDGY role vocabulary (WS1).
6
+ *
7
+ * A role is the semantic identity of an EDGY artefact — no rule will ever look
8
+ * at a shape type. Three families live here, and they answer three different
9
+ * questions:
10
+ *
11
+ * - the ELEMENTS: `edgy:element` at the root, the four PERSISTED kinds under it
12
+ * (`people`, `outcome`, `object`, `activity` — the `EdgyNodeKind` the user
13
+ * picks in the toolbox and the model stores), and the twelve OFFICIAL
14
+ * elements of the metamodel under their own kind;
15
+ * - the BACKGROUNDS: `edgy:background`, specialised by the facets diagram and
16
+ * the blank board — the two frames a finding can be attributed to;
17
+ * - the RELATIONS: `edgy:relation`, specialised by ONE ROLE PER CANONICAL VERB.
18
+ *
19
+ * Hierarchy is DATA (`parent`), never TS inheritance: a rule written on
20
+ * `edgy:element` covers `edgy:purpose` for free (see `roleIsA`), which is what
21
+ * lets `edgy.overlapping-artefacts` be one line rather than a matrix of
22
+ * sixteen.
23
+ *
24
+ * ## Why a role per VERB, and not one `edgy:relation`
25
+ *
26
+ * EDGY's grammar is not "these two things may be linked", it is "content
27
+ * EXPRESSES purpose". A single relation role could only ever check the pair,
28
+ * and would happily accept a link labelled "expresses" drawn between a task and
29
+ * a channel — the exact mistake the metamodel exists to prevent. One role per
30
+ * verb makes the sentence itself the unit the engine checks, through the
31
+ * `relation-endpoints` family: source, edge, target, all three named by role.
32
+ *
33
+ * Tier 1 of `docs/adr/0010` applies to every one of them: the SOURCE is the
34
+ * subject of the verb, the TARGET its object. The verb travels with the link as
35
+ * a visible label too — that is presentation, and it stays; the role is what
36
+ * the engine reads.
37
+ *
38
+ * ## Compatibility
39
+ *
40
+ * Nothing is backfilled. An EDGY board drawn before today carries elements and
41
+ * connectors with no role, so it is never evaluated and never says a word — the
42
+ * same promise every role in this library has made (PRD principle 8).
43
+ */
44
+ /** The twelve official elements of the metamodel, by name. */
45
+ export type EdgyElementRole = EdgyElementName;
46
+ /** The static half of the vocabulary — everything not derived from a verb. */
47
+ export type EdgyStaticRole = 'element' | EdgyNodeKind | 'background' | 'facets' | 'board' | 'relation' | EdgyElementRole;
48
+ export type EdgyRoleId = `edgy:${string}`;
49
+ /** Role ids, keyed by the name used at the creation sites. */
50
+ export declare const EDGY_ROLE: {
51
+ readonly element: "edgy:element";
52
+ readonly people: "edgy:people";
53
+ readonly outcome: "edgy:outcome";
54
+ readonly object: "edgy:object";
55
+ readonly activity: "edgy:activity";
56
+ readonly purpose: "edgy:purpose";
57
+ readonly capability: "edgy:capability";
58
+ readonly task: "edgy:task";
59
+ readonly story: "edgy:story";
60
+ readonly process: "edgy:process";
61
+ readonly journey: "edgy:journey";
62
+ readonly content: "edgy:content";
63
+ readonly asset: "edgy:asset";
64
+ readonly channel: "edgy:channel";
65
+ readonly organisation: "edgy:organisation";
66
+ readonly product: "edgy:product";
67
+ readonly brand: "edgy:brand";
68
+ readonly background: "edgy:background";
69
+ readonly facets: "edgy:facets";
70
+ readonly board: "edgy:board";
71
+ readonly relation: "edgy:relation";
72
+ };
73
+ /**
74
+ * Role id per canonical verb, DERIVED from {@link EDGY_DYNAMIC_RELATIONS}.
75
+ *
76
+ * Derived rather than restated: the metamodel is written once (`./metamodel.ts`)
77
+ * and the template that draws it, the vocabulary that names it and the rule that
78
+ * checks it all read the same table. `requires` appears on three rows and gets
79
+ * ONE role — a verb is a verb wherever it is spoken, and the three sentences it
80
+ * belongs to are three triplets, not three roles.
81
+ */
82
+ export declare const EDGY_VERB_ROLE: Readonly<Record<string, EdgyRoleId>>;
83
+ /**
84
+ * Key of an ORDERED pair of element roles: `edgy:process edgy:capability`.
85
+ *
86
+ * A SPACE separates them, and not `-`, `:` or `>`: a role id already contains a
87
+ * colon and may contain a dash (`edgy:is-part-of`), and a separator that can
88
+ * occur inside a value is a lookup table with collisions waiting in it. No role
89
+ * id has ever contained whitespace — `verbRoleId` above replaces it on the one
90
+ * family that could.
91
+ */
92
+ export declare const edgyPairKey: (source: string, target: string) => string;
93
+ /**
94
+ * Ordered pair of ELEMENT roles → the canonical verb the metamodel gives it,
95
+ * DERIVED from {@link EDGY_DYNAMIC_RELATIONS}.
96
+ *
97
+ * The metamodel's 24 rows are 24 DISTINCT ordered pairs, so the verb of a
98
+ * relation is entirely determined by which two elements it runs between: there
99
+ * is never a choice to offer. That is what lets the toolbox ship ONE "Relation"
100
+ * entry instead of twenty-two, and what `./relation.ts` reads to name a
101
+ * hand-drawn link after the pair the user attached it to.
102
+ *
103
+ * Derived, never restated — same contract as {@link EDGY_VERB_ROLE}: a relation
104
+ * added to `./metamodel.ts` becomes nameable by hand without anybody editing
105
+ * this file.
106
+ */
107
+ export declare const EDGY_PAIR_TO_VERB: Readonly<Record<string, string>>;
108
+ export declare const EDGY_ROLES: RoleDefs;
package/dist/roles.js ADDED
@@ -0,0 +1,182 @@
1
+ import { EDGY_DYNAMIC_NODES, EDGY_DYNAMIC_RELATIONS, edgyElementLabel, } from './metamodel.js';
2
+ import { NODE_LABEL } from './node/consts.js';
3
+ /** Role ids, keyed by the name used at the creation sites. */
4
+ export const EDGY_ROLE = {
5
+ element: 'edgy:element',
6
+ // The four persisted kinds (`EdgyNodeKind`).
7
+ people: 'edgy:people',
8
+ outcome: 'edgy:outcome',
9
+ object: 'edgy:object',
10
+ activity: 'edgy:activity',
11
+ // The twelve official elements.
12
+ purpose: 'edgy:purpose',
13
+ capability: 'edgy:capability',
14
+ task: 'edgy:task',
15
+ story: 'edgy:story',
16
+ process: 'edgy:process',
17
+ journey: 'edgy:journey',
18
+ content: 'edgy:content',
19
+ asset: 'edgy:asset',
20
+ channel: 'edgy:channel',
21
+ organisation: 'edgy:organisation',
22
+ product: 'edgy:product',
23
+ brand: 'edgy:brand',
24
+ // The two frames.
25
+ background: 'edgy:background',
26
+ facets: 'edgy:facets',
27
+ board: 'edgy:board',
28
+ // The parent of the 22 verbs.
29
+ relation: 'edgy:relation',
30
+ };
31
+ /**
32
+ * A canonical verb, as a role id: `is part of` → `edgy:is-part-of`.
33
+ *
34
+ * The verb IS the identifier, so a relation added to the metamodel gets its
35
+ * role for free and nobody has to remember to name it. No collision with the
36
+ * element roles above is possible in the metamodel as it stands, and a future
37
+ * verb colliding with an element name would be caught by the unit spec, which
38
+ * counts the vocabulary.
39
+ */
40
+ function verbRoleId(verb) {
41
+ return `edgy:${verb.replace(/\s+/g, '-')}`;
42
+ }
43
+ /**
44
+ * Role id per canonical verb, DERIVED from {@link EDGY_DYNAMIC_RELATIONS}.
45
+ *
46
+ * Derived rather than restated: the metamodel is written once (`./metamodel.ts`)
47
+ * and the template that draws it, the vocabulary that names it and the rule that
48
+ * checks it all read the same table. `requires` appears on three rows and gets
49
+ * ONE role — a verb is a verb wherever it is spoken, and the three sentences it
50
+ * belongs to are three triplets, not three roles.
51
+ */
52
+ export const EDGY_VERB_ROLE = Object.assign(Object.create(null), Object.fromEntries(EDGY_DYNAMIC_RELATIONS.map(([, , verb]) => [verb, verbRoleId(verb)])));
53
+ /**
54
+ * Key of an ORDERED pair of element roles: `edgy:process edgy:capability`.
55
+ *
56
+ * A SPACE separates them, and not `-`, `:` or `>`: a role id already contains a
57
+ * colon and may contain a dash (`edgy:is-part-of`), and a separator that can
58
+ * occur inside a value is a lookup table with collisions waiting in it. No role
59
+ * id has ever contained whitespace — `verbRoleId` above replaces it on the one
60
+ * family that could.
61
+ */
62
+ export const edgyPairKey = (source, target) => `${source} ${target}`;
63
+ /**
64
+ * Ordered pair of ELEMENT roles → the canonical verb the metamodel gives it,
65
+ * DERIVED from {@link EDGY_DYNAMIC_RELATIONS}.
66
+ *
67
+ * The metamodel's 24 rows are 24 DISTINCT ordered pairs, so the verb of a
68
+ * relation is entirely determined by which two elements it runs between: there
69
+ * is never a choice to offer. That is what lets the toolbox ship ONE "Relation"
70
+ * entry instead of twenty-two, and what `./relation.ts` reads to name a
71
+ * hand-drawn link after the pair the user attached it to.
72
+ *
73
+ * Derived, never restated — same contract as {@link EDGY_VERB_ROLE}: a relation
74
+ * added to `./metamodel.ts` becomes nameable by hand without anybody editing
75
+ * this file.
76
+ */
77
+ export const EDGY_PAIR_TO_VERB = Object.assign(Object.create(null), Object.fromEntries(EDGY_DYNAMIC_RELATIONS.map(([source, target, verb]) => [
78
+ edgyPairKey(EDGY_ROLE[source], EDGY_ROLE[target]),
79
+ verb,
80
+ ])));
81
+ /** i18n key stem of a role id: `edgy:is-part-of` → `com.labre.edgy.role.is-part-of`. */
82
+ const roleKey = (id) => `com.labre.edgy.role.${id.slice('edgy:'.length)}`;
83
+ const ELEMENT_DEFS = [
84
+ // The root of every artefact a board is made of. A rule written here covers
85
+ // the four kinds and the twelve elements at once — which is exactly what
86
+ // `edgy.overlapping-artefacts` needs and why the root exists.
87
+ {
88
+ id: EDGY_ROLE.element,
89
+ kind: 'node',
90
+ labelKey: roleKey(EDGY_ROLE.element),
91
+ labelFallback: 'Element',
92
+ },
93
+ // The four PERSISTED kinds. They are the base shapes the toolbox offers, so
94
+ // they are what an element created from the palette carries: somebody
95
+ // dropping an "Object" on the board has said "object" and nothing more, and
96
+ // the role says exactly that much.
97
+ //
98
+ // The fallback is the wording the palette itself writes inside the shape
99
+ // (`node/consts.ts`), so a reader who meets the role in a legend or a
100
+ // direction reveal meets the word they dropped on the board.
101
+ ...['people', 'outcome', 'object', 'activity'].map(kind => ({
102
+ id: EDGY_ROLE[kind],
103
+ parent: EDGY_ROLE.element,
104
+ kind: 'node',
105
+ labelKey: roleKey(EDGY_ROLE[kind]),
106
+ labelFallback: NODE_LABEL[kind],
107
+ })),
108
+ // The twelve official elements, each under the kind the metamodel draws it
109
+ // with — Purpose is an outcome, Story an activity, Channel an object. The
110
+ // parent is READ from `EDGY_DYNAMIC_NODES` rather than restated: the diagram
111
+ // and the vocabulary cannot drift apart if there is only one table. So is the
112
+ // fallback wording, from the same table's own names.
113
+ ...Object.entries(EDGY_DYNAMIC_NODES).map(([name, { kind }]) => ({
114
+ id: EDGY_ROLE[name],
115
+ parent: EDGY_ROLE[kind],
116
+ kind: 'node',
117
+ labelKey: roleKey(EDGY_ROLE[name]),
118
+ labelFallback: edgyElementLabel(name),
119
+ })),
120
+ ];
121
+ const BACKGROUND_DEFS = [
122
+ // The frame, and deliberately NOT a child of `edgy:element`: a rule written
123
+ // on the artefacts must never match the board they are drawn on. Two frames
124
+ // specialise it — the facets diagram and the blank board — so a rule
125
+ // attributes its findings to whichever one the user is working on without
126
+ // naming either.
127
+ {
128
+ id: EDGY_ROLE.background,
129
+ kind: 'node',
130
+ labelKey: roleKey(EDGY_ROLE.background),
131
+ },
132
+ {
133
+ id: EDGY_ROLE.facets,
134
+ parent: EDGY_ROLE.background,
135
+ kind: 'node',
136
+ labelKey: roleKey(EDGY_ROLE.facets),
137
+ },
138
+ {
139
+ id: EDGY_ROLE.board,
140
+ parent: EDGY_ROLE.background,
141
+ kind: 'node',
142
+ labelKey: roleKey(EDGY_ROLE.board),
143
+ },
144
+ ];
145
+ /**
146
+ * The relations: the parent, then one child per canonical verb.
147
+ *
148
+ * The parent carries no `direction` — it names no verb, so it has no sentence
149
+ * to state. Every child does, and the gesture hint is derived from the verb for
150
+ * the same reason the id is: "drag from the X to the Y" is the same sentence
151
+ * with the same two holes for all 22 of them, and writing it out 22 times would
152
+ * be 22 chances to write it out wrong.
153
+ */
154
+ const RELATION_DEFS = [
155
+ {
156
+ id: EDGY_ROLE.relation,
157
+ kind: 'edge',
158
+ labelKey: roleKey(EDGY_ROLE.relation),
159
+ labelFallback: 'Relation',
160
+ },
161
+ ...Object.entries(EDGY_VERB_ROLE).map(([verb, id]) => ({
162
+ id,
163
+ parent: EDGY_ROLE.relation,
164
+ kind: 'edge',
165
+ labelKey: roleKey(id),
166
+ labelFallback: verb.charAt(0).toUpperCase() + verb.slice(1),
167
+ direction: {
168
+ verbKey: `${roleKey(id)}.verb`,
169
+ verbFallback: verb,
170
+ gestureHintKey: `${roleKey(id)}.gesture`,
171
+ gestureHintFallback: `Drag from the element that is the subject of "${verb}" to its object.`,
172
+ },
173
+ })),
174
+ ];
175
+ const DEFS = [
176
+ ...ELEMENT_DEFS,
177
+ ...BACKGROUND_DEFS,
178
+ ...RELATION_DEFS,
179
+ ];
180
+ // Null prototype: this is a lookup table keyed by ids that may one day come
181
+ // from host-supplied packs, so `defs['toString']` must not resolve.
182
+ export const EDGY_ROLES = Object.assign(Object.create(null), Object.fromEntries(DEFS.map(def => [def.id, def])));
@@ -0,0 +1,43 @@
1
+ import type { EndpointTriplet, ValidationRule } from '@formicoidea/labre-core/blocks/surface';
2
+ /**
3
+ * EDGY validation rules (WS1).
4
+ *
5
+ * DATA owned by the framework, versioned per rule: the engine
6
+ * (`@labre/affine-block-surface`) knows how to evaluate a FAMILY, never a
7
+ * concrete rule. Adding an EDGY rule is adding an entry to this array.
8
+ *
9
+ * Registered from the flag-gated `EdgyViewExtension`, so switching the EDGY
10
+ * flag off removes the rules with the rest of the tooling — boards already
11
+ * drawn keep rendering, they simply stop being checked (`docs/adr/0009`).
12
+ *
13
+ * ## Two rules, and the ones deliberately left out
14
+ *
15
+ * The PO arbitration of 26/08/2026 put EDGY's two JUDGEMENT controls — "every
16
+ * intersection element is linked to both its parent facets" and "every element
17
+ * wears its facet's colour" — in `./nudges.ts` rather than here. Neither is
18
+ * decidable: the first would have to guess which circle an element belongs to
19
+ * from where somebody dropped it, and the second would indict a board whose
20
+ * author uses their own palette. A checklist can ask them; an algorithm cannot
21
+ * answer them.
22
+ *
23
+ * ## Debt: `tree-mixed-kinds`
24
+ *
25
+ * EDGY's own notation has TREES — a capability tree, an organisation tree —
26
+ * whose levels must all be the same kind of element. There is no tree on this
27
+ * canvas yet: nothing draws one, nothing stores one, and no role names one. The
28
+ * rule is therefore deferred rather than written against an artefact that does
29
+ * not exist. When a tree lands, it comes with its own role and this is where its
30
+ * rule goes.
31
+ */
32
+ /**
33
+ * The 24 sanctioned sentences, DERIVED from the metamodel.
34
+ *
35
+ * One triplet per row of `EDGY_DYNAMIC_RELATIONS`, read exactly as
36
+ * `docs/adr/0010` reads a typed edge: source is the subject, target the object.
37
+ * The matrix is never restated — a relation added to the metamodel is drawn by
38
+ * the template, gets a role from `roles.ts` and becomes legal here, all from the
39
+ * one table. `requires` contributes three triplets under one role, which is what
40
+ * it means for a verb to be spoken of three different pairs.
41
+ */
42
+ export declare const EDGY_ALLOWED_RELATIONS: readonly EndpointTriplet[];
43
+ export declare const EDGY_RULES: readonly ValidationRule[];