@formicoidea/labre-framework-cynefin 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.
@@ -0,0 +1,134 @@
1
+ import { type CanvasRenderer, Overlay, type RoughCanvas } from '@formicoidea/labre-core/blocks/surface';
2
+ import { InteractivityExtension } from '@formicoidea/labre-core/std/gfx';
3
+ /**
4
+ * The REVEAL — the ~600 ms animation that plays when an Estuarine curve is
5
+ * switched back on (WS4, PO arbitration of 26/08/2026).
6
+ *
7
+ * The permanent look of a curve is now a discreet ghost (`GHOST_ALPHA`, dashed:
8
+ * see `./element-renderer.ts`). That is the right resting state and the wrong
9
+ * ARRIVAL: a user who clicks "show the Volatile line" and gets a 45 %-opacity
10
+ * dashed curve on a busy map can reasonably fail to notice anything happened,
11
+ * and conclude the toggle is broken. So the flip is animated — the dashes march
12
+ * along the path while a second, brighter stroke rides above the ghost, then
13
+ * decays away and leaves the ghost alone.
14
+ *
15
+ * An OVERLAY rather than a renderer change, for the same reasons the validation
16
+ * bracket is one: it touches no element model, writes nothing to the document,
17
+ * creates no undo entry, and holds nothing that could survive a reload. "When
18
+ * did I last flip this toggle" is session state and must never reach the
19
+ * document.
20
+ */
21
+ /** How long the reveal stroke takes to march across the curve. */
22
+ export declare const GHOST_REVEAL_MS = 600;
23
+ /** How long it then takes to fade back into the permanent ghost. */
24
+ export declare const GHOST_DECAY_MS = 200;
25
+ /** Total life of one reveal. */
26
+ export declare const GHOST_TOTAL_MS: number;
27
+ /**
28
+ * Reference-space units the dash pattern travels during the reveal — four
29
+ * periods of {@link GHOST_DASH}, so the march reads as motion rather than as a
30
+ * jitter.
31
+ */
32
+ export declare const GHOST_DASH_TRAVEL: number;
33
+ /**
34
+ * Peak opacity of the reveal stroke, ON TOP of the ghost the renderer already
35
+ * painted. Chosen so the two together reach a full-strength line at the crest
36
+ * and never exceed it.
37
+ */
38
+ export declare const GHOST_PEAK_ALPHA = 0.55;
39
+ /** One frame of the reveal. */
40
+ export interface GhostRevealFrame {
41
+ /** Opacity of the reveal stroke; `0` means "paint nothing". */
42
+ alpha: number;
43
+ /** `lineDashOffset` for this frame, negative so the dashes march forward. */
44
+ dashOffset: number;
45
+ /** Whether this reveal is over and can be forgotten. */
46
+ done: boolean;
47
+ }
48
+ /**
49
+ * The whole animation, as a pure function of elapsed milliseconds.
50
+ *
51
+ * Pure and exported on purpose: it is the only part of this file that has an
52
+ * opinion, and a function taking a number is testable without a canvas, a
53
+ * surface, a DI container or a clock. The overlay below is then reduced to
54
+ * "ask this, then stroke" — which is the part that cannot go wrong quietly.
55
+ *
56
+ * Out-of-range input is answered rather than trusted: a negative or non-finite
57
+ * elapsed (a clock that went backwards, a `performance.now()` mock) paints
58
+ * nothing instead of throwing or flashing.
59
+ */
60
+ export declare function ghostRevealFrame(elapsed: number): GhostRevealFrame;
61
+ /**
62
+ * Whether the user has asked their system for less motion.
63
+ *
64
+ * Read at every reveal rather than cached: the setting can change mid-session
65
+ * (an OS toggle, a devtools emulation), and the answer costs one media query.
66
+ * `globalThis.matchMedia` is optional-chained because this module is imported
67
+ * by unit specs running under Node.
68
+ */
69
+ export declare function prefersReducedMotion(): boolean;
70
+ /**
71
+ * Paints the reveal stroke over whatever curves are currently on.
72
+ *
73
+ * The rAF loop is copied from `ValidationOverlay`, deliberately and including
74
+ * its two guards: the clock is never armed when nothing is animating, and a
75
+ * detached renderer stops it dead. An overlay that keeps requesting frames for
76
+ * a surface that no longer exists is sixty repaints a second of nothing.
77
+ */
78
+ export declare class EstuarineGhostOverlay extends Overlay {
79
+ static overlayName: string;
80
+ /** Element id → `performance.now()` at the moment its toggle flipped. */
81
+ private readonly _reveals;
82
+ /** Armed only while something is still revealing. */
83
+ private _frame;
84
+ /**
85
+ * Whether the renderer this overlay paints into is gone. The manager below
86
+ * lives on the gfx scope and keeps its subscriptions on the surface MODEL,
87
+ * which outlives the surface COMPONENT — so a toggle can perfectly well be
88
+ * flipped after this overlay was torn down.
89
+ */
90
+ private _detached;
91
+ private readonly _onFrame;
92
+ /** Whether any reveal is still inside its window at `now`. */
93
+ isAnimating(now: number): boolean;
94
+ /**
95
+ * Start (or restart) the reveal on `elementId`.
96
+ *
97
+ * Reduced motion is honoured HERE rather than at the call site, so every
98
+ * future trigger inherits it: the permanent ghost the renderer paints is
99
+ * already the end state, so declining to animate is a complete no-op and not
100
+ * a degraded mode.
101
+ */
102
+ reveal(elementId: string): void;
103
+ /**
104
+ * Repaint, and keep repainting while a reveal is inside its window. Stops on
105
+ * its own: an idle board requests no animation frame at all.
106
+ */
107
+ private _schedule;
108
+ private _cancelFrame;
109
+ private _forget;
110
+ setRenderer(renderer: CanvasRenderer | null): void;
111
+ clear(): void;
112
+ dispose(): void;
113
+ render(ctx: CanvasRenderingContext2D, _rc: RoughCanvas): void;
114
+ }
115
+ /**
116
+ * Turns "a curve toggle just went from off to on" into a reveal.
117
+ *
118
+ * The transition is read from the `elementUpdated` payload — `props` carries
119
+ * the new value, `oldValues` the previous one — and BOTH halves are required:
120
+ * `props.showVolatile === true` alone also fires when the map is created, when
121
+ * a remote peer syncs an unrelated change, or when the value is rewritten
122
+ * identically. Only a genuine `false → true` flip is a user asking to see a
123
+ * line appear.
124
+ */
125
+ export declare class EstuarineGhostManager extends InteractivityExtension {
126
+ static key: string;
127
+ private _subscriptions;
128
+ private _disposeSurfaceEffect;
129
+ private get _overlay();
130
+ mounted(): void;
131
+ unmounted(): void;
132
+ private _unsubscribe;
133
+ private _resubscribe;
134
+ }
@@ -0,0 +1,277 @@
1
+ import { Overlay, OverlayIdentifier, } from '@formicoidea/labre-core/blocks/surface';
2
+ import { EstuarineElementModel } from '@formicoidea/labre-core/model';
3
+ import { InteractivityExtension } from '@formicoidea/labre-core/std/gfx';
4
+ import { effect } from '@preact/signals-core';
5
+ import { applyEstuarineTransform, estuarineCurves, GHOST_DASH, } from './element-renderer.js';
6
+ /**
7
+ * The REVEAL — the ~600 ms animation that plays when an Estuarine curve is
8
+ * switched back on (WS4, PO arbitration of 26/08/2026).
9
+ *
10
+ * The permanent look of a curve is now a discreet ghost (`GHOST_ALPHA`, dashed:
11
+ * see `./element-renderer.ts`). That is the right resting state and the wrong
12
+ * ARRIVAL: a user who clicks "show the Volatile line" and gets a 45 %-opacity
13
+ * dashed curve on a busy map can reasonably fail to notice anything happened,
14
+ * and conclude the toggle is broken. So the flip is animated — the dashes march
15
+ * along the path while a second, brighter stroke rides above the ghost, then
16
+ * decays away and leaves the ghost alone.
17
+ *
18
+ * An OVERLAY rather than a renderer change, for the same reasons the validation
19
+ * bracket is one: it touches no element model, writes nothing to the document,
20
+ * creates no undo entry, and holds nothing that could survive a reload. "When
21
+ * did I last flip this toggle" is session state and must never reach the
22
+ * document.
23
+ */
24
+ /** How long the reveal stroke takes to march across the curve. */
25
+ export const GHOST_REVEAL_MS = 600;
26
+ /** How long it then takes to fade back into the permanent ghost. */
27
+ export const GHOST_DECAY_MS = 200;
28
+ /** Total life of one reveal. */
29
+ export const GHOST_TOTAL_MS = GHOST_REVEAL_MS + GHOST_DECAY_MS;
30
+ /**
31
+ * Reference-space units the dash pattern travels during the reveal — four
32
+ * periods of {@link GHOST_DASH}, so the march reads as motion rather than as a
33
+ * jitter.
34
+ */
35
+ export const GHOST_DASH_TRAVEL = 4 * (GHOST_DASH[0] + GHOST_DASH[1]);
36
+ /**
37
+ * Peak opacity of the reveal stroke, ON TOP of the ghost the renderer already
38
+ * painted. Chosen so the two together reach a full-strength line at the crest
39
+ * and never exceed it.
40
+ */
41
+ export const GHOST_PEAK_ALPHA = 0.55;
42
+ /**
43
+ * The whole animation, as a pure function of elapsed milliseconds.
44
+ *
45
+ * Pure and exported on purpose: it is the only part of this file that has an
46
+ * opinion, and a function taking a number is testable without a canvas, a
47
+ * surface, a DI container or a clock. The overlay below is then reduced to
48
+ * "ask this, then stroke" — which is the part that cannot go wrong quietly.
49
+ *
50
+ * Out-of-range input is answered rather than trusted: a negative or non-finite
51
+ * elapsed (a clock that went backwards, a `performance.now()` mock) paints
52
+ * nothing instead of throwing or flashing.
53
+ */
54
+ export function ghostRevealFrame(elapsed) {
55
+ if (!Number.isFinite(elapsed) || elapsed <= 0) {
56
+ return { alpha: 0, dashOffset: 0, done: false };
57
+ }
58
+ if (elapsed >= GHOST_TOTAL_MS) {
59
+ return { alpha: 0, dashOffset: 0, done: true };
60
+ }
61
+ // The march stops when the reveal does; the decay fades a still line.
62
+ const marched = Math.min(elapsed, GHOST_REVEAL_MS) / GHOST_REVEAL_MS;
63
+ const dashOffset = -GHOST_DASH_TRAVEL * marched;
64
+ const alpha = elapsed <= GHOST_REVEAL_MS
65
+ ? GHOST_PEAK_ALPHA * marched
66
+ : GHOST_PEAK_ALPHA * (1 - (elapsed - GHOST_REVEAL_MS) / GHOST_DECAY_MS);
67
+ return { alpha, dashOffset, done: false };
68
+ }
69
+ /**
70
+ * Whether the user has asked their system for less motion.
71
+ *
72
+ * Read at every reveal rather than cached: the setting can change mid-session
73
+ * (an OS toggle, a devtools emulation), and the answer costs one media query.
74
+ * `globalThis.matchMedia` is optional-chained because this module is imported
75
+ * by unit specs running under Node.
76
+ */
77
+ export function prefersReducedMotion() {
78
+ return (globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false);
79
+ }
80
+ /**
81
+ * Paints the reveal stroke over whatever curves are currently on.
82
+ *
83
+ * The rAF loop is copied from `ValidationOverlay`, deliberately and including
84
+ * its two guards: the clock is never armed when nothing is animating, and a
85
+ * detached renderer stops it dead. An overlay that keeps requesting frames for
86
+ * a surface that no longer exists is sixty repaints a second of nothing.
87
+ */
88
+ export class EstuarineGhostOverlay extends Overlay {
89
+ constructor() {
90
+ super(...arguments);
91
+ /** Element id → `performance.now()` at the moment its toggle flipped. */
92
+ this._reveals = new Map();
93
+ /** Armed only while something is still revealing. */
94
+ this._frame = null;
95
+ /**
96
+ * Whether the renderer this overlay paints into is gone. The manager below
97
+ * lives on the gfx scope and keeps its subscriptions on the surface MODEL,
98
+ * which outlives the surface COMPONENT — so a toggle can perfectly well be
99
+ * flipped after this overlay was torn down.
100
+ */
101
+ this._detached = false;
102
+ this._onFrame = () => {
103
+ this._frame = null;
104
+ this._schedule();
105
+ };
106
+ }
107
+ static { this.overlayName = 'estuarine-ghost'; }
108
+ /** Whether any reveal is still inside its window at `now`. */
109
+ isAnimating(now) {
110
+ for (const start of this._reveals.values()) {
111
+ if (!ghostRevealFrame(now - start).done)
112
+ return true;
113
+ }
114
+ return false;
115
+ }
116
+ /**
117
+ * Start (or restart) the reveal on `elementId`.
118
+ *
119
+ * Reduced motion is honoured HERE rather than at the call site, so every
120
+ * future trigger inherits it: the permanent ghost the renderer paints is
121
+ * already the end state, so declining to animate is a complete no-op and not
122
+ * a degraded mode.
123
+ */
124
+ reveal(elementId) {
125
+ if (this._detached)
126
+ return;
127
+ if (prefersReducedMotion())
128
+ return;
129
+ this._reveals.set(elementId, performance.now());
130
+ this._schedule();
131
+ }
132
+ /**
133
+ * Repaint, and keep repainting while a reveal is inside its window. Stops on
134
+ * its own: an idle board requests no animation frame at all.
135
+ */
136
+ _schedule() {
137
+ if (this._detached)
138
+ return;
139
+ this.refresh();
140
+ if (this._frame !== null)
141
+ return;
142
+ if (!this.isAnimating(performance.now()))
143
+ return;
144
+ this._frame = requestAnimationFrame(this._onFrame);
145
+ }
146
+ _cancelFrame() {
147
+ if (this._frame === null)
148
+ return;
149
+ cancelAnimationFrame(this._frame);
150
+ this._frame = null;
151
+ }
152
+ _forget() {
153
+ this._cancelFrame();
154
+ this._reveals.clear();
155
+ }
156
+ setRenderer(renderer) {
157
+ this._detached = renderer === null;
158
+ super.setRenderer(renderer);
159
+ }
160
+ clear() {
161
+ this._forget();
162
+ super.clear();
163
+ }
164
+ dispose() {
165
+ this._detached = true;
166
+ this._forget();
167
+ super.dispose();
168
+ }
169
+ render(ctx, _rc) {
170
+ if (this._reveals.size === 0)
171
+ return;
172
+ const surface = this.gfx.surface;
173
+ if (!surface)
174
+ return;
175
+ const now = performance.now();
176
+ for (const [id, start] of [...this._reveals]) {
177
+ const frame = ghostRevealFrame(now - start);
178
+ if (frame.done) {
179
+ this._reveals.delete(id);
180
+ continue;
181
+ }
182
+ if (frame.alpha <= 0)
183
+ continue;
184
+ const model = surface.getElementById(id);
185
+ // Deleted, or replaced by something else entirely: the reveal is about
186
+ // an element that no longer exists.
187
+ if (!(model instanceof EstuarineElementModel)) {
188
+ this._reveals.delete(id);
189
+ continue;
190
+ }
191
+ const [x, y, w, h] = model.deserializedXYWH;
192
+ ctx.save();
193
+ // Model space, exactly like the element renderer: translate to the
194
+ // element, rotate about its centre, then enter the STRETCHED reference
195
+ // frame through the shared transform — the reveal stroke has to sit on
196
+ // the ghost to the pixel, so the two go through one function, never two
197
+ // copies of the same arithmetic. Recomputed at PAINT time rather than
198
+ // captured at reveal time, so the stroke follows a map the user drags,
199
+ // rotates or resizes mid-animation.
200
+ ctx.translate(x, y);
201
+ ctx.translate(w / 2, h / 2);
202
+ ctx.rotate((model.rotate * Math.PI) / 180);
203
+ ctx.translate(-w / 2, -h / 2);
204
+ const fit = applyEstuarineTransform(ctx, w, h);
205
+ ctx.lineCap = 'round';
206
+ ctx.lineJoin = 'round';
207
+ ctx.globalAlpha = frame.alpha;
208
+ ctx.setLineDash([...GHOST_DASH]);
209
+ ctx.lineDashOffset = frame.dashOffset;
210
+ for (const curve of estuarineCurves()) {
211
+ if (!model[curve.visibleProp])
212
+ continue;
213
+ ctx.strokeStyle = curve.color;
214
+ ctx.lineWidth = curve.width * fit.curveLineScale;
215
+ ctx.stroke(curve.path);
216
+ }
217
+ ctx.restore();
218
+ }
219
+ }
220
+ }
221
+ /** The three toggles a reveal can be triggered by. */
222
+ const SHOW_PROPS = [
223
+ 'showLiminal',
224
+ 'showVolatile',
225
+ 'showCounterfactual',
226
+ ];
227
+ /**
228
+ * Turns "a curve toggle just went from off to on" into a reveal.
229
+ *
230
+ * The transition is read from the `elementUpdated` payload — `props` carries
231
+ * the new value, `oldValues` the previous one — and BOTH halves are required:
232
+ * `props.showVolatile === true` alone also fires when the map is created, when
233
+ * a remote peer syncs an unrelated change, or when the value is rewritten
234
+ * identically. Only a genuine `false → true` flip is a user asking to see a
235
+ * line appear.
236
+ */
237
+ export class EstuarineGhostManager extends InteractivityExtension {
238
+ constructor() {
239
+ super(...arguments);
240
+ this._subscriptions = [];
241
+ this._disposeSurfaceEffect = null;
242
+ }
243
+ static { this.key = 'estuarine-ghost'; }
244
+ get _overlay() {
245
+ return this.std.getOptional(OverlayIdentifier(EstuarineGhostOverlay.overlayName));
246
+ }
247
+ mounted() {
248
+ // The surface is a SIGNAL, not a fact: it can be null at mount and arrive
249
+ // later, and it is replaced if the surface block is.
250
+ this._disposeSurfaceEffect = effect(() => {
251
+ this._resubscribe(this.gfx.surface$.value);
252
+ });
253
+ }
254
+ unmounted() {
255
+ this._disposeSurfaceEffect?.();
256
+ this._disposeSurfaceEffect = null;
257
+ this._unsubscribe();
258
+ super.unmounted();
259
+ }
260
+ _unsubscribe() {
261
+ for (const subscription of this._subscriptions)
262
+ subscription.unsubscribe();
263
+ this._subscriptions = [];
264
+ }
265
+ _resubscribe(surface) {
266
+ this._unsubscribe();
267
+ if (!surface)
268
+ return;
269
+ this._subscriptions.push(surface.elementUpdated.subscribe(({ id, props, oldValues }) => {
270
+ if (!props || !oldValues)
271
+ return;
272
+ const flipped = SHOW_PROPS.some(prop => props[prop] === true && oldValues[prop] === false);
273
+ if (flipped)
274
+ this._overlay?.reveal(id);
275
+ }));
276
+ }
277
+ }
@@ -0,0 +1,41 @@
1
+ import type { QualityNudge } from '@formicoidea/labre-core/blocks/surface';
2
+ /**
3
+ * Estuarine **map quality** — the checklist, and this framework's ENTIRE
4
+ * contribution to validation (WS4).
5
+ *
6
+ * ## No rules, and therefore no profiles (PO arbitration, 26/08/2026)
7
+ *
8
+ * The 26/08 study looked for deterministic rules on an Estuarine map and found
9
+ * none worth shipping. Everything an Estuarine session decides — where the
10
+ * counter-factual line falls, which constraints are volatile, whether a
11
+ * constraint is a constructor or an actor — is a judgement the group makes out
12
+ * loud; an algorithm that claimed to check any of it would be lying about the
13
+ * one thing the method exists to produce.
14
+ *
15
+ * A severity profile is a dial over rules. With no rule to dial, a profile
16
+ * picker is a control that decides nothing: three entries, no observable
17
+ * effect, and a user reasonably concluding that the tool checks something it
18
+ * does not. So this framework ships **no `profiles.ts`** — deliberately, not
19
+ * by omission. The profiles will be born WITH the first rule, if one is ever
20
+ * born; until then the file's absence is the honest statement.
21
+ *
22
+ * What does reach the user is this checklist, offered on the map because
23
+ * `ValidationFrameworkExtension` declares `estuarine:map` a root instance
24
+ * (WS0.3) — the gate no longer needs a rule to exist.
25
+ *
26
+ * Registered from the flag-gated `CynefinEstuarineViewExtension`, beside the
27
+ * framework declaration: a checklist is tooling. Switching the flag off takes
28
+ * it away and leaves the ticks written on the map, unread, until it comes back.
29
+ */
30
+ /**
31
+ * **Q1–Q4** — the four things an Estuarine map needs in order to do its job,
32
+ * and that no algorithm can check.
33
+ *
34
+ * Q1 and Q2 are about the two lines that make the map a map: a counter-factual
35
+ * line nobody argued over is a curve, not a boundary, and an undelimited
36
+ * volatile zone leaves every constraint equally urgent. Q3 is the method's own
37
+ * discipline — above the counter-factual line you do not act, you Monitor,
38
+ * Research or Request. Q4 is the typing the hexagons carry in the group's head
39
+ * and nowhere in the document.
40
+ */
41
+ export declare const ESTUARINE_NUDGES: readonly QualityNudge[];
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Estuarine **map quality** — the checklist, and this framework's ENTIRE
3
+ * contribution to validation (WS4).
4
+ *
5
+ * ## No rules, and therefore no profiles (PO arbitration, 26/08/2026)
6
+ *
7
+ * The 26/08 study looked for deterministic rules on an Estuarine map and found
8
+ * none worth shipping. Everything an Estuarine session decides — where the
9
+ * counter-factual line falls, which constraints are volatile, whether a
10
+ * constraint is a constructor or an actor — is a judgement the group makes out
11
+ * loud; an algorithm that claimed to check any of it would be lying about the
12
+ * one thing the method exists to produce.
13
+ *
14
+ * A severity profile is a dial over rules. With no rule to dial, a profile
15
+ * picker is a control that decides nothing: three entries, no observable
16
+ * effect, and a user reasonably concluding that the tool checks something it
17
+ * does not. So this framework ships **no `profiles.ts`** — deliberately, not
18
+ * by omission. The profiles will be born WITH the first rule, if one is ever
19
+ * born; until then the file's absence is the honest statement.
20
+ *
21
+ * What does reach the user is this checklist, offered on the map because
22
+ * `ValidationFrameworkExtension` declares `estuarine:map` a root instance
23
+ * (WS0.3) — the gate no longer needs a rule to exist.
24
+ *
25
+ * Registered from the flag-gated `CynefinEstuarineViewExtension`, beside the
26
+ * framework declaration: a checklist is tooling. Switching the flag off takes
27
+ * it away and leaves the ticks written on the map, unread, until it comes back.
28
+ */
29
+ /**
30
+ * **Q1–Q4** — the four things an Estuarine map needs in order to do its job,
31
+ * and that no algorithm can check.
32
+ *
33
+ * Q1 and Q2 are about the two lines that make the map a map: a counter-factual
34
+ * line nobody argued over is a curve, not a boundary, and an undelimited
35
+ * volatile zone leaves every constraint equally urgent. Q3 is the method's own
36
+ * discipline — above the counter-factual line you do not act, you Monitor,
37
+ * Research or Request. Q4 is the typing the hexagons carry in the group's head
38
+ * and nowhere in the document.
39
+ */
40
+ export const ESTUARINE_NUDGES = [
41
+ {
42
+ id: 'estuarine.q1-counterfactual',
43
+ framework: 'estuarine',
44
+ labelKey: 'com.labre.estuarine.quality.counterfactual',
45
+ fallback: 'The counter-factual line has been negotiated and drawn by the group',
46
+ order: 1,
47
+ },
48
+ {
49
+ id: 'estuarine.q2-volatile-zone',
50
+ framework: 'estuarine',
51
+ labelKey: 'com.labre.estuarine.quality.volatile-zone',
52
+ fallback: 'The volatile zone has been delimited',
53
+ order: 2,
54
+ },
55
+ {
56
+ id: 'estuarine.q3-strategies',
57
+ framework: 'estuarine',
58
+ labelKey: 'com.labre.estuarine.quality.strategies',
59
+ fallback: 'Every element above the counter-factual line has a Monitor, Research or Request strategy',
60
+ order: 3,
61
+ },
62
+ {
63
+ id: 'estuarine.q4-hexagon-types',
64
+ framework: 'estuarine',
65
+ labelKey: 'com.labre.estuarine.quality.hexagon-types',
66
+ fallback: 'Every hexagon is typed: constraint, constructor or actor',
67
+ order: 4,
68
+ },
69
+ ];
@@ -0,0 +1,48 @@
1
+ import type { RoleDefs } from '@formicoidea/labre-core/std/gfx';
2
+ /**
3
+ * Estuarine role vocabulary (WS4).
4
+ *
5
+ * Two roles, and deliberately only two: the MAP — the axes and the three
6
+ * reference curves an Estuarine session is read against — and the CONSTRAINT
7
+ * hexagon the group drops onto it. Everything else on such a board (the
8
+ * captions, the stickies, a free arrow drawn to link two hexagons) is a plain
9
+ * drawing and stays neutral, because nothing in this framework has anything to
10
+ * say about it.
11
+ *
12
+ * ## Cynefin declares NO role, and never will
13
+ *
14
+ * The two frameworks share this package and share nothing else here. The PO's
15
+ * arbitration of 26/08/2026 is that Cynefin is out of the validation perimeter
16
+ * for good: its four domains are a sense-making device, not a notation with
17
+ * rules, so giving its background a role would advertise tooling — a profile
18
+ * picker, a quality checklist — over a framework that has nothing to check.
19
+ * A role with no consumer is a promise the product does not keep.
20
+ *
21
+ * ## What a role buys Estuarine, which ships no rule either
22
+ *
23
+ * The map role is what makes the map a ROOT INSTANCE: it is handed to
24
+ * `ValidationFrameworkExtension` so the engine can answer "is this element
25
+ * somebody's frame?" and offer the Map quality checklist on it. That gate used
26
+ * to be derived from the registered rules alone, which is exactly why WS0.3
27
+ * gave it a second, explicit source — see `ValidationFrameworkDef`.
28
+ *
29
+ * The constraint role earns its keep on its own: a hexagon is a plain polygon
30
+ * shape on the canvas, so nothing about its geometry says what it means, and
31
+ * the first rule this framework ever writes will be written against this role
32
+ * rather than against `shapeType === 'polygon'`.
33
+ *
34
+ * ## Compatibility
35
+ *
36
+ * No backfill (promise #71): maps and hexagons drawn before today carry no
37
+ * role, are nobody's frame, and go on painting exactly as they did. They simply
38
+ * offer no checklist until they are redrawn.
39
+ */
40
+ /** Every role this framework declares. */
41
+ export type EstuarineRole = 'map' | 'constraint';
42
+ export type EstuarineRoleId = `estuarine:${EstuarineRole}`;
43
+ /** Role ids, keyed by the name used at the creation sites. */
44
+ export declare const ESTUARINE_ROLE: {
45
+ readonly map: "estuarine:map";
46
+ readonly constraint: "estuarine:constraint";
47
+ };
48
+ export declare const ESTUARINE_ROLES: RoleDefs;
@@ -0,0 +1,28 @@
1
+ /** Role ids, keyed by the name used at the creation sites. */
2
+ export const ESTUARINE_ROLE = {
3
+ map: 'estuarine:map',
4
+ constraint: 'estuarine:constraint',
5
+ };
6
+ const DEFS = [
7
+ // The map itself: the e / t axes and the three curves everything else is
8
+ // positioned against. A frame, so it specialises nothing — a rule written on
9
+ // `estuarine:constraint` must never match the map it is measured against.
10
+ {
11
+ id: ESTUARINE_ROLE.map,
12
+ kind: 'node',
13
+ labelKey: 'com.labre.estuarine.role.map',
14
+ labelFallback: 'Estuarine map',
15
+ },
16
+ // The hexi constraint. A `node`: it is measured by its bounds, which is
17
+ // exactly what an Estuarine reading is about — where on the energy/time
18
+ // plane the group placed this constraint.
19
+ {
20
+ id: ESTUARINE_ROLE.constraint,
21
+ kind: 'node',
22
+ labelKey: 'com.labre.estuarine.role.constraint',
23
+ labelFallback: 'Constraint',
24
+ },
25
+ ];
26
+ // Null prototype: a lookup table keyed by ids that may one day come from
27
+ // host-supplied packs, so `defs['toString']` must not resolve.
28
+ export const ESTUARINE_ROLES = Object.assign(Object.create(null), Object.fromEntries(DEFS.map(def => [def.id, def])));
@@ -3,13 +3,67 @@ import { EstuarineElementModel } from '@formicoidea/labre-core/model';
3
3
  import { ToolbarModuleExtension, } from '@formicoidea/labre-core/shared/services';
4
4
  import { BlockFlavourIdentifier } from '@formicoidea/labre-core/std';
5
5
  import { html } from 'lit';
6
- const ResizeIcon = html `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5H5v4M15 19h4v-4" /><path d="M5 5l6 6M19 19l-6-6" /></svg>`;
6
+ const ResizeIcon = html `<svg
7
+ width="24"
8
+ height="24"
9
+ viewBox="0 0 24 24"
10
+ fill="none"
11
+ stroke="currentColor"
12
+ stroke-width="1.6"
13
+ stroke-linecap="round"
14
+ stroke-linejoin="round"
15
+ >
16
+ <path d="M9 5H5v4M15 19h4v-4" />
17
+ <path d="M5 5l6 6M19 19l-6-6" />
18
+ </svg>`;
7
19
  // All curve icons use currentColor so the toolbar can grey them when inactive;
8
20
  // they are distinguished by shape (wave / arc / hooked curve).
9
- const LiminalIcon = html `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 13c3-6 6 4 9 0s6-6 9 0" /></svg>`;
10
- const VolatileIcon = html `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M7 4a9 9 0 0 1 0 18" /></svg>`;
11
- const CounterfactualIcon = html `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7c7 0 11 4 12 14" /></svg>`;
12
- const AxisIcon = html `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3v18M6 12h15" /><path d="M3 6l3-3 3 3M18 9l3 3-3 3" /></svg>`;
21
+ const LiminalIcon = html `<svg
22
+ width="24"
23
+ height="24"
24
+ viewBox="0 0 24 24"
25
+ fill="none"
26
+ stroke="currentColor"
27
+ stroke-width="2"
28
+ stroke-linecap="round"
29
+ >
30
+ <path d="M3 13c3-6 6 4 9 0s6-6 9 0" />
31
+ </svg>`;
32
+ const VolatileIcon = html `<svg
33
+ width="24"
34
+ height="24"
35
+ viewBox="0 0 24 24"
36
+ fill="none"
37
+ stroke="currentColor"
38
+ stroke-width="2"
39
+ stroke-linecap="round"
40
+ >
41
+ <path d="M7 4a9 9 0 0 1 0 18" />
42
+ </svg>`;
43
+ const CounterfactualIcon = html `<svg
44
+ width="24"
45
+ height="24"
46
+ viewBox="0 0 24 24"
47
+ fill="none"
48
+ stroke="currentColor"
49
+ stroke-width="2"
50
+ stroke-linecap="round"
51
+ >
52
+ <path d="M4 7c7 0 11 4 12 14" />
53
+ </svg>`;
54
+ const AxisIcon = html `<svg
55
+ width="24"
56
+ height="24"
57
+ viewBox="0 0 24 24"
58
+ fill="none"
59
+ stroke="currentColor"
60
+ stroke-width="1.6"
61
+ stroke-linecap="round"
62
+ stroke-linejoin="round"
63
+ >
64
+ <path d="M6 3v18M6 12h15" />
65
+ <path d="M3 6l3-3 3 3M18 9l3 3-3 3" />
66
+ </svg>`;
13
67
  function booleanToggle(id, tooltip, icon, prop) {
14
68
  return {
15
69
  id,