@formicoidea/labre-core 0.36.0 → 0.38.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.
Files changed (45) hide show
  1. package/dist/_pkgs/affine-block-frame/frame-manager.d.ts +31 -0
  2. package/dist/_pkgs/affine-block-frame/frame-manager.js +62 -2
  3. package/dist/_pkgs/affine-block-frame/frame-tool.js +6 -2
  4. package/dist/_pkgs/affine-block-surface/extensions/audit.js +12 -34
  5. package/dist/_pkgs/affine-block-surface/extensions/reading-widget.d.ts +25 -13
  6. package/dist/_pkgs/affine-block-surface/extensions/reading-widget.js +58 -24
  7. package/dist/_pkgs/affine-block-surface/extensions/reading.d.ts +83 -7
  8. package/dist/_pkgs/affine-block-surface/extensions/reading.js +51 -42
  9. package/dist/_pkgs/affine-block-surface/extensions/validation.d.ts +321 -16
  10. package/dist/_pkgs/affine-block-surface/extensions/validation.js +945 -62
  11. package/dist/_pkgs/affine-block-surface/framework-background/facts.d.ts +24 -0
  12. package/dist/_pkgs/affine-block-surface/framework-background/facts.js +31 -0
  13. package/dist/_pkgs/affine-block-surface/framework-background/index.d.ts +1 -0
  14. package/dist/_pkgs/affine-block-surface/framework-background/index.js +1 -0
  15. package/dist/_pkgs/affine-block-surface/framework-background/stacking.d.ts +131 -0
  16. package/dist/_pkgs/affine-block-surface/framework-background/stacking.js +184 -0
  17. package/dist/_pkgs/affine-block-surface/utils/sort.d.ts +15 -1
  18. package/dist/_pkgs/affine-block-surface/utils/sort.js +30 -31
  19. package/dist/_pkgs/affine-block-surface/view.js +5 -0
  20. package/dist/_pkgs/affine-gfx-group/element-renderer/utils.js +3 -1
  21. package/dist/_pkgs/affine-gfx-template/index.d.ts +1 -0
  22. package/dist/_pkgs/affine-gfx-template/index.js +1 -0
  23. package/dist/_pkgs/affine-gfx-template/make-snapshot.d.ts +16 -2
  24. package/dist/_pkgs/affine-gfx-template/make-snapshot.js +43 -4
  25. package/dist/_pkgs/affine-gfx-template/services/template-middlewares.js +1 -0
  26. package/dist/_pkgs/affine-gfx-template/services/template.d.ts +6 -0
  27. package/dist/_pkgs/affine-gfx-template/services/template.js +6 -0
  28. package/dist/_pkgs/affine-gfx-template/snapshot-from-action.d.ts +20 -0
  29. package/dist/_pkgs/affine-gfx-template/snapshot-from-action.js +205 -0
  30. package/dist/_pkgs/affine-gfx-template/toolbar/template-panel.js +4 -0
  31. package/dist/_pkgs/affine-gfx-template/toolbar/template-type.d.ts +13 -0
  32. package/dist/_pkgs/affine-inline-link/link-node/configs/toolbar.d.ts +11 -17
  33. package/dist/_pkgs/affine-inline-link/link-node/configs/toolbar.js +55 -14
  34. package/dist/_pkgs/affine-model/elements/cynefin/cynefin.d.ts +9 -20
  35. package/dist/_pkgs/affine-model/elements/cynefin/cynefin.js +7 -28
  36. package/dist/_pkgs/affine-model/elements/edgy/board.d.ts +9 -21
  37. package/dist/_pkgs/affine-model/elements/edgy/board.js +8 -29
  38. package/dist/_pkgs/affine-model/elements/edgy/facets.d.ts +14 -28
  39. package/dist/_pkgs/affine-model/elements/edgy/facets.js +12 -36
  40. package/dist/_pkgs/affine-model/elements/estuarine/estuarine.d.ts +9 -19
  41. package/dist/_pkgs/affine-model/elements/estuarine/estuarine.js +7 -27
  42. package/dist/_pkgs/affine-shared/services/link-preview-service/link-preview-service.d.ts +28 -0
  43. package/dist/_pkgs/affine-shared/services/link-preview-service/link-preview-service.js +20 -3
  44. package/dist/translations.js +8 -2
  45. package/package.json +1 -1
@@ -35,6 +35,37 @@ export declare class EdgelessFrameManager extends GfxExtension {
35
35
  }>(a: T, b: T): 0 | 1 | -1;
36
36
  private _addChildrenToLegacyFrame;
37
37
  private _addFrameBlock;
38
+ /**
39
+ * The index a frame gets when it is placed at `bound`.
40
+ *
41
+ * A frame's index is deliberately at the BACK of the stack, so it renders
42
+ * behind everything it owns. On a bare canvas that is right. On a framework
43
+ * board it is not: a Wardley map — a C4 board, a BPMN pool, any
44
+ * `FrameworkBackgroundElementModel` — is an OPAQUE canvas element, so a
45
+ * frame sent behind the whole surface goes behind the paint and only the
46
+ * part overhanging the board stays visible. The back of the SURFACE and the
47
+ * back of the BOARD are two different depths.
48
+ *
49
+ * So the frame lands just above the topmost background it covers, which is
50
+ * still behind every artefact drawn on that board — the depth a Wardley zone
51
+ * already needed, for the same reason, in #213. See
52
+ * {@link indexOverBackgrounds}.
53
+ */
54
+ frameIndexAt(bound: Bound): string;
55
+ /**
56
+ * A frame DROPPED onto a framework background is raised above it, exactly as
57
+ * a frame drawn there is ({@link frameIndexAt}) — same gesture, arriving a
58
+ * moment later.
59
+ *
60
+ * Only ever upwards, and only while the frame is actually BELOW a background
61
+ * it overlaps. So a frame already above one is left alone (a drag writes an
62
+ * index at most once, and only on the move that buries it), and undo is safe
63
+ * for the same reason: the restored position is the one the frame came from,
64
+ * where nothing overlapping was above it. A frame the author sent to the
65
+ * back on purpose is only raised again if they move it — on a board, moving
66
+ * a frame means wanting to see it.
67
+ */
68
+ private _watchFrameMoved;
38
69
  private _watchElementAdded;
39
70
  /**
40
71
  * Whether `element` fully encloses `frame`.
@@ -1,4 +1,4 @@
1
- import { Overlay } from '../affine-block-surface/index.js';
1
+ import { indexOverBackgrounds, Overlay, stackedElementsOf, } from '../affine-block-surface/index.js';
2
2
  import { FrameworkBackgroundElementModel } from '../affine-model/index.js';
3
3
  import { EditPropsStore } from '../affine-shared/services/index.js';
4
4
  import { DisposableGroup } from '../global/disposable/index.js';
@@ -100,6 +100,7 @@ export class EdgelessFrameManager extends GfxExtension {
100
100
  super(gfx);
101
101
  this._disposable = new DisposableGroup();
102
102
  this._watchElementAdded();
103
+ this._watchFrameMoved();
103
104
  }
104
105
  static framePresentationComparator(a, b) {
105
106
  function stringCompare(a, b) {
@@ -145,7 +146,7 @@ export class EdgelessFrameManager extends GfxExtension {
145
146
  .applyLastProps('affine:frame', {
146
147
  title: new Text(new Y.Text(`Frame ${this.frames.length + 1}`)),
147
148
  xywh: bound.serialize(),
148
- index: this.gfx.layer.generateIndex(true),
149
+ index: this.frameIndexAt(bound),
149
150
  presentationIndex: this.generatePresentationIndex(),
150
151
  });
151
152
  const id = this.gfx.doc.addBlock('affine:frame', props, surfaceModel);
@@ -155,6 +156,65 @@ export class EdgelessFrameManager extends GfxExtension {
155
156
  }
156
157
  return frameModel;
157
158
  }
159
+ /**
160
+ * The index a frame gets when it is placed at `bound`.
161
+ *
162
+ * A frame's index is deliberately at the BACK of the stack, so it renders
163
+ * behind everything it owns. On a bare canvas that is right. On a framework
164
+ * board it is not: a Wardley map — a C4 board, a BPMN pool, any
165
+ * `FrameworkBackgroundElementModel` — is an OPAQUE canvas element, so a
166
+ * frame sent behind the whole surface goes behind the paint and only the
167
+ * part overhanging the board stays visible. The back of the SURFACE and the
168
+ * back of the BOARD are two different depths.
169
+ *
170
+ * So the frame lands just above the topmost background it covers, which is
171
+ * still behind every artefact drawn on that board — the depth a Wardley zone
172
+ * already needed, for the same reason, in #213. See
173
+ * {@link indexOverBackgrounds}.
174
+ */
175
+ frameIndexAt(bound) {
176
+ return (indexOverBackgrounds(stackedElementsOf(this.gfx), bound) ??
177
+ this.gfx.layer.generateIndex(true));
178
+ }
179
+ /**
180
+ * A frame DROPPED onto a framework background is raised above it, exactly as
181
+ * a frame drawn there is ({@link frameIndexAt}) — same gesture, arriving a
182
+ * moment later.
183
+ *
184
+ * Only ever upwards, and only while the frame is actually BELOW a background
185
+ * it overlaps. So a frame already above one is left alone (a drag writes an
186
+ * index at most once, and only on the move that buries it), and undo is safe
187
+ * for the same reason: the restored position is the one the frame came from,
188
+ * where nothing overlapping was above it. A frame the author sent to the
189
+ * back on purpose is only raised again if they move it — on a board, moving
190
+ * a frame means wanting to see it.
191
+ */
192
+ _watchFrameMoved() {
193
+ this._disposable.add(this.gfx.doc.slots.blockUpdated.subscribe(payload => {
194
+ if (payload.type !== 'update' ||
195
+ !payload.isLocal ||
196
+ payload.flavour !== 'affine:frame' ||
197
+ payload.props.key !== 'xywh') {
198
+ return;
199
+ }
200
+ const frame = this.gfx.getElementById(payload.id);
201
+ // A nested frame keeps its parent's ordering: `compare` reads the
202
+ // ancestor's index, so raising the child would change nothing.
203
+ if (!frame || !isFrameBlock(frame) || frame.group !== null)
204
+ return;
205
+ const siblings = stackedElementsOf(this.gfx, frame);
206
+ const box = frame.elementBound;
207
+ const buried = siblings.some(element => element.isBackground &&
208
+ element.index > frame.index &&
209
+ Bound.deserialize(element.xywh).isOverlapWithBound(box));
210
+ if (!buried)
211
+ return;
212
+ const over = indexOverBackgrounds(siblings, box);
213
+ if (over !== null) {
214
+ this.gfx.updateElement(frame, { index: over });
215
+ }
216
+ }));
217
+ }
158
218
  _watchElementAdded() {
159
219
  if (!this.gfx.surface) {
160
220
  return;
@@ -44,12 +44,16 @@ export class FrameTool extends BaseTool {
44
44
  return;
45
45
  if (!this._frame) {
46
46
  const frames = this.gfx.layer.blocks.filter(block => block.flavour === 'affine:frame');
47
+ const bound = Bound.fromPoints([this._startPoint, currentPoint]);
47
48
  const props = this.std
48
49
  .get(EditPropsStore)
49
50
  .applyLastProps('affine:frame', {
50
51
  title: new Text(new Y.Text(`Frame ${frames.length + 1}`)),
51
- xywh: Bound.fromPoints([this._startPoint, currentPoint]).serialize(),
52
- index: this.gfx.layer.generateIndex(true),
52
+ xywh: bound.serialize(),
53
+ // Not the raw back of the stack: a frame drawn on a framework
54
+ // background must sit just above it, or the author draws blind
55
+ // behind an opaque map. See `EdgelessFrameManager.frameIndexAt`.
56
+ index: this.frameManager.frameIndexAt(bound),
53
57
  presentationIndex: this.frameManager.generatePresentationIndex(),
54
58
  });
55
59
  const id = this.doc.addBlock('affine:frame', props, this.gfx.surface);
@@ -31,7 +31,7 @@ import { getAuditCriteria, requestAudit, TelemetryProvider, } from '../../affine
31
31
  import { GfxControllerIdentifier, GfxPrimitiveElementModel, roleIsA, } from '../../std/gfx/index.js';
32
32
  import { z } from 'zod';
33
33
  import { backgroundInstanceZones, backgroundPlot, } from '../framework-background/def.js';
34
- import { backgroundAxisFacts } from '../framework-background/facts.js';
34
+ import { backgroundAxisFacts, containingFrame, } from '../framework-background/facts.js';
35
35
  import { ValidationManager, ValidationRuleIdentifier, } from './validation.js';
36
36
  /**
37
37
  * `map.audit` takes no required parameter: the assistant audits what is
@@ -105,6 +105,7 @@ function collectFrames(rules, elements) {
105
105
  const profileId = el.validationProfile;
106
106
  frames.push({
107
107
  def,
108
+ id: el.id,
108
109
  bound: el.elementBound,
109
110
  fact: {
110
111
  elementId: el.id,
@@ -135,41 +136,12 @@ function collectFrames(rules, elements) {
135
136
  }
136
137
  return frames;
137
138
  }
138
- /** Squared edge-to-edge gap — the same attribution heuristic the engine uses. */
139
- function gapSquared(frame, bound) {
140
- const dx = Math.max(frame.x - bound.maxX, bound.x - frame.maxX, 0);
141
- const dy = Math.max(frame.y - bound.maxY, bound.y - frame.maxY, 0);
142
- return dx * dx + dy * dy;
143
- }
144
- /**
145
- * The frame an element belongs to: the one that CONTAINS it, failing that the
146
- * nearest by edge-to-edge gap, ties broken by the smaller id.
147
- *
148
- * Deliberately the same answer `attributeBackground` gives inside the engine —
149
- * an audit that attributed an element to a different map than the rule that
150
- * indicted it would produce two findings about one component that disagree
151
- * about which map it is on.
152
- */
153
- function attribute(bound, frames) {
154
- let nearest = null;
155
- let nearestDistance = Infinity;
156
- for (const frame of frames) {
157
- if (frame.bound.contains(bound))
158
- return frame;
159
- const distance = gapSquared(frame.bound, bound);
160
- if (distance < nearestDistance ||
161
- (distance === nearestDistance &&
162
- nearest !== null &&
163
- frame.fact.elementId < nearest.fact.elementId)) {
164
- nearestDistance = distance;
165
- nearest = frame;
166
- }
167
- }
168
- return nearest;
169
- }
170
139
  /**
171
140
  * Where an element's centre sits inside a frame's PLOT, as ratios.
172
141
  *
142
+ * A POSITION, not a membership test: it is asked only about an element some
143
+ * frame already contains, and answers where in that frame's plot it lies.
144
+ *
173
145
  * Ratios of the plot and not of the element box: a Wardley transition drawn at
174
146
  * `0.4` is 40 % of the PLOT, not of the map element, and the margin between the
175
147
  * two is exactly where the axis titles live. `backgroundPlot` is the one place
@@ -262,7 +234,13 @@ export function collectAuditFacts(std) {
262
234
  if (role === undefined || frameIds.has(el.id))
263
235
  continue;
264
236
  const bound = el.elementBound;
265
- const frame = attribute(bound, frames);
237
+ // Membership is WHOLE containment (PF2.4), and there is no nearest-frame
238
+ // fallback: an element straddling the edge of a map is not on it, so the
239
+ // audit reports it exactly as it reports one on bare canvas — id and role,
240
+ // no frame, no position, no zone. Anything else would tell an assistant a
241
+ // component is on a map the engine is simultaneously indicting it for
242
+ // hanging off.
243
+ const frame = containingFrame(bound, frames, candidate => candidate.bound);
266
244
  if (frame === null) {
267
245
  elementFacts.push({ id: el.id, role });
268
246
  continue;
@@ -1,19 +1,6 @@
1
1
  import { nothing, type TemplateResult } from 'lit';
2
2
  import { EditorAnchoredPanel } from './editor-anchored-panel.js';
3
3
  export declare const READING_PROPOSAL_WIDGET = "affine-reading-proposal-widget";
4
- /**
5
- * How the panel names each side of a parent-child relation, keyed under
6
- * `com.labre.reading.relations.`. Chrome, and only chrome.
7
- *
8
- * Exported because it is DATA: the translation-key manifest walks it rather
9
- * than restating the two wordings, which a source scan could not check — the
10
- * key is built as a template literal and the fallback reaches `translateKey`
11
- * through a local helper.
12
- */
13
- export declare const RELATION_SIDE_FALLBACK: {
14
- readonly consumers: "Consumers (above)";
15
- readonly suppliers: "Suppliers (below)";
16
- };
17
4
  /**
18
5
  * **The proposal panel** — what the tool reads of one component, offered for
19
6
  * confirmation (MF3).
@@ -94,6 +81,14 @@ export declare class ReadingProposalWidget extends EditorAnchoredPanel {
94
81
  * host's word into the document.
95
82
  */
96
83
  private _renderNature;
84
+ /**
85
+ * The typed edges touching the subject, grouped by side and named with the
86
+ * FRAMEWORK's own two words (`ReadingRelationDef.sides`).
87
+ *
88
+ * The `data-testid` keeps the shape it shipped with — `reading-consumers`,
89
+ * `reading-suppliers` — because it names the SIDE, which is generic, and not
90
+ * the wording, which is now the framework's.
91
+ */
97
92
  private _renderRelations;
98
93
  /**
99
94
  * **Value flow** — the same typed edges as the relations above, said the way
@@ -110,8 +105,25 @@ export declare class ReadingProposalWidget extends EditorAnchoredPanel {
110
105
  * "Value flows up from X to Y"; a host that translates
111
106
  * `com.labre.reading.value-flow` gets the same two slots in its own order via
112
107
  * the `.to` suffix key.
108
+ *
109
+ * ## Why it is gated on the framework's `geometry`
110
+ *
111
+ * "Up" is a claim about the board, not about the edge: it is only true where
112
+ * the framework said the vertical axis carries the order
113
+ * (`ReadingRelationDef.geometry`). Wardley says it, and this section is the
114
+ * value chain read from the bottom. Nobody else does, and "value flows up
115
+ * from Receive order to Check stock" would be a sentence BPMN never said.
113
116
  */
114
117
  private _renderValueFlow;
118
+ /**
119
+ * Where the subject sits along the frame's phase axis.
120
+ *
121
+ * Absent ENTIRELY — not "no phase to read" — for a framework that declares no
122
+ * frame. The empty state below is about a component that could have been on a
123
+ * map and is not; telling the author of a BPMN task that it is "not on a
124
+ * framework background" would be answering a question their notation never
125
+ * asks.
126
+ */
115
127
  private _renderPhase;
116
128
  private _renderNaming;
117
129
  /**
@@ -46,18 +46,16 @@ export const READING_PROPOSAL_WIDGET = 'affine-reading-proposal-widget';
46
46
  const TAG_SET_ID = 'tag.set';
47
47
  const PIVOT_BIND_ID = 'pivot.bind';
48
48
  /**
49
- * How the panel names each side of a parent-child relation, keyed under
50
- * `com.labre.reading.relations.`. Chrome, and only chrome.
49
+ * The two sides of a relation, in the order the panel lists them: what needs
50
+ * the subject first, then what the subject needs.
51
51
  *
52
- * Exported because it is DATA: the translation-key manifest walks it rather
53
- * than restating the two wordings, which a source scan could not check — the
54
- * key is built as a template literal and the fallback reaches `translateKey`
55
- * through a local helper.
52
+ * The WORDINGS are no longer here. They used to be a chrome table
53
+ * (`Consumers (above)` / `Suppliers (below)`), which was Wardley's sentence
54
+ * written into the engine — a BPMN sequence flow has no "above". Each framework
55
+ * now names its own two sides on its {@link ReadingProfile}, Wardley included,
56
+ * and restates those very keys and wordings so nothing on a Wardley map moved.
56
57
  */
57
- export const RELATION_SIDE_FALLBACK = {
58
- consumers: 'Consumers (above)',
59
- suppliers: 'Suppliers (below)',
60
- };
58
+ const RELATION_SIDES = ['consumer', 'supplier'];
61
59
  /**
62
60
  * **The proposal panel** — what the tool reads of one component, offered for
63
61
  * confirmation (MF3).
@@ -411,24 +409,38 @@ let ReadingProposalWidget = (() => {
411
409
  </div>`
412
410
  : nothing}`);
413
411
  }
414
- _renderRelations(reading) {
415
- const consumers = reading.relations.filter(r => r.side === 'consumer');
416
- const suppliers = reading.relations.filter(r => r.side === 'supplier');
417
- const line = (relations, key) => relations.length
418
- ? html `<div class="reading-value" data-testid=${`reading-${key}`}>
419
- ${translateKey(this.std, `com.labre.reading.relations.${key}`, RELATION_SIDE_FALLBACK[key])}:
420
- ${relations
412
+ /**
413
+ * The typed edges touching the subject, grouped by side and named with the
414
+ * FRAMEWORK's own two words (`ReadingRelationDef.sides`).
415
+ *
416
+ * The `data-testid` keeps the shape it shipped with — `reading-consumers`,
417
+ * `reading-suppliers` — because it names the SIDE, which is generic, and not
418
+ * the wording, which is now the framework's.
419
+ */
420
+ _renderRelations(reading, profile) {
421
+ const sides = profile.relation?.sides;
422
+ const line = (side) => {
423
+ const relations = reading.relations.filter(r => r.side === side);
424
+ const wording = sides?.[side];
425
+ // No wording means the framework declared no relation at all, in which
426
+ // case there is nothing to list either. Silence rather than a heading
427
+ // this library would have had to invent.
428
+ if (!relations.length || !wording)
429
+ return nothing;
430
+ return html `<div class="reading-value" data-testid=${`reading-${side}s`}>
431
+ ${translateKey(this.std, wording.labelKey, wording.labelFallback)}:
432
+ ${relations
421
433
  .map(relation => relation.otherName || relation.otherId)
422
434
  .join(', ')}
423
- </div>`
424
- : nothing;
435
+ </div>`;
436
+ };
425
437
  const contradictions = reading.relations.filter(r => r.contradictsGeometry);
426
438
  return this._field('reading-relations', translateKey(this.std, 'com.labre.reading.field.relations', 'Parent-child relations'), html `${reading.relations.length === 0
427
439
  ? html `<div class="reading-empty">
428
440
  ${translateKey(this.std, 'com.labre.reading.relations.none', 'No typed link touches this component.')}
429
441
  </div>`
430
442
  : nothing}
431
- ${line(consumers, 'consumers')} ${line(suppliers, 'suppliers')}
443
+ ${RELATION_SIDES.map(line)}
432
444
  ${contradictions.length
433
445
  ? html `<div class="reading-note" data-testid="reading-contradiction">
434
446
  ${translateKey(this.std, 'com.labre.reading.relations.contradiction', 'A link states the opposite of what the positions show')}:
@@ -453,8 +465,18 @@ let ReadingProposalWidget = (() => {
453
465
  * "Value flows up from X to Y"; a host that translates
454
466
  * `com.labre.reading.value-flow` gets the same two slots in its own order via
455
467
  * the `.to` suffix key.
468
+ *
469
+ * ## Why it is gated on the framework's `geometry`
470
+ *
471
+ * "Up" is a claim about the board, not about the edge: it is only true where
472
+ * the framework said the vertical axis carries the order
473
+ * (`ReadingRelationDef.geometry`). Wardley says it, and this section is the
474
+ * value chain read from the bottom. Nobody else does, and "value flows up
475
+ * from Receive order to Check stock" would be a sentence BPMN never said.
456
476
  */
457
- _renderValueFlow(reading) {
477
+ _renderValueFlow(reading, profile) {
478
+ if (profile.relation?.geometry !== 'vertical')
479
+ return nothing;
458
480
  const flows = readValueFlows(reading);
459
481
  if (flows.length === 0)
460
482
  return nothing;
@@ -468,7 +490,18 @@ let ReadingProposalWidget = (() => {
468
490
  ${from} ${flow.from} ${to} ${flow.to}
469
491
  </div>`)}`);
470
492
  }
471
- _renderPhase(reading) {
493
+ /**
494
+ * Where the subject sits along the frame's phase axis.
495
+ *
496
+ * Absent ENTIRELY — not "no phase to read" — for a framework that declares no
497
+ * frame. The empty state below is about a component that could have been on a
498
+ * map and is not; telling the author of a BPMN task that it is "not on a
499
+ * framework background" would be answering a question their notation never
500
+ * asks.
501
+ */
502
+ _renderPhase(reading, profile) {
503
+ if (!profile.frame)
504
+ return nothing;
472
505
  const { phase } = reading;
473
506
  return this._field('reading-phase', translateKey(this.std, 'com.labre.reading.field.phase', 'Evolution phase'), phase
474
507
  ? html `<div class="reading-value" data-zone-id=${phase.zoneId}>
@@ -600,8 +633,9 @@ let ReadingProposalWidget = (() => {
600
633
  ${translateKey(this.std, 'com.labre.reading.panel.title', 'What this map says about this component')}
601
634
  </div>`, html `${this._renderNodeType(reading, profile)}
602
635
  ${this._renderNature(reading, profile, record, writable)}
603
- ${this._renderRelations(reading)} ${this._renderValueFlow(reading)}
604
- ${this._renderPhase(reading)} ${this._renderNaming(reading)}
636
+ ${this._renderRelations(reading, profile)}
637
+ ${this._renderValueFlow(reading, profile)}
638
+ ${this._renderPhase(reading, profile)} ${this._renderNaming(reading)}
605
639
  ${this._renderRecord(reading, writable)}
606
640
  ${writable ? this._renderDrift(reading) : nothing}`);
607
641
  }
@@ -80,6 +80,53 @@ export interface ReadingNamingConvention {
80
80
  /** English default, for a host that ships no catalogue. */
81
81
  hintFallback: string;
82
82
  }
83
+ /**
84
+ * How a framework names ONE end of its typed relation, in the panel.
85
+ *
86
+ * Key plus the framework's own wording, exactly like a role's label or a rule's
87
+ * message: the host localises it, and a host with no catalogue still reads a
88
+ * real phrase.
89
+ */
90
+ export interface ReadingRelationSideLabel {
91
+ labelKey: string;
92
+ /** The framework's own English wording, for a host that ships no catalogue. */
93
+ labelFallback: string;
94
+ }
95
+ /**
96
+ * The typed edge a reading walks, and the two words the framework reads it
97
+ * with.
98
+ *
99
+ * The wordings live HERE rather than in the panel because they are the
100
+ * framework's own sentence and nobody else's: "Consumers (above)" is a Wardley
101
+ * value chain, "Followed by" is a BPMN sequence, "Upstream" is a context map.
102
+ * The panel used to hard-code the first pair, which is exactly what made the
103
+ * reading a Wardley feature wearing a generic coat.
104
+ */
105
+ export interface ReadingRelationDef {
106
+ edgeRole: RoleId;
107
+ /**
108
+ * One wording per side, keyed by what the OTHER end is to the subject — the
109
+ * same convention {@link ReadingRelation.side} carries. `supplier` labels the
110
+ * edges LEAVING the subject (the subject is the `source`, i.e. the subject of
111
+ * the role's verb), `consumer` the edges arriving at it.
112
+ */
113
+ sides: Record<ReadingRelationSide, ReadingRelationSideLabel>;
114
+ /**
115
+ * The framework claims its board has a VERTICAL reading — that the axis the
116
+ * elements are drawn along IS the statement of order, so a link and the two
117
+ * positions can contradict each other.
118
+ *
119
+ * Opt-in, and only Wardley opts in: on a value chain a consumer is drawn
120
+ * above what it needs (ADR 0010 § 5), so an edge pointing the other way is a
121
+ * disagreement worth reporting. On a BPMN pool, a context map or a storming
122
+ * roll, "above" means nothing — two tasks side by side are not in conflict —
123
+ * and a contradiction note there would be a complaint about a drawing that is
124
+ * perfectly correct. Absent, {@link ReadingRelation.contradictsGeometry} is
125
+ * always `false`, and the panel also drops the VALUE FLOW section: "value
126
+ * flows up from X to Y" is the same vertical claim said the other way round.
127
+ */
128
+ geometry?: 'vertical';
129
+ }
83
130
  /**
84
131
  * One framework's reading contract. Every section is optional: a framework that
85
132
  * declares only `appliesTo` gets the node-type line and nothing else, which is
@@ -109,9 +156,7 @@ export interface ReadingProfile {
109
156
  conventions: readonly ReadingNamingConvention[];
110
157
  };
111
158
  /** The typed edge whose two ends the relation reading walks. */
112
- relation?: {
113
- edgeRole: RoleId;
114
- };
159
+ relation?: ReadingRelationDef;
115
160
  /** The frame the phase is read from. */
116
161
  frame?: {
117
162
  backgroundRole: RoleId;
@@ -159,6 +204,10 @@ export interface ReadingRelation {
159
204
  * says one thing and the two positions say the other, the reading says so
160
205
  * rather than picking a winner: that is W4 seen from the record's side, and
161
206
  * the user is the one who decides which of the two was the mistake.
207
+ *
208
+ * Always `false` unless the framework declared
209
+ * {@link ReadingRelationDef.geometry} — a board whose axis carries no order
210
+ * has nothing to contradict.
162
211
  */
163
212
  contradictsGeometry: boolean;
164
213
  }
@@ -279,6 +328,15 @@ export interface ReadingValueFlow {
279
328
  export declare function readValueFlows(reading: ElementReading): ReadingValueFlow[];
280
329
  /** The profile governing an element, or `null` — the first match wins. */
281
330
  export declare function readingProfileFor(element: GfxPrimitiveElementModel, profiles: readonly ReadingProfile[]): ReadingProfile | null;
331
+ /**
332
+ * How the engine asks the HOST for a wording — `translateKey`, curried with the
333
+ * `std` its caller owns.
334
+ *
335
+ * A function rather than a scope, so every comparison below stays a pure
336
+ * function of its arguments and one caller ({@link ReadingManager}) owns the
337
+ * single `std` read.
338
+ */
339
+ export type ReadingTranslate = (key: string, fallback: string) => string;
282
340
  /**
283
341
  * What the LINKED RECORD says about the two fields a reading can compare
284
342
  * itself against. Bounded by construction: the profile names at most two host
@@ -341,6 +399,16 @@ export interface ResolvedRecordNature {
341
399
  * its words would be inventing it;
342
400
  * - **`values: 'open'`** — the def says any string is a value of this tag, so
343
401
  * everything resolves. That is the def's own claim, not ours.
402
+ *
403
+ * ## Why there is no `translate` here, unlike {@link compareReading}
404
+ *
405
+ * There is nothing to translate. `TagValueDef.label` is documented as "already
406
+ * localized by the host" (ADR 0007): a pack is SEEDED with the wording of the
407
+ * deployment that seeded it, so the label this resolver already matches against
408
+ * IS the localized one. Adding a resolver would be a parameter that could never
409
+ * change an answer. The zone labels are the opposite case — they are declared
410
+ * in the library, in English, with an i18n key beside them — which is why the
411
+ * phase comparison takes one.
344
412
  */
345
413
  export declare function resolveRecordNature(def: TagDef | undefined, values: readonly string[]): ResolvedRecordNature;
346
414
  /** One field on which the drawing and the record disagree. */
@@ -389,11 +457,19 @@ export declare function readRecord(std: BlockStdScope, element: GfxPrimitiveElem
389
457
  * side. `readRecord` has already resolved what CAN be compared; what could not
390
458
  * is not a difference of opinion, it is a difference of alphabet.
391
459
  *
392
- * The phase comparison accepts either the zone id or the zone's own wording,
393
- * because a host is free to store "Product" where the declaration says
394
- * `product`, and neither spelling is more correct than the other.
460
+ * The phase comparison accepts the zone id, the zone's own English wording, and
461
+ * — when a `translate` is handed in — the wording the HOST's catalogue gives
462
+ * that zone. A French deployment stores "Produit" in its record and reads
463
+ * `product` off the board; without the third spelling the panel would report a
464
+ * permanent, false disagreement on a component nobody had moved. Same shape as
465
+ * the value-id resolution above: a difference of alphabet is not a difference
466
+ * of opinion.
467
+ *
468
+ * `translate` is a PARAMETER rather than a `std` read, so this stays a pure
469
+ * function of its arguments; {@link ReadingManager} owns the single `std` the
470
+ * closure is built from.
395
471
  */
396
- export declare function compareReading(reading: ElementReading, record: RecordReading): ReadingDriftField[];
472
+ export declare function compareReading(reading: ElementReading, record: RecordReading, translate?: ReadingTranslate): ReadingDriftField[];
397
473
  /** A framework registers its reading profile here; nothing else registers one. */
398
474
  export declare const ReadingProfileIdentifier: import("../../global/di/types.js").ServiceIdentifier<ReadingProfile> & (<U extends ReadingProfile = ReadingProfile>(variant: import("../../global/di/types.js").ServiceVariant) => import("../../global/di/types.js").ServiceIdentifier<U>);
399
475
  /**