@formicoidea/labre-framework-edgy 0.32.0 → 0.34.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 +18 -0
- package/dist/actions.js +61 -5
- package/dist/board-renderer.js +4 -1
- package/dist/commands-manifest.d.ts +18 -0
- package/dist/commands-manifest.js +82 -0
- package/dist/commands.js +21 -3
- package/dist/element-renderer.js +5 -2
- package/dist/element-view.d.ts +14 -0
- package/dist/element-view.js +25 -7
- 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/node/consts.d.ts +12 -0
- package/dist/node/consts.js +12 -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 +5 -13
- package/dist/templates/index.js +210 -107
- package/dist/toolbar/config.d.ts +12 -4
- package/dist/toolbar/config.js +48 -5
- package/dist/toolbar/edgy-senior-button.js +8 -2
- package/dist/toolbar/icons.d.ts +8 -0
- package/dist/toolbar/icons.js +13 -0
- package/dist/toolbar/senior-tool.js +1 -0
- package/dist/translations.d.ts +17 -7
- package/dist/translations.js +38 -9
- package/dist/view.d.ts +8 -5
- package/dist/view.js +60 -10
- package/package.json +10 -3
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The EDGY metamodel, as DATA — the 12 official elements and the 24 canonical
|
|
3
|
+
* relations that link them.
|
|
4
|
+
*
|
|
5
|
+
* It used to live in `templates/index.ts`, where it was drawn. It moved here
|
|
6
|
+
* the day the roles started being DERIVED from it (`./roles.ts`): the template
|
|
7
|
+
* needs the vocabulary to stamp its elements, and the vocabulary needs the
|
|
8
|
+
* metamodel to know which verbs exist, so a single module holding both would be
|
|
9
|
+
* a cycle. Nothing was renamed — `templates/index.ts` re-exports both tables
|
|
10
|
+
* under the names they always had.
|
|
11
|
+
*
|
|
12
|
+
* This file is the ONE place the metamodel is written down. `roles.ts` derives
|
|
13
|
+
* an edge role per verb from it, `rules.ts` derives the sanctioned sentences of
|
|
14
|
+
* `edgy.non-canonical-link` from it, and the template draws it. Restating the
|
|
15
|
+
* matrix anywhere else would be inviting the three to disagree.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Official pastel fills per zone (the `pictograms/Shape-*.svg` colors) — the
|
|
19
|
+
* colour an element of that zone is DRAWN with, on the diagram and therefore in
|
|
20
|
+
* the legend that documents it.
|
|
21
|
+
*
|
|
22
|
+
* Exported because the legend needs the same swatch the board uses: a swatch
|
|
23
|
+
* restated as a second literal would be a colour free to drift away from the
|
|
24
|
+
* one the eye sees.
|
|
25
|
+
*/
|
|
26
|
+
export const EDGY_ZONE_FILL = {
|
|
27
|
+
identity: '#80ffb7',
|
|
28
|
+
architecture: '#a6c0ff',
|
|
29
|
+
experience: '#ff99bd',
|
|
30
|
+
organisation: '#80eaff',
|
|
31
|
+
brand: '#ffd580',
|
|
32
|
+
product: '#e599ff',
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* The six zones, in reading order — the three FACETS first, then the three
|
|
36
|
+
* INTERSECTIONS, each named by the pair of circles it is the lens of (the very
|
|
37
|
+
* pairs `element-renderer.ts` clips: A∩B, B∩C, A∩C).
|
|
38
|
+
*
|
|
39
|
+
* Written down as data because the split is otherwise readable only inside the
|
|
40
|
+
* renderer's paint calls, and the legend has to group the twelve elements by
|
|
41
|
+
* it. `group` is the only thing here a reader could not already derive from
|
|
42
|
+
* {@link EDGY_DYNAMIC_NODES}.
|
|
43
|
+
*/
|
|
44
|
+
export const EDGY_ZONES = [
|
|
45
|
+
{ id: 'identity', group: 'facet' },
|
|
46
|
+
{ id: 'architecture', group: 'facet' },
|
|
47
|
+
{ id: 'experience', group: 'facet' },
|
|
48
|
+
// Identity ∩ Architecture, Architecture ∩ Experience, Experience ∩ Identity.
|
|
49
|
+
{ id: 'organisation', group: 'intersection' },
|
|
50
|
+
{ id: 'product', group: 'intersection' },
|
|
51
|
+
{ id: 'brand', group: 'intersection' },
|
|
52
|
+
];
|
|
53
|
+
/**
|
|
54
|
+
* The 24 canonical EDGY relations (source, target, verb, label position along
|
|
55
|
+
* the link) — exported for the unit tests. 7 per facet + 3 between the
|
|
56
|
+
* intersections. The optional 4th member mirrors the reference diagram's
|
|
57
|
+
* placements: verbs of intersection-outgoing links sit near the far element
|
|
58
|
+
* (`labelOffset.distance` ≈ .75), short peer links keep the middle.
|
|
59
|
+
*
|
|
60
|
+
* Read the way `docs/adr/0010` reads every typed edge: the SOURCE is the
|
|
61
|
+
* subject of the verb and the TARGET its object, so each row is one sentence a
|
|
62
|
+
* practitioner would say out loud — "content expresses purpose". That is what
|
|
63
|
+
* makes the row usable as an {@link EndpointTriplet} without a single
|
|
64
|
+
* coordinate taking part.
|
|
65
|
+
*/
|
|
66
|
+
export const EDGY_DYNAMIC_RELATIONS = [
|
|
67
|
+
// Identity
|
|
68
|
+
['content', 'purpose', 'expresses'],
|
|
69
|
+
['content', 'story', 'conveys', 0.75],
|
|
70
|
+
['story', 'purpose', 'contextualises', 0.8],
|
|
71
|
+
['organisation', 'purpose', 'pursues', 0.8],
|
|
72
|
+
['organisation', 'story', 'authors', 0.6],
|
|
73
|
+
['brand', 'purpose', 'represents', 0.8],
|
|
74
|
+
['brand', 'story', 'evokes', 0.65],
|
|
75
|
+
// Architecture
|
|
76
|
+
['organisation', 'process', 'performs', 0.8],
|
|
77
|
+
['process', 'capability', 'realises', 0.75],
|
|
78
|
+
['process', 'asset', 'requires'],
|
|
79
|
+
['capability', 'asset', 'requires', 0.75],
|
|
80
|
+
['organisation', 'capability', 'has', 0.6],
|
|
81
|
+
['product', 'capability', 'requires', 0.75],
|
|
82
|
+
['process', 'product', 'creates', 0.65],
|
|
83
|
+
// Experience
|
|
84
|
+
['task', 'journey', 'is part of', 0.6],
|
|
85
|
+
['task', 'channel', 'uses', 0.6],
|
|
86
|
+
['journey', 'channel', 'traverses', 0.6],
|
|
87
|
+
['product', 'task', 'serves', 0.9],
|
|
88
|
+
['product', 'journey', 'features in', 0.8],
|
|
89
|
+
['brand', 'task', 'supports', 0.8],
|
|
90
|
+
['brand', 'journey', 'appears in', 0.9],
|
|
91
|
+
// Intersections
|
|
92
|
+
['organisation', 'brand', 'builds', 0.85],
|
|
93
|
+
['organisation', 'product', 'makes', 0.65],
|
|
94
|
+
['product', 'brand', 'embodies', 0.8],
|
|
95
|
+
];
|
|
96
|
+
/**
|
|
97
|
+
* The metamodel's own wording for one of its lowercase names — one of the
|
|
98
|
+
* twelve elements, or one of the six zones. It is the name written on the
|
|
99
|
+
* diagram, so it is also what the vocabulary falls back to and what the legend
|
|
100
|
+
* prints as a row label or a section title. One expression, one wording.
|
|
101
|
+
*/
|
|
102
|
+
export const edgyElementLabel = (name) => name.charAt(0).toUpperCase() + name.slice(1);
|
|
103
|
+
export const EDGY_DYNAMIC_NODES = {
|
|
104
|
+
content: { kind: 'object', cx: 237.5, cy: 100, zone: 'identity' },
|
|
105
|
+
purpose: { kind: 'outcome', cx: 282.5, cy: 100, zone: 'identity' },
|
|
106
|
+
organisation: {
|
|
107
|
+
kind: 'object',
|
|
108
|
+
cx: 340,
|
|
109
|
+
cy: 100,
|
|
110
|
+
w: 175,
|
|
111
|
+
zone: 'organisation',
|
|
112
|
+
},
|
|
113
|
+
process: { kind: 'activity', cx: 397.5, cy: 100, zone: 'architecture' },
|
|
114
|
+
asset: { kind: 'object', cx: 442.5, cy: 100, zone: 'architecture' },
|
|
115
|
+
story: { kind: 'activity', cx: 255, cy: 152.5, zone: 'identity' },
|
|
116
|
+
capability: {
|
|
117
|
+
kind: 'outcome',
|
|
118
|
+
cx: 425,
|
|
119
|
+
cy: 152.5,
|
|
120
|
+
w: 150,
|
|
121
|
+
zone: 'architecture',
|
|
122
|
+
},
|
|
123
|
+
brand: { kind: 'object', cx: 280, cy: 195, zone: 'brand' },
|
|
124
|
+
product: { kind: 'object', cx: 400, cy: 195, zone: 'product' },
|
|
125
|
+
task: { kind: 'outcome', cx: 310, cy: 257.5, zone: 'experience' },
|
|
126
|
+
journey: { kind: 'activity', cx: 370, cy: 257.5, zone: 'experience' },
|
|
127
|
+
channel: { kind: 'object', cx: 340, cy: 297.5, zone: 'experience' },
|
|
128
|
+
};
|
package/dist/node/consts.d.ts
CHANGED
|
@@ -21,6 +21,18 @@ export declare const NODE_SIZE: Record<EdgyNodeKind, {
|
|
|
21
21
|
export declare const INNER_FONT_SIZE = 20;
|
|
22
22
|
export declare const LABEL_FONT_SIZE = 18;
|
|
23
23
|
export declare const LABEL_GAP = 8;
|
|
24
|
+
/**
|
|
25
|
+
* The i18n key {@link NODE_LABEL} is the English default of, when the label is
|
|
26
|
+
* written onto the canvas.
|
|
27
|
+
*
|
|
28
|
+
* Resolved AT PLACEMENT (`createEdgyBox`, `createEdgyPeople`) and never
|
|
29
|
+
* afterwards: the text a gesture writes is content the author owns from that
|
|
30
|
+
* moment on, and re-translating it on every paint would overwrite a name
|
|
31
|
+
* somebody typed. The same words also name the ROLES (`roles.ts`), under their
|
|
32
|
+
* own keys — a role is a fact about an element and is read live, which is a
|
|
33
|
+
* different question with a different answer.
|
|
34
|
+
*/
|
|
35
|
+
export declare const nodeLabelKey: (kind: EdgyNodeKind) => string;
|
|
24
36
|
/** Default inner text / label per kind. */
|
|
25
37
|
export declare const NODE_LABEL: Record<EdgyNodeKind, string>;
|
|
26
38
|
/**
|
package/dist/node/consts.js
CHANGED
|
@@ -28,6 +28,18 @@ export const NODE_SIZE = {
|
|
|
28
28
|
export const INNER_FONT_SIZE = 20;
|
|
29
29
|
export const LABEL_FONT_SIZE = 18;
|
|
30
30
|
export const LABEL_GAP = 8;
|
|
31
|
+
/**
|
|
32
|
+
* The i18n key {@link NODE_LABEL} is the English default of, when the label is
|
|
33
|
+
* written onto the canvas.
|
|
34
|
+
*
|
|
35
|
+
* Resolved AT PLACEMENT (`createEdgyBox`, `createEdgyPeople`) and never
|
|
36
|
+
* afterwards: the text a gesture writes is content the author owns from that
|
|
37
|
+
* moment on, and re-translating it on every paint would overwrite a name
|
|
38
|
+
* somebody typed. The same words also name the ROLES (`roles.ts`), under their
|
|
39
|
+
* own keys — a role is a fact about an element and is read live, which is a
|
|
40
|
+
* different question with a different answer.
|
|
41
|
+
*/
|
|
42
|
+
export const nodeLabelKey = (kind) => `com.labre.edgy.seed.${kind}`;
|
|
31
43
|
/** Default inner text / label per kind. */
|
|
32
44
|
export const NODE_LABEL = {
|
|
33
45
|
people: 'People',
|
package/dist/nudges.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { QualityNudge } from '@formicoidea/labre-core/blocks/surface';
|
|
2
|
+
/**
|
|
3
|
+
* EDGY **work quality** — the checklist (WS1).
|
|
4
|
+
*
|
|
5
|
+
* Level 1 is `./rules.ts`: deterministic, decidable, real time. This file is
|
|
6
|
+
* level 2, and the split is the whole point of the taxonomy — a rule only
|
|
7
|
+
* enters the deterministic engine if an algorithm can decide it on persisted
|
|
8
|
+
* data inside the ~16 ms budget. Everything else is a NUDGE: an expectation
|
|
9
|
+
* offered as a checklist, never evaluated, never blocking anything. Ticking is
|
|
10
|
+
* assuming.
|
|
11
|
+
*
|
|
12
|
+
* ## Why q1 and q2 are here and not in `rules.ts` (PO arbitration, 26/08/2026)
|
|
13
|
+
*
|
|
14
|
+
* They read like rules, and they are not. "Each intersection element is linked
|
|
15
|
+
* to both of its parent facets" would need the tool to know which circle an
|
|
16
|
+
* element belongs to — and nothing says so except where somebody dropped it,
|
|
17
|
+
* which is the layout answering a question about meaning. "Each element wears
|
|
18
|
+
* its facet's colour" would indict every board whose author uses their own
|
|
19
|
+
* palette, which is most of them after the first workshop. Both are judgements
|
|
20
|
+
* a modeller makes; the tool can put them on screen and cannot answer them.
|
|
21
|
+
*
|
|
22
|
+
* Registered from the flag-gated `EdgyViewExtension`, beside the rules and the
|
|
23
|
+
* profiles: a checklist is tooling. Switching the flag off takes it away and
|
|
24
|
+
* leaves the ticks written on the board, unread, until it comes back (PF7.10).
|
|
25
|
+
*/
|
|
26
|
+
export declare const EDGY_NUDGES: readonly QualityNudge[];
|
package/dist/nudges.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EDGY **work quality** — the checklist (WS1).
|
|
3
|
+
*
|
|
4
|
+
* Level 1 is `./rules.ts`: deterministic, decidable, real time. This file is
|
|
5
|
+
* level 2, and the split is the whole point of the taxonomy — a rule only
|
|
6
|
+
* enters the deterministic engine if an algorithm can decide it on persisted
|
|
7
|
+
* data inside the ~16 ms budget. Everything else is a NUDGE: an expectation
|
|
8
|
+
* offered as a checklist, never evaluated, never blocking anything. Ticking is
|
|
9
|
+
* assuming.
|
|
10
|
+
*
|
|
11
|
+
* ## Why q1 and q2 are here and not in `rules.ts` (PO arbitration, 26/08/2026)
|
|
12
|
+
*
|
|
13
|
+
* They read like rules, and they are not. "Each intersection element is linked
|
|
14
|
+
* to both of its parent facets" would need the tool to know which circle an
|
|
15
|
+
* element belongs to — and nothing says so except where somebody dropped it,
|
|
16
|
+
* which is the layout answering a question about meaning. "Each element wears
|
|
17
|
+
* its facet's colour" would indict every board whose author uses their own
|
|
18
|
+
* palette, which is most of them after the first workshop. Both are judgements
|
|
19
|
+
* a modeller makes; the tool can put them on screen and cannot answer them.
|
|
20
|
+
*
|
|
21
|
+
* Registered from the flag-gated `EdgyViewExtension`, beside the rules and the
|
|
22
|
+
* profiles: a checklist is tooling. Switching the flag off takes it away and
|
|
23
|
+
* leaves the ticks written on the board, unread, until it comes back (PF7.10).
|
|
24
|
+
*/
|
|
25
|
+
export const EDGY_NUDGES = [
|
|
26
|
+
{
|
|
27
|
+
id: 'edgy.q1-intersection-links',
|
|
28
|
+
framework: 'edgy',
|
|
29
|
+
labelKey: 'com.labre.edgy.quality.intersection-links',
|
|
30
|
+
fallback: 'Each intersection element is linked to both of its parent facets.',
|
|
31
|
+
order: 1,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'edgy.q2-facet-colour',
|
|
35
|
+
framework: 'edgy',
|
|
36
|
+
labelKey: 'com.labre.edgy.quality.facet-colour',
|
|
37
|
+
fallback: "Each element wears its facet's colour.",
|
|
38
|
+
order: 2,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'edgy.q3-readable-relations',
|
|
42
|
+
framework: 'edgy',
|
|
43
|
+
labelKey: 'com.labre.edgy.quality.readable-relations',
|
|
44
|
+
fallback: 'Relations read correctly (the source is the subject of the verb).',
|
|
45
|
+
order: 3,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'edgy.q4-three-facets',
|
|
49
|
+
framework: 'edgy',
|
|
50
|
+
labelKey: 'com.labre.edgy.quality.three-facets',
|
|
51
|
+
fallback: 'All three facets have been explored.',
|
|
52
|
+
order: 4,
|
|
53
|
+
},
|
|
54
|
+
];
|
package/dist/profiles.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EDGY validation profiles (WS1).
|
|
3
|
+
*
|
|
4
|
+
* DATA owned by the framework, like its rules and its roles: the engine knows
|
|
5
|
+
* how to APPLY a profile, never which ones EDGY has. Adding a level of
|
|
6
|
+
* requirement is adding an entry to this array.
|
|
7
|
+
*
|
|
8
|
+
* A profile is chosen per BACKGROUND, not per document (PF9.1) — a workshop
|
|
9
|
+
* scribble and a deliverable model coexist on the same canvas with different
|
|
10
|
+
* requirements, and the choice rides on the facets diagram or board itself.
|
|
11
|
+
*
|
|
12
|
+
* Registered from the flag-gated `EdgyViewExtension`, beside the rules:
|
|
13
|
+
* switching the EDGY flag off takes the choice away with the rest of the
|
|
14
|
+
* tooling, and a board already set to `strict` simply stops being checked until
|
|
15
|
+
* it comes back — the id stays written, untouched.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Sketch: every rule demoted to `audit`, so findings still reach `violations$`
|
|
19
|
+
* — a host panel and a conformance report see them — and the canvas says
|
|
20
|
+
* nothing at all.
|
|
21
|
+
*
|
|
22
|
+
* The DEFAULT, and deliberately so (PRD principle 3). EDGY is a WORKSHOP
|
|
23
|
+
* notation: elements land on the diagram in the order people say them, half the
|
|
24
|
+
* relations point the wrong way for the ten minutes it takes to argue about
|
|
25
|
+
* them, and boxes pile up in a corner before anybody sorts them into facets. A
|
|
26
|
+
* tool warning about all of that in real time would be arguing with the
|
|
27
|
+
* workshop.
|
|
28
|
+
*
|
|
29
|
+
* Being the default also means it is the one profile that WRITES NOTHING: a
|
|
30
|
+
* board on `sketch` carries no profile key, so every EDGY diagram ever drawn is
|
|
31
|
+
* on it, with no migration and no backfill.
|
|
32
|
+
*/
|
|
33
|
+
const sketch = {
|
|
34
|
+
id: 'edgy.sketch',
|
|
35
|
+
framework: 'edgy',
|
|
36
|
+
labelKey: 'com.labre.edgy.profile.sketch',
|
|
37
|
+
fallback: 'Sketch',
|
|
38
|
+
isDefault: true,
|
|
39
|
+
rules: {
|
|
40
|
+
'edgy.non-canonical-link': 'audit',
|
|
41
|
+
'edgy.overlapping-artefacts': 'audit',
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Strict: the deliverable level. Both rules bite, and both severities are
|
|
46
|
+
* spelled out here rather than left absent — PF9.4 wants the answer readable in
|
|
47
|
+
* one place, and a profile that says "warning" out loud cannot be misread as
|
|
48
|
+
* "whatever the rule happens to declare this quarter".
|
|
49
|
+
*
|
|
50
|
+
* `warning` and not `blocking-overridable` for E1, though a relation outside the
|
|
51
|
+
* metamodel is exactly the statement a strict EDGY model must not contain:
|
|
52
|
+
* nothing in this library implements a blocking level, so the value would claim
|
|
53
|
+
* an effect that does not exist. When the gesture refusal lands, this is a
|
|
54
|
+
* two-word change and this file is the only one that moves.
|
|
55
|
+
*/
|
|
56
|
+
const strict = {
|
|
57
|
+
id: 'edgy.strict',
|
|
58
|
+
framework: 'edgy',
|
|
59
|
+
labelKey: 'com.labre.edgy.profile.strict',
|
|
60
|
+
fallback: 'Strict',
|
|
61
|
+
rules: {
|
|
62
|
+
'edgy.non-canonical-link': 'warning',
|
|
63
|
+
'edgy.overlapping-artefacts': 'warning',
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
export const EDGY_PROFILES = [sketch, strict];
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { InteractivityExtension } from '@formicoidea/labre-core/std/gfx';
|
|
2
|
+
/**
|
|
3
|
+
* Turns "a connector was drawn, or one of its ends just moved" into a naming.
|
|
4
|
+
*
|
|
5
|
+
* Registered from the FLAG-GATED `EdgyViewExtension`, not from the always-on
|
|
6
|
+
* render one, and the distinction is `docs/adr/0009`'s: this is a creation
|
|
7
|
+
* tool. It authors content — it writes a role and a label into the document —
|
|
8
|
+
* and it only ever fires on an edge the flag-gated toolbox itself stamped
|
|
9
|
+
* `edgy:relation`. A board drawn while the flag was on keeps every verb it was
|
|
10
|
+
* given, painted by the always-on renderer and read by the always-on
|
|
11
|
+
* vocabulary; with the flag off nothing new is named because nothing new is
|
|
12
|
+
* being armed. Registering it always-on would mean the library kept authoring
|
|
13
|
+
* EDGY content for a host that switched EDGY off.
|
|
14
|
+
*
|
|
15
|
+
* The subscription mechanics are `EstuarineGhostManager`'s, including the part
|
|
16
|
+
* that matters: the surface is a SIGNAL, null at mount and replaced when the
|
|
17
|
+
* surface block is.
|
|
18
|
+
*/
|
|
19
|
+
export declare class EdgyRelationResolver extends InteractivityExtension {
|
|
20
|
+
static key: string;
|
|
21
|
+
private _subscriptions;
|
|
22
|
+
private _disposeSurfaceEffect;
|
|
23
|
+
private _pending;
|
|
24
|
+
private _candidates;
|
|
25
|
+
mounted(): void;
|
|
26
|
+
unmounted(): void;
|
|
27
|
+
private _forget;
|
|
28
|
+
private _unsubscribe;
|
|
29
|
+
private _resubscribe;
|
|
30
|
+
private _schedule;
|
|
31
|
+
private _flush;
|
|
32
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { InteractivityExtension } from '@formicoidea/labre-core/std/gfx';
|
|
2
|
+
import { effect } from '@preact/signals-core';
|
|
3
|
+
import { EDGY_RELATION_DELAY_MS, resolveEdgyRelations } from './relation.js';
|
|
4
|
+
/**
|
|
5
|
+
* Turns "a connector was drawn, or one of its ends just moved" into a naming.
|
|
6
|
+
*
|
|
7
|
+
* Registered from the FLAG-GATED `EdgyViewExtension`, not from the always-on
|
|
8
|
+
* render one, and the distinction is `docs/adr/0009`'s: this is a creation
|
|
9
|
+
* tool. It authors content — it writes a role and a label into the document —
|
|
10
|
+
* and it only ever fires on an edge the flag-gated toolbox itself stamped
|
|
11
|
+
* `edgy:relation`. A board drawn while the flag was on keeps every verb it was
|
|
12
|
+
* given, painted by the always-on renderer and read by the always-on
|
|
13
|
+
* vocabulary; with the flag off nothing new is named because nothing new is
|
|
14
|
+
* being armed. Registering it always-on would mean the library kept authoring
|
|
15
|
+
* EDGY content for a host that switched EDGY off.
|
|
16
|
+
*
|
|
17
|
+
* The subscription mechanics are `EstuarineGhostManager`'s, including the part
|
|
18
|
+
* that matters: the surface is a SIGNAL, null at mount and replaced when the
|
|
19
|
+
* surface block is.
|
|
20
|
+
*/
|
|
21
|
+
export class EdgyRelationResolver extends InteractivityExtension {
|
|
22
|
+
constructor() {
|
|
23
|
+
super(...arguments);
|
|
24
|
+
this._subscriptions = [];
|
|
25
|
+
this._disposeSurfaceEffect = null;
|
|
26
|
+
this._pending = null;
|
|
27
|
+
this._candidates = new Set();
|
|
28
|
+
}
|
|
29
|
+
static { this.key = 'edgy-relation-resolver'; }
|
|
30
|
+
mounted() {
|
|
31
|
+
this._disposeSurfaceEffect = effect(() => {
|
|
32
|
+
this._resubscribe(this.gfx.surface$.value);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
unmounted() {
|
|
36
|
+
this._disposeSurfaceEffect?.();
|
|
37
|
+
this._disposeSurfaceEffect = null;
|
|
38
|
+
this._unsubscribe();
|
|
39
|
+
this._forget();
|
|
40
|
+
super.unmounted();
|
|
41
|
+
}
|
|
42
|
+
_forget() {
|
|
43
|
+
if (this._pending)
|
|
44
|
+
clearTimeout(this._pending);
|
|
45
|
+
this._pending = null;
|
|
46
|
+
this._candidates.clear();
|
|
47
|
+
}
|
|
48
|
+
_unsubscribe() {
|
|
49
|
+
for (const subscription of this._subscriptions)
|
|
50
|
+
subscription.unsubscribe();
|
|
51
|
+
this._subscriptions = [];
|
|
52
|
+
}
|
|
53
|
+
_resubscribe(surface) {
|
|
54
|
+
this._unsubscribe();
|
|
55
|
+
// Candidates queued against the previous surface are about elements this
|
|
56
|
+
// one has never heard of.
|
|
57
|
+
this._forget();
|
|
58
|
+
if (!surface)
|
|
59
|
+
return;
|
|
60
|
+
this._subscriptions.push(surface.elementAdded.subscribe(({ id, local }) => {
|
|
61
|
+
if (!local)
|
|
62
|
+
return;
|
|
63
|
+
this._schedule(id);
|
|
64
|
+
}));
|
|
65
|
+
this._subscriptions.push(surface.elementUpdated.subscribe(({ id, props, local }) => {
|
|
66
|
+
// LOCAL only, the filter `reading.ts` documents at length: `local`
|
|
67
|
+
// partitions the fleet into one writer and N−1 silent observers with
|
|
68
|
+
// no leader election. Without it, everyone on a shared board would
|
|
69
|
+
// name the same relation the moment it synced — the same role and the
|
|
70
|
+
// same word each time, so nothing would corrupt, but each peer would
|
|
71
|
+
// get an undo entry for a gesture they did not make.
|
|
72
|
+
if (!local)
|
|
73
|
+
return;
|
|
74
|
+
// Only the two ends can turn an unnamed relation into a sentence.
|
|
75
|
+
// Every other prop — a drag, a restyle, a label edit — must not even
|
|
76
|
+
// rearm the timer.
|
|
77
|
+
if (!props || (!('source' in props) && !('target' in props)))
|
|
78
|
+
return;
|
|
79
|
+
this._schedule(id);
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
_schedule(id) {
|
|
83
|
+
this._candidates.add(id);
|
|
84
|
+
if (this._pending)
|
|
85
|
+
clearTimeout(this._pending);
|
|
86
|
+
this._pending = setTimeout(() => {
|
|
87
|
+
this._pending = null;
|
|
88
|
+
this._flush();
|
|
89
|
+
}, EDGY_RELATION_DELAY_MS);
|
|
90
|
+
}
|
|
91
|
+
_flush() {
|
|
92
|
+
const surface = this.gfx.surface;
|
|
93
|
+
const candidates = this._candidates;
|
|
94
|
+
// Whatever happens below, what accumulated is accounted for: a set left
|
|
95
|
+
// behind would be replayed against a later, unrelated gesture.
|
|
96
|
+
this._candidates = new Set();
|
|
97
|
+
if (!surface)
|
|
98
|
+
return;
|
|
99
|
+
resolveEdgyRelations(surface, candidates);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -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[];
|