@formicoidea/labre-framework-wardley 0.35.0 → 0.36.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,255 @@
1
+ import { WardleyNodeElementModel } from '@formicoidea/labre-core/model';
2
+ import { ELEMENT_TAGS_FIELD, elementTagValues, InteractivityExtension, setElementTag, } from '@formicoidea/labre-core/std/gfx';
3
+ import { WARDLEY_COMPETITION_TAG_ID } from '../natures.js';
4
+ import { competitionOfPorterLetter, porterLetterOfCompetition, } from '../porter-competition.js';
5
+ /**
6
+ * Keeps a Porter's-forces glyph saying ONE thing: the letter in the circle and
7
+ * the `wardley:competition` tag on it are two spellings of the same statement.
8
+ *
9
+ * ## Why there are two spellings at all
10
+ *
11
+ * The letter is the NOTATION — an architect reads `L` off the map and knows
12
+ * what it says, and typing another letter is the whole of the edit
13
+ * (`node-view.ts`). The tag is the FACT — the half a rule, a reading or a host
14
+ * report can query, because nothing can ask "which forces does this map name?"
15
+ * of a `Y.Text` holding one character. Dropping either would cost something
16
+ * real, so both exist.
17
+ *
18
+ * ## The invariant: the tag is DERIVED from the letter
19
+ *
20
+ * The two are not symmetric, and the recette of #211 is what made the asymmetry
21
+ * necessary. The letter is what the author writes; the tag is a reading of it,
22
+ * recomputed on EVERY local change of the text and written as a consequence of
23
+ * that change rather than as a step of its own. So `tag = f(text)` holds at
24
+ * every instant, including at every instant an undo or a redo passes through.
25
+ *
26
+ * That is the fix for the defect the recette found. Reconciling only when the
27
+ * editor closed left the two able to disagree: the inline editor writes a
28
+ * select-all-and-retype as several Yjs operations, each its own undo entry,
29
+ * while the tag was one further entry captured after all of them. Undoing then
30
+ * landed on an intermediate text — `RR` — carrying the tag of the text that
31
+ * came after it, and redoing past the tag's own entry re-ran the reconciliation
32
+ * against a text that had already moved on, so the document never returned to
33
+ * where it started.
34
+ *
35
+ * The reading is taken on a MICROTASK after the change, and written UNTRACKED
36
+ * (`Store.withoutTransact`, an origin the undo manager ignores). So the undo
37
+ * stack holds the author's keystrokes and nothing else, and whatever text a
38
+ * step lands on — `E`, the intermediate `RR`, the original `R` — the tag is
39
+ * recomputed from it a tick later. There is no entry to get out of order,
40
+ * because the qualification is not an entry.
41
+ *
42
+ * ## Tag → letter, and why it is the quiet direction
43
+ *
44
+ * A menu pick is the one gesture that starts from the tag, so it — and only it
45
+ * — redraws the letter. Two guards keep that from becoming a second author:
46
+ *
47
+ * - It compares the two SEMANTICALLY (`competitionOfPorterLetter(raw)`), never
48
+ * character by character. A circle reading `e` already says "struggle to
49
+ * establish", so a tag derived from it must not turn round and rewrite it to
50
+ * `E`. Without this the two directions would trade writes on every keystroke.
51
+ * - It never runs while the id is in the EDITING set. That is C4's lesson
52
+ * (`C4TypeLineWatcher`): rewriting the text an author has their caret in
53
+ * throws that caret, and no letter is worth it.
54
+ *
55
+ * The one deliberate asymmetry: a CLEARED tag leaves the circle alone. Removing
56
+ * a value from the menu is a statement about the qualification — "I no longer
57
+ * claim which force this is" — and not about a map somebody is still reading.
58
+ * The tie is re-tied by the next edit of the letter, which is the only gesture
59
+ * that ever asserts one.
60
+ *
61
+ * ## `local`, and always-on
62
+ *
63
+ * Both directions are LOCAL-only, for `EdgyRelationResolver`'s reason: without
64
+ * it every peer on a shared board would derive the same tag the moment a letter
65
+ * synced, giving N−1 of them an entry for a gesture they did not make.
66
+ *
67
+ * Registered by the RENDER half rather than the flag-gated one
68
+ * (`docs/adr/0009`): this creates no element, offers no button and adds no
69
+ * artefact to any map. It keeps an element ALREADY in the document coherent
70
+ * with itself, which a force drawn while the Wardley button was on must stay
71
+ * when the button goes off.
72
+ */
73
+ export class WardleyPorterWatcher extends InteractivityExtension {
74
+ constructor() {
75
+ super(...arguments);
76
+ this._selection = null;
77
+ this._updated = null;
78
+ this._disposeSurface = null;
79
+ this._editing = new Set();
80
+ this._pending = new Set();
81
+ this._scheduled = false;
82
+ this._mounted = false;
83
+ }
84
+ static { this.key = 'wardley-porter-watcher'; }
85
+ mounted() {
86
+ this._mounted = true;
87
+ // The editing set is tracked for ONE purpose: to know whose caret is where,
88
+ // so the tag→letter direction never rewrites a circle somebody is inside.
89
+ this._selection = this.gfx.selection.slots.updated.subscribe(selections => {
90
+ this._editing = wardleyEditingIds(selections);
91
+ });
92
+ // The surface is a SIGNAL — null at mount on a document whose surface block
93
+ // has not arrived yet, and replaced when it is. Subscribing to the signal
94
+ // rather than reading it once is what `EdgyRelationResolver` does, and for
95
+ // the same reason: a subscription taken against a surface that no longer
96
+ // exists is a subscription to nothing.
97
+ this._disposeSurface = this.gfx.surface$.subscribe(surface => {
98
+ this._updated?.unsubscribe();
99
+ this._updated = null;
100
+ if (!surface)
101
+ return;
102
+ this._updated = surface.elementUpdated.subscribe(({ id, props, local }) => {
103
+ if (!local || !props)
104
+ return;
105
+ // `watchText` bridges the nested `Y.Text` under this key, so this is
106
+ // every change of the letter — a keystroke, an undo, a redo alike.
107
+ if ('text' in props)
108
+ this._scheduleDerive(id);
109
+ // `observeTags` republishes the nested map under the field's own key,
110
+ // so this is the one payload a qualification produces.
111
+ if (ELEMENT_TAGS_FIELD in props)
112
+ this._drawLetterFromTag(id);
113
+ });
114
+ });
115
+ }
116
+ unmounted() {
117
+ this._mounted = false;
118
+ this._pending.clear();
119
+ this._selection?.unsubscribe();
120
+ this._selection = null;
121
+ this._updated?.unsubscribe();
122
+ this._updated = null;
123
+ this._disposeSurface?.();
124
+ this._disposeSurface = null;
125
+ this._editing = new Set();
126
+ super.unmounted();
127
+ }
128
+ /** The porter this id names, when it is one this peer may rewrite. */
129
+ _porter(elementId) {
130
+ // A read-only document is READ: every write below would be refused, and
131
+ // nobody can have typed a letter into it in the first place.
132
+ if (this.std.store.readonly)
133
+ return null;
134
+ const element = this.gfx.surface?.getElementById(elementId);
135
+ if (!(element instanceof WardleyNodeElementModel))
136
+ return null;
137
+ if (element.kind !== 'porter')
138
+ return null;
139
+ if (element.isLocked())
140
+ return null;
141
+ return element;
142
+ }
143
+ /**
144
+ * Queue a re-reading of the letter, for the end of the current task.
145
+ *
146
+ * A MICROTASK, and it is load-bearing rather than cosmetic. `watchText` fires
147
+ * from inside the Yjs transaction that changed the text, and at that instant
148
+ * the element's own `Y.Map` observer may not have run yet — so `element.tags`
149
+ * can still resolve, through `_preserved`, to a nested map the very same
150
+ * transaction has just deleted. A tag written into that map goes nowhere at
151
+ * all: the recette of #211 saw exactly that, as an undo landing on a letter
152
+ * whose force had silently failed to follow it.
153
+ *
154
+ * Deferring past the transaction settles two things at once. The document is
155
+ * consistent when the reading is taken, and the write lands OUTSIDE any
156
+ * tracked transaction — so {@link Store.withoutTransact} really does keep it
157
+ * off the undo stack, and the tag becomes what it claims to be: a pure
158
+ * function of the text, recomputed after every change of it, in either
159
+ * direction of history, and never a step an author has to undo.
160
+ */
161
+ _scheduleDerive(elementId) {
162
+ this._pending.add(elementId);
163
+ if (this._scheduled)
164
+ return;
165
+ this._scheduled = true;
166
+ queueMicrotask(() => {
167
+ this._scheduled = false;
168
+ const ids = [...this._pending];
169
+ this._pending.clear();
170
+ if (!this._mounted)
171
+ return;
172
+ for (const id of ids)
173
+ this._deriveTagFromText(id);
174
+ });
175
+ }
176
+ /** The letter as it now stands, written down as the fact it states. */
177
+ _deriveTagFromText(elementId) {
178
+ const element = this._porter(elementId);
179
+ if (!element)
180
+ return;
181
+ const value = competitionOfPorterLetter(element.text?.toString());
182
+ // Anything that is not one of the three — a half-typed `RR`, a stray word,
183
+ // an emptied circle — carries no tag. A glyph nobody can read must not be
184
+ // reported as a force somebody named, and an INTERMEDIATE state an undo
185
+ // stops on is exactly such a glyph.
186
+ const next = value ? [value] : [];
187
+ const current = elementTagValues(element, WARDLEY_COMPETITION_TAG_ID);
188
+ if (current.length === next.length &&
189
+ current.every((v, i) => v === next[i]))
190
+ return;
191
+ // No `captureSync`, and UNTRACKED. The tag is a consequence of the letter,
192
+ // never a step of its own: `withoutTransact` writes with an origin the undo
193
+ // manager ignores, so an author undoes the letter they typed and the
194
+ // reading of it simply follows, in either direction, for ever.
195
+ this.std.store.withoutTransact(() => {
196
+ setElementTag(element, WARDLEY_COMPETITION_TAG_ID, next);
197
+ });
198
+ }
199
+ /** The value just picked from the menu, drawn as the letter it stands for. */
200
+ _drawLetterFromTag(elementId) {
201
+ const element = this._porter(elementId);
202
+ if (!element)
203
+ return;
204
+ // Never into a circle somebody has their caret in (`C4TypeLineWatcher`).
205
+ if (this._editing.has(elementId))
206
+ return;
207
+ const value = elementTagValues(element, WARDLEY_COMPETITION_TAG_ID)[0];
208
+ const letter = porterLetterOfCompetition(value);
209
+ // A cleared tag — and a value whose def has vanished, which has no letter
210
+ // either — leaves the circle exactly as it is. See the class comment: this
211
+ // is the one deliberate asymmetry.
212
+ if (!letter)
213
+ return;
214
+ const text = element.text;
215
+ if (!text)
216
+ return;
217
+ const raw = text.toString();
218
+ // SEMANTICALLY, not character by character: a circle reading `e` already
219
+ // says this value, and rewriting it to `E` would be this watcher answering
220
+ // its own derivation on every keystroke.
221
+ if (competitionOfPorterLetter(raw) === value)
222
+ return;
223
+ // No `captureSync` either: this only ever runs as a consequence of the tag
224
+ // write that caused it, and it nests into that write's transaction — so a
225
+ // menu pick costs exactly one undo entry, redrawing included.
226
+ //
227
+ // Mutated IN PLACE rather than replaced, as `C4TypeLineWatcher` does: the
228
+ // `Y.Text` instance is what the element's change watcher and any bound
229
+ // editor hold, and swapping it for a fresh one would leave both pointing at
230
+ // a text nobody is editing.
231
+ this.std.store.transact(() => {
232
+ text.delete(0, text.length);
233
+ text.insert(0, letter);
234
+ });
235
+ }
236
+ }
237
+ /**
238
+ * The ids currently being EDITED, out of a selection update.
239
+ *
240
+ * A pure function because it is the whole of what the watcher needs to know
241
+ * about the selection: a text editor holds an editing selection for as long as
242
+ * it is mounted and drops it when it closes, so this set is "whose caret is in
243
+ * something right now" and nothing else. Total over an empty selection, which
244
+ * is what arrives when an editor closes onto bare canvas.
245
+ */
246
+ export function wardleyEditingIds(selections) {
247
+ const editing = new Set();
248
+ for (const selection of selections) {
249
+ if (!selection.editing)
250
+ continue;
251
+ for (const id of selection.elements)
252
+ editing.add(id);
253
+ }
254
+ return editing;
255
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The letter and the tag, in both directions — the whole of what makes the
3
+ * drawing and the qualification say the same thing.
4
+ *
5
+ * A pure module on purpose: this is the only place where the notation
6
+ * (`R` / `L` / `E`, one character in a circle) and the queryable fact
7
+ * (`wardley:competition/...`) are related to one another, and it is the part of
8
+ * `WardleyPorterWatcher` worth being wrong about. Nothing here reads an
9
+ * element, a surface or a document.
10
+ */
11
+ /** The three letters the notation admits, and nothing else. */
12
+ export type WardleyPorterLetter = 'R' | 'L' | 'E';
13
+ /**
14
+ * What a competition value is written as, `null` when it is not one of the
15
+ * three.
16
+ *
17
+ * Total over anything a DOCUMENT can carry, not just over the ids this pack
18
+ * declares: defs are runtime configuration and are never persisted, so a board
19
+ * may legitimately hold a value whose pack was removed or renamed. Such a value
20
+ * has no letter, and the watcher leaves the circle alone rather than guessing
21
+ * one.
22
+ */
23
+ export declare function porterLetterOfCompetition(valueId: string | null | undefined): WardleyPorterLetter | null;
24
+ /**
25
+ * The competition value a circle's text states, `null` when it states none.
26
+ *
27
+ * Deliberately forgiving in exactly two ways and strict everywhere else. It
28
+ * trims — the shape editor keeps whatever whitespace the author left — and it
29
+ * upper-cases, because `l` is the same force as `L` and refusing it would make
30
+ * the qualification depend on a shift key. It then demands EXACTLY one of the
31
+ * three letters: `RL` is not a force, `X` is not a force, and neither is an
32
+ * empty circle. Anything else clears the tag rather than picking the nearest
33
+ * value, because a glyph nobody can read must not be reported as a force
34
+ * somebody named.
35
+ */
36
+ export declare function competitionOfPorterLetter(text: string | null | undefined): string | null;
@@ -0,0 +1,50 @@
1
+ import { WARDLEY_COMPETITION } from './natures.js';
2
+ const LETTER_OF_VALUE = {
3
+ [WARDLEY_COMPETITION.relative]: 'R',
4
+ [WARDLEY_COMPETITION.struggle]: 'L',
5
+ [WARDLEY_COMPETITION.establish]: 'E',
6
+ };
7
+ const VALUE_OF_LETTER = {
8
+ R: WARDLEY_COMPETITION.relative,
9
+ L: WARDLEY_COMPETITION.struggle,
10
+ E: WARDLEY_COMPETITION.establish,
11
+ };
12
+ /**
13
+ * What a competition value is written as, `null` when it is not one of the
14
+ * three.
15
+ *
16
+ * Total over anything a DOCUMENT can carry, not just over the ids this pack
17
+ * declares: defs are runtime configuration and are never persisted, so a board
18
+ * may legitimately hold a value whose pack was removed or renamed. Such a value
19
+ * has no letter, and the watcher leaves the circle alone rather than guessing
20
+ * one.
21
+ */
22
+ export function porterLetterOfCompetition(valueId) {
23
+ if (typeof valueId !== 'string')
24
+ return null;
25
+ // A null-prototype lookup would be tidier, but the table is a literal and
26
+ // `Object.hasOwn` is what stops `'toString'` resolving to a letter.
27
+ return Object.hasOwn(LETTER_OF_VALUE, valueId)
28
+ ? LETTER_OF_VALUE[valueId]
29
+ : null;
30
+ }
31
+ /**
32
+ * The competition value a circle's text states, `null` when it states none.
33
+ *
34
+ * Deliberately forgiving in exactly two ways and strict everywhere else. It
35
+ * trims — the shape editor keeps whatever whitespace the author left — and it
36
+ * upper-cases, because `l` is the same force as `L` and refusing it would make
37
+ * the qualification depend on a shift key. It then demands EXACTLY one of the
38
+ * three letters: `RL` is not a force, `X` is not a force, and neither is an
39
+ * empty circle. Anything else clears the tag rather than picking the nearest
40
+ * value, because a glyph nobody can read must not be reported as a force
41
+ * somebody named.
42
+ */
43
+ export function competitionOfPorterLetter(text) {
44
+ if (typeof text !== 'string')
45
+ return null;
46
+ const letter = text.trim().toUpperCase();
47
+ return Object.hasOwn(VALUE_OF_LETTER, letter)
48
+ ? VALUE_OF_LETTER[letter]
49
+ : null;
50
+ }
package/dist/presets.d.ts CHANGED
@@ -39,11 +39,39 @@ export declare const WARDLEY_NODE_SIZE: Record<WardleyArtefactKind, {
39
39
  w: number;
40
40
  h: number;
41
41
  }>;
42
+ /** The two shapes one area kind is drawn as: a plain zone, or one with corners. */
43
+ export type WardleyAreaShape = 'rect' | 'polygon';
44
+ /**
45
+ * The size an area is born at, per shape.
46
+ *
47
+ * Beside {@link WARDLEY_NODE_SIZE} rather than in it, because the table above
48
+ * is keyed by KIND and these two artefacts are one kind: a rectangle 240 × 160
49
+ * covers a band of the map, and the polygon is born square so its default
50
+ * pentagon is regular. `WARDLEY_NODE_SIZE.area` is the rect's, so anything that
51
+ * asks a kind for its canonical box — the morph does — still gets an answer.
52
+ */
53
+ export declare const WARDLEY_AREA_SIZE: Record<WardleyAreaShape, {
54
+ w: number;
55
+ h: number;
56
+ }>;
57
+ /**
58
+ * Every kind that is created NEXT TO a name — {@link WardleyArtefactKind} minus
59
+ * the `porter` and the `area`.
60
+ *
61
+ * Those two are the artefacts with no label ELEMENT at all, and narrowing the
62
+ * key type is how that is said rather than an empty string parked in the table
63
+ * below. An empty string would be a name the author has not typed yet, which is
64
+ * exactly what the placeholders ARE and exactly what the morph is allowed to
65
+ * rewrite; a force's letter is the notation and belongs to nobody's vocabulary,
66
+ * and a zone's name is written INSIDE the zone, so a placeholder there would be
67
+ * a word the author has to delete before typing their own.
68
+ */
69
+ export type WardleyLabelledKind = Exclude<WardleyArtefactKind, 'porter' | 'area'>;
42
70
  /**
43
71
  * The words a kind is created NEXT TO — the prompt an artefact nobody has named
44
72
  * still carries, and the only string a morph is ever allowed to rewrite.
45
73
  */
46
- export declare const WARDLEY_NODE_LABEL: Record<WardleyArtefactKind, string>;
74
+ export declare const WARDLEY_NODE_LABEL: Record<WardleyLabelledKind, string>;
47
75
  /** A box centred on a point — how every Wardley creation site places a node. */
48
76
  export declare function wardleyCenteredBox(cx: number, cy: number, w: number, h: number): string;
49
77
  /** The canonical box of one kind, centred where the artefact already stands. */
@@ -51,21 +79,56 @@ export declare function wardleyCanonicalBox(kind: WardleyArtefactKind, cx: numbe
51
79
  /**
52
80
  * Every prop a Wardley node is created with, for one kind and one box.
53
81
  *
54
- * Five of the six are a native ELLIPSE and differ only in fill and size: the
82
+ * Most of the pack is a native ELLIPSE and differs only in fill and size: the
55
83
  * glyph — the anchor's silhouette, the ecosystem's hatched donut, the method's
56
84
  * inner circle — is drawn by the renderer from `kind`, off this same model, so
57
85
  * stroke and fill stay editable from the shape toolbar exactly like any other
58
- * shape's. The pipeline is the exception and the reason `clearOf` exists on this
59
- * pack: it is a RECT, and it is the only kind that writes `radius`.
86
+ * shape's. Two families are the exception, and they are the reason `clearOf`
87
+ * exists on this pack: the pipeline and the area are RECTS and the only kinds
88
+ * that write `radius`, and the accelerator / decelerator are POLYGONS — the
89
+ * only kinds that write `vertices` and `isClosed` from here.
60
90
  *
61
91
  * No `text`: a Wardley label is a separate free-text element grouped with the
62
- * node (see `addLabel` in `actions.ts`), never words stored on the shape.
92
+ * node (see `addLabel` in `actions.ts`), never words stored on the shape. The
93
+ * porter and the area are the two exceptions, and neither writes it HERE: their
94
+ * inner text is content the author types, added at the creation site.
63
95
  */
64
96
  export declare function wardleyNodeProps(kind: WardleyArtefactKind, box: {
65
97
  xywh: string;
66
98
  }): Record<string, unknown> & {
67
99
  type: string;
68
100
  };
101
+ /**
102
+ * An AREA, as props — one kind, two shapes.
103
+ *
104
+ * The second discriminator this pack needed, and the reason it is a parameter
105
+ * rather than a second `kind`: a rectangular zone and a polygonal one are the
106
+ * same statement about the map ("all of this is one thing"), drawn with a
107
+ * different number of corners. A second kind would have to be kept in step in
108
+ * the role table, the legend, the export and the morph's key union, for a
109
+ * difference the `shapeType` already carries.
110
+ *
111
+ * Everything but the outline comes from {@link wardleyNodeProps}, which is the
112
+ * house rule: the fill, the rim and the role are the area's, wherever it is
113
+ * built from. What is added here is the two shapes and the fact that an area's
114
+ * NAME is its own inner text.
115
+ *
116
+ * ## The text is the name, and the zone never grows to fit it
117
+ *
118
+ * `TextFitMode.Overflow`, exactly as the porter's letter and the inertia bar:
119
+ * an area is a boundary drawn around real components, so a long name must never
120
+ * push that boundary out and swallow a component the author did not mean to
121
+ * include. Top-left, because a zone's name belongs in its corner rather than
122
+ * across the middle of the map it covers — and no `text` at all, so the editor
123
+ * a double-click opens starts on an empty line.
124
+ */
125
+ export declare function wardleyAreaProps(shape: WardleyAreaShape, box: {
126
+ xywh: string;
127
+ }): Record<string, unknown> & {
128
+ type: string;
129
+ };
130
+ /** Where an area of the given shape is born, centred on a point. */
131
+ export declare function wardleyAreaBox(shape: WardleyAreaShape, cx: number, cy: number): string;
69
132
  /**
70
133
  * The pipeline's handle: a node-sized square straddling the body's top edge, and
71
134
  * the only place a connector may land on a pipeline.
@@ -106,6 +169,71 @@ export declare function wardleyMarketLinkProps(sourceId: string, targetId: strin
106
169
  };
107
170
  /** The pairs the triangle wires, given the three dots in creation order. */
108
171
  export declare function wardleyMarketLinkPairs<T>(dots: readonly T[]): [T, T][];
172
+ /** One of the four arrows: its own axis-aligned box, and the outline in it. */
173
+ export interface WardleyPorterArrow {
174
+ /** Serialized `xywh` of the box the polygon is drawn inside. */
175
+ xywh: string;
176
+ /** The outline as normalized [0-1] coordinates of that box, tip first. */
177
+ vertices: number[][];
178
+ }
179
+ /**
180
+ * The four arrows of a Porter's-forces glyph, for a circle of the given radius
181
+ * centred on (cx, cy) — a box and an outline each.
182
+ *
183
+ * The ONE description of the geometry, so the creation site, the map legend and
184
+ * the tests read the same numbers rather than three sets that agreed the day
185
+ * they were written. Each arrow starts `PORTER_ARROW.gap` clear of the rim —
186
+ * the arrows push against the circle, they do not touch it — and runs
187
+ * `PORTER_ARROW.length` further out, tip at the far end.
188
+ *
189
+ * Every distance scales WITH the radius, and that is what makes a legend row the
190
+ * same drawing at another size: a 12-unit glyph carrying four map-sized arrows
191
+ * would not be a small Porter, it would be a circle somebody stabbed.
192
+ */
193
+ export declare function wardleyPorterArrows(cx: number, cy: number, radius?: number): WardleyPorterArrow[];
194
+ /**
195
+ * One of those arrows as props: a filled POLYGON on a `wardleyNode`, and
196
+ * deliberately ROLE-LESS.
197
+ *
198
+ * A polygon and not a connector, which is the first fix the recette of #210
199
+ * asked for: a connector's triangle head is sized off its stroke width, so the
200
+ * heads arrived longer than the arrows and covered the circle, the letter and
201
+ * the double-click target. A polygon's outline is exactly the seven points
202
+ * above.
203
+ *
204
+ * A `wardleyNode` and not a plain `shape`, which is the second: recette v2
205
+ * found that `ShapeElementView` gives EVERY plain shape a double-click that
206
+ * mounts the inner-text editor, and mounting it grew an arrow from 24 units
207
+ * high to 44 — a deformation that survived Escape. An arrow is not a thing you
208
+ * write in. Posting it as a `wardleyNode` moves it onto `WardleyNodeView`,
209
+ * which opens an editor only on the artefact that HAS a letter; the renderer is
210
+ * unaffected, because a Wardley node draws itself through the native shape
211
+ * renderer and that renderer already dispatches on `shapeType`.
212
+ *
213
+ * Role-less, and `kind: 'porter'` with it, for the reason the market's three
214
+ * inner dots already give — they are `kind: 'component'` and carry no role: this
215
+ * is the glyph's own wiring rather than anything the author drew, so a role
216
+ * would make every composite report an overlap with itself (W3), would offer
217
+ * four arrows to the OWM writer, and would let a stray label bind to one.
218
+ * `undefined` writes no key at all, exactly as it does for those dots.
219
+ *
220
+ * Filled rather than stroked, so nothing about them is sized off a line width
221
+ * ever again.
222
+ */
223
+ export declare function wardleyPorterArrowProps(arrow: WardleyPorterArrow): Record<string, unknown> & {
224
+ type: string;
225
+ };
226
+ /**
227
+ * The letter inside the circle, as shape-text props.
228
+ *
229
+ * A plain STRING and not a `Y.Text`: `ShapeElementModel.propsToY` builds the
230
+ * Y type on the way into the document, and this package depends on yjs only in
231
+ * its tests. `TextFitMode.Overflow` is the load-bearing line — the glyph has a
232
+ * canonical size that says "external force" at a glance, so the circle must
233
+ * never grow to fit whatever the author types into it, exactly as the inertia
234
+ * bar never deforms around its own text.
235
+ */
236
+ export declare function wardleyPorterLetterProps(letter: string, fontSize?: number): Record<string, unknown>;
109
237
  /**
110
238
  * What a kind is worth to a node that ALREADY EXISTS — {@link wardleyNodeProps}
111
239
  * minus the three things a morph has no business touching.
@@ -117,6 +245,24 @@ export declare function wardleyMarketLinkPairs<T>(dots: readonly T[]): [T, T][];
117
245
  * circle.
118
246
  */
119
247
  export declare function wardleyMorphProps(kind: WardleyArtefactKind): Record<string, unknown>;
248
+ /**
249
+ * The kinds that can actually MORPH — the one family `morph.ts` declares.
250
+ *
251
+ * Restated here rather than imported, and that is a circular-import dodge with
252
+ * a real reason behind it: `morph.ts` reads this module for the presets, so
253
+ * this module cannot read it back. `morph.unit.spec.ts` asserts the two lists
254
+ * are the same set, so the restatement cannot drift.
255
+ *
256
+ * It exists because {@link wardleyMorphClears} needs a UNION, and a union over
257
+ * the whole pack is the wrong one. Since the accelerator and the decelerator
258
+ * joined it, "every key any kind writes" includes `vertices` and `isClosed` —
259
+ * keys of two POLYGONS that are in no family and that nothing may ever morph
260
+ * into. Left unrestricted, morphing a market back to a component would emit a
261
+ * delete for `vertices` on an ellipse that never had one: harmless today, and a
262
+ * patch that says something untrue about the pack the day a morphable kind
263
+ * gains an outline.
264
+ */
265
+ export declare const WARDLEY_MORPHABLE_KINDS: readonly WardleyArtefactKind[];
120
266
  /**
121
267
  * The fields to DELETE after morphing to `kind` — the keys some other kind
122
268
  * writes and this one does not.