@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.
- package/dist/actions.d.ts +39 -0
- package/dist/actions.js +193 -0
- package/dist/board-renderer.d.ts +12 -0
- package/dist/board-renderer.js +28 -0
- package/dist/board-view.d.ts +11 -0
- package/dist/board-view.js +20 -0
- package/dist/commands.d.ts +4 -0
- package/dist/commands.js +103 -0
- package/dist/consts.d.ts +35 -0
- package/dist/consts.js +46 -0
- package/dist/descriptor.d.ts +8 -3
- package/dist/descriptor.js +6 -3
- package/dist/element-renderer.js +24 -13
- package/dist/element-view.js +9 -3
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/label-layout.js +8 -2
- package/dist/legend.d.ts +2 -0
- package/dist/legend.js +109 -0
- package/dist/metamodel.d.ts +96 -0
- package/dist/metamodel.js +128 -0
- package/dist/nudges.d.ts +26 -0
- package/dist/nudges.js +54 -0
- package/dist/profiles.d.ts +2 -0
- package/dist/profiles.js +66 -0
- package/dist/relation-resolver.d.ts +32 -0
- package/dist/relation-resolver.js +101 -0
- package/dist/relation.d.ts +142 -0
- package/dist/relation.js +188 -0
- package/dist/roles.d.ts +108 -0
- package/dist/roles.js +182 -0
- package/dist/rules.d.ts +43 -0
- package/dist/rules.js +153 -0
- package/dist/templates/index.d.ts +22 -1
- package/dist/templates/index.js +303 -52
- package/dist/toolbar/config.d.ts +33 -0
- package/dist/toolbar/config.js +82 -15
- package/dist/toolbar/edgy-menu.d.ts +7 -25
- package/dist/toolbar/edgy-menu.js +7 -186
- package/dist/toolbar/edgy-senior-button.js +8 -2
- package/dist/toolbar/icons.d.ts +12 -0
- package/dist/toolbar/icons.js +31 -0
- package/dist/toolbar/senior-tool.js +1 -0
- package/dist/translations.d.ts +24 -0
- package/dist/translations.js +29 -0
- package/dist/view.d.ts +18 -0
- package/dist/view.js +95 -8
- 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[];
|
package/dist/relation.js
ADDED
|
@@ -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
|
+
}
|
package/dist/roles.d.ts
ADDED
|
@@ -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])));
|
package/dist/rules.d.ts
ADDED
|
@@ -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[];
|