@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.
@@ -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
+ };
@@ -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
  /**
@@ -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',
@@ -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
+ ];
@@ -0,0 +1,2 @@
1
+ import type { ValidationProfile } from '@formicoidea/labre-core/blocks/surface';
2
+ export declare const EDGY_PROFILES: readonly ValidationProfile[];
@@ -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[];