@formicoidea/labre-framework-edgy 0.32.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 +18 -0
- package/dist/actions.js +55 -2
- package/dist/board-renderer.js +4 -1
- package/dist/commands.js +21 -3
- package/dist/element-renderer.js +5 -2
- 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 +5 -13
- package/dist/templates/index.js +210 -107
- package/dist/toolbar/config.d.ts +10 -0
- package/dist/toolbar/config.js +38 -1
- 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 +23 -9
- package/dist/view.d.ts +5 -2
- package/dist/view.js +54 -5
- package/package.json +6 -3
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[];
|
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;
|