@formicoidea/labre-core 0.34.2 → 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.
Files changed (46) hide show
  1. package/dist/_pkgs/affine-block-surface/extensions/editor-anchored-panel.d.ts +18 -2
  2. package/dist/_pkgs/affine-block-surface/extensions/editor-anchored-panel.js +141 -8
  3. package/dist/_pkgs/affine-block-surface/extensions/map-quality-widget.js +16 -22
  4. package/dist/_pkgs/affine-block-surface/extensions/reading-widget.d.ts +18 -0
  5. package/dist/_pkgs/affine-block-surface/extensions/reading-widget.js +35 -12
  6. package/dist/_pkgs/affine-block-surface/extensions/reading.d.ts +8 -0
  7. package/dist/_pkgs/affine-block-surface/extensions/reading.js +17 -6
  8. package/dist/_pkgs/affine-block-surface/extensions/tags-toolbar.js +17 -2
  9. package/dist/_pkgs/affine-block-surface/extensions/validation-toolbar.js +9 -2
  10. package/dist/_pkgs/affine-block-surface/framework-background/def.d.ts +14 -11
  11. package/dist/_pkgs/affine-block-surface/framework-background/renderer.js +14 -7
  12. package/dist/_pkgs/affine-components/highlight-dropdown-menu/highlight-duotone-icon.js +8 -8
  13. package/dist/_pkgs/affine-components/highlight-dropdown-menu/text-duotone-icon.js +11 -11
  14. package/dist/_pkgs/affine-components/icons/text.js +426 -426
  15. package/dist/_pkgs/affine-components/toolbar/menu-button.js +40 -0
  16. package/dist/_pkgs/affine-gfx-connector/direction/direction-reveal.d.ts +2 -2
  17. package/dist/_pkgs/affine-gfx-connector/direction/direction-reveal.js +1 -1
  18. package/dist/_pkgs/affine-gfx-connector/direction/direction-widget.d.ts +1 -1
  19. package/dist/_pkgs/affine-gfx-connector/direction/direction-widget.js +10 -1
  20. package/dist/_pkgs/affine-gfx-shape/element-view.d.ts +42 -1
  21. package/dist/_pkgs/affine-gfx-shape/element-view.js +54 -0
  22. package/dist/_pkgs/affine-gfx-shape/toolbar/index.d.ts +1 -0
  23. package/dist/_pkgs/affine-gfx-shape/toolbar/index.js +1 -0
  24. package/dist/_pkgs/affine-gfx-shape/toolbar/palette-color-action.d.ts +25 -0
  25. package/dist/_pkgs/affine-gfx-shape/toolbar/palette-color-action.js +123 -0
  26. package/dist/_pkgs/affine-model/elements/c4/board.d.ts +49 -0
  27. package/dist/_pkgs/affine-model/elements/c4/board.js +68 -0
  28. package/dist/_pkgs/affine-model/elements/wardley/node.d.ts +21 -6
  29. package/dist/_pkgs/affine-model/elements/wardley/node.js +20 -5
  30. package/dist/_pkgs/affine-shared/services/telemetry-service/block-lifecycle-watcher.d.ts +16 -1
  31. package/dist/_pkgs/affine-shared/services/telemetry-service/block-lifecycle-watcher.js +27 -2
  32. package/dist/_pkgs/affine-widget-edgeless-toolbar/menu/slide-menu.js +1 -1
  33. package/dist/_pkgs/affine-widget-keyboard-toolbar/icons.js +98 -98
  34. package/dist/_pkgs/data-view/core/group-by/renderer/boolean-group.js +11 -11
  35. package/dist/_pkgs/data-view/core/utils/menu-title.js +17 -17
  36. package/dist/_pkgs/data-view/view-presets/table/pc-virtual/group/top/group-title.js +18 -18
  37. package/dist/_pkgs/std/extension/command-registry.d.ts +15 -8
  38. package/dist/_pkgs/std/gfx/index.d.ts +1 -1
  39. package/dist/_pkgs/std/gfx/index.js +1 -1
  40. package/dist/_pkgs/std/gfx/interactivity/gfx-view-event-handler.d.ts +57 -0
  41. package/dist/_pkgs/std/gfx/interactivity/gfx-view-event-handler.js +80 -25
  42. package/dist/_pkgs/std/gfx/model/surface/pivot.d.ts +55 -1
  43. package/dist/_pkgs/std/gfx/model/surface/pivot.js +87 -0
  44. package/dist/_pkgs/std/gfx/model/surface/role.d.ts +12 -1
  45. package/dist/extensions/command-telemetry.js +4 -0
  46. package/package.json +1 -1
@@ -89,7 +89,19 @@ export declare abstract class EditorAnchoredPanel extends WidgetComponent<RootBl
89
89
  protected anchorBox(): AnchoredPanelBox;
90
90
  /**
91
91
  * The panel's frame: the box, the dialog semantics and the pointer
92
- * swallowing. The subclass passes its own body and nothing else.
92
+ * swallowing. The subclass passes its own two halves and nothing else.
93
+ *
94
+ * ## Why the header is a parameter and not a line of the body
95
+ *
96
+ * Because the split is the whole fix. Both panels rendered their title as the
97
+ * first node of a body that was itself the scrolling box, so a reading long
98
+ * enough to scroll scrolled its own title away. Making the header an argument
99
+ * is what forces a consumer to say which of its markup stays put — a default
100
+ * would have quietly kept the old behaviour for whoever forgot.
101
+ *
102
+ * The header is where a panel puts its title and, if it has one, the actions
103
+ * that belong beside it: Map quality's close button rides there and stays
104
+ * reachable however far the checklist runs.
93
105
  *
94
106
  * Deliberately NOT `aria-modal`: it promises everything behind the dialog is
95
107
  * inert, and these panels promise the opposite — the canvas stays usable
@@ -105,7 +117,11 @@ export declare abstract class EditorAnchoredPanel extends WidgetComponent<RootBl
105
117
  variant: string;
106
118
  /** The element the panel is about, when it is about one. */
107
119
  elementId?: string;
108
- }, body: unknown): TemplateResult;
120
+ },
121
+ /** Pinned: the title, and whatever must stay beside it. */
122
+ header: unknown,
123
+ /** Scrolled: everything the panel has to say. */
124
+ body: unknown): TemplateResult;
109
125
  /**
110
126
  * Click-away, Escape, and the two things that move the bar under the panel.
111
127
  *
@@ -31,8 +31,9 @@ import { styleMap } from 'lit/directives/style-map.js';
31
31
  * ## What is shared, and what is not
32
32
  *
33
33
  * Shared: the host geometry and its layer, the panel's box and chrome, the
34
- * click-away and Escape contracts, and the re-measure wiring. Not shared: a
35
- * single line of CONTENT — each panel renders its own body into
34
+ * pinned header over a scrolling body, the click-away and Escape contracts, and
35
+ * the re-measure wiring. Not shared: a single line of CONTENT — each panel
36
+ * passes its own header and body to
36
37
  * {@link EditorAnchoredPanel.renderAnchoredPanel}, and neither knows the other
37
38
  * exists.
38
39
  */
@@ -91,6 +92,32 @@ const PANEL_MIN_HEIGHT = 160;
91
92
  * wants.
92
93
  */
93
94
  export const EDITOR_ANCHORED_PANEL_Z_INDEX = 'calc(var(--affine-z-index-popover, 1000) + 10)';
95
+ /**
96
+ * The corner radius of the senior button bar, which this panel wears too.
97
+ *
98
+ * `edgeless-toolbar` rounds at 16 and so, since #199, does every menu that
99
+ * slides out of it. The panel is the same family of chrome floating over the
100
+ * same bar; at 8 it read as a foreign box parked above the toolbar.
101
+ */
102
+ const PANEL_RADIUS = 16;
103
+ /**
104
+ * The inset between the panel's border and the words inside it.
105
+ *
106
+ * It has to clear the corner arc, not merely look comfortable: an inner
107
+ * rectangle inset by less than `r × (1 − 1/√2)` ≈ 4.7px has its own corners cut
108
+ * by a 16px radius. Every inset below is at or above that, which is why the
109
+ * scrolling box never bleeds into the rounded edge.
110
+ */
111
+ const PANEL_INLINE_PADDING = 16;
112
+ /**
113
+ * The width of the scrollbar the body reserves, and the width the thumb takes.
114
+ *
115
+ * Reserved permanently (`scrollbar-gutter: stable`) rather than appearing with
116
+ * the overflow: the panel's content is recomputed on every board change, so a
117
+ * gutter that came and went would shift the text sideways under the reader
118
+ * while they are reading it.
119
+ */
120
+ const PANEL_SCROLLBAR_WIDTH = 8;
94
121
  /**
95
122
  * The host geometry, the layer and the panel's chrome — the half of the pattern
96
123
  * that is CSS.
@@ -123,12 +150,27 @@ export const editorAnchoredPanelStyles = css `
123
150
  pointer-events: none;
124
151
  }
125
152
 
153
+ /*
154
+ The FRAME, and nothing that scrolls.
155
+
156
+ \`overflow: hidden\` rather than \`overflow-y: auto\`: the panel owns the
157
+ rounded corners, so it is the box that must clip — and a box that clips is
158
+ the only one whose children can be trusted not to paint over the arc. The
159
+ scrolling moved one level in, to \`.editor-anchored-panel-body\`, which is
160
+ what pins the header.
161
+
162
+ A column, because \`max-height\` has to be shared: the header takes what it
163
+ needs and the body takes the rest. The measured maximum
164
+ ({@link EditorAnchoredPanel.anchorBox}) therefore still means what it says —
165
+ the whole panel, header included, never exceeds it.
166
+ */
126
167
  .editor-anchored-panel {
127
168
  position: absolute;
128
169
  box-sizing: border-box;
129
- overflow-y: auto;
130
- padding: 12px;
131
- border-radius: 8px;
170
+ display: flex;
171
+ flex-direction: column;
172
+ overflow: hidden;
173
+ border-radius: ${unsafeCSS(PANEL_RADIUS)}px;
132
174
  border: 1px solid var(--affine-border-color);
133
175
  background: var(--affine-background-overlay-panel-color, #fff);
134
176
  box-shadow: var(--affine-shadow-2);
@@ -138,6 +180,73 @@ export const editorAnchoredPanelStyles = css `
138
180
  line-height: 1.4;
139
181
  pointer-events: auto;
140
182
  }
183
+
184
+ /*
185
+ The header stays. It is the sentence that says what the panel is about, and
186
+ a title that scrolls out of its own panel leaves the reader looking at a
187
+ list of values with nothing naming them.
188
+
189
+ \`flex: 0 0 auto\` so a long title wraps rather than being squeezed, and the
190
+ bottom border so the body visibly passes UNDER it instead of appearing to
191
+ end mid-air.
192
+ */
193
+ .editor-anchored-panel-header {
194
+ flex: 0 0 auto;
195
+ padding: 12px ${unsafeCSS(PANEL_INLINE_PADDING)}px 8px;
196
+ border-bottom: 1px solid var(--affine-border-color);
197
+ }
198
+
199
+ /*
200
+ The one box that scrolls.
201
+
202
+ \`min-height: 0\` is belt-and-braces, not load-bearing: a flex child's
203
+ automatic minimum size is its content, but \`overflow-y: auto\` already
204
+ resets it to zero (CSS flexbox § 4.5) — the suite stays green with the
205
+ line removed (lead's mutation probe). Spelled out anyway so the shrink
206
+ survives the day someone moves the overflow off this box.
207
+
208
+ The right inset is split — \`padding-right\` plus the reserved gutter — so
209
+ the text sits the same distance from both edges while the scrollbar keeps a
210
+ lane of its own. The bottom inset is a MARGIN rather than padding: padding
211
+ is inside the scrolling box, so the scrollbar would run down to it and into
212
+ the bottom arc; a margin ends the scrolling box early and leaves the corner
213
+ to the frame.
214
+ */
215
+ .editor-anchored-panel-body {
216
+ flex: 1 1 auto;
217
+ min-height: 0;
218
+ overflow-y: auto;
219
+ scrollbar-gutter: stable;
220
+ padding: 12px ${unsafeCSS(PANEL_INLINE_PADDING - PANEL_SCROLLBAR_WIDTH)}px 0
221
+ ${unsafeCSS(PANEL_INLINE_PADDING)}px;
222
+ margin-bottom: 12px;
223
+ }
224
+
225
+ /*
226
+ The house scrollbar: thin, invisible at rest, and only drawn while the
227
+ pointer is over the box it belongs to. Same values as \`table-block-css\`
228
+ and the data-view scrollers, so the editor has one scrollbar and not three.
229
+ */
230
+ .editor-anchored-panel-body::-webkit-scrollbar {
231
+ width: ${unsafeCSS(PANEL_SCROLLBAR_WIDTH)}px;
232
+ }
233
+
234
+ .editor-anchored-panel-body::-webkit-scrollbar-thumb {
235
+ border-radius: ${unsafeCSS(PANEL_SCROLLBAR_WIDTH / 2)}px;
236
+ background-color: transparent;
237
+ }
238
+
239
+ .editor-anchored-panel-body::-webkit-scrollbar-track {
240
+ background-color: transparent;
241
+ }
242
+
243
+ .editor-anchored-panel-body:hover::-webkit-scrollbar-thumb {
244
+ background-color: var(--affine-black-30);
245
+ }
246
+
247
+ .editor-anchored-panel-body:hover::-webkit-scrollbar-track {
248
+ background-color: var(--affine-hover-color);
249
+ }
141
250
  `;
142
251
  /**
143
252
  * The base every editor-anchored info panel extends.
@@ -233,7 +342,19 @@ export class EditorAnchoredPanel extends WidgetComponent {
233
342
  }
234
343
  /**
235
344
  * The panel's frame: the box, the dialog semantics and the pointer
236
- * swallowing. The subclass passes its own body and nothing else.
345
+ * swallowing. The subclass passes its own two halves and nothing else.
346
+ *
347
+ * ## Why the header is a parameter and not a line of the body
348
+ *
349
+ * Because the split is the whole fix. Both panels rendered their title as the
350
+ * first node of a body that was itself the scrolling box, so a reading long
351
+ * enough to scroll scrolled its own title away. Making the header an argument
352
+ * is what forces a consumer to say which of its markup stays put — a default
353
+ * would have quietly kept the old behaviour for whoever forgot.
354
+ *
355
+ * The header is where a panel puts its title and, if it has one, the actions
356
+ * that belong beside it: Map quality's close button rides there and stays
357
+ * reachable however far the checklist runs.
237
358
  *
238
359
  * Deliberately NOT `aria-modal`: it promises everything behind the dialog is
239
360
  * inert, and these panels promise the opposite — the canvas stays usable
@@ -242,7 +363,11 @@ export class EditorAnchoredPanel extends WidgetComponent {
242
363
  * screen reader, and trapping Tab from a host that is still interactive would
243
364
  * be the library taking something that is not its to take.
244
365
  */
245
- renderAnchoredPanel(options, body) {
366
+ renderAnchoredPanel(options,
367
+ /** Pinned: the title, and whatever must stay beside it. */
368
+ header,
369
+ /** Scrolled: everything the panel has to say. */
370
+ body) {
246
371
  const { left, width, bottom, maxHeight } = this.anchorBox();
247
372
  return html `<div
248
373
  class="editor-anchored-panel ${options.variant}"
@@ -261,7 +386,15 @@ export class EditorAnchoredPanel extends WidgetComponent {
261
386
  @pointerup=${this.swallow}
262
387
  @click=${this.swallow}
263
388
  >
264
- ${body}
389
+ <div
390
+ class="editor-anchored-panel-header"
391
+ data-testid="anchored-panel-header"
392
+ >
393
+ ${header}
394
+ </div>
395
+ <div class="editor-anchored-panel-body" data-testid="anchored-panel-body">
396
+ ${body}
397
+ </div>
265
398
  </div>`;
266
399
  }
267
400
  /**
@@ -149,14 +149,9 @@ let MapQualityWidget = (() => {
149
149
  static { this.styles = [
150
150
  editorAnchoredPanelStyles,
151
151
  css `
152
- /* The box, the layer and the anchoring come from the shared pattern; this
153
- is the panel's own stacking of sections inside it. */
154
- .map-quality-panel {
155
- display: flex;
156
- flex-direction: column;
157
- gap: 12px;
158
- }
159
-
152
+ /* The box, the layer, the anchoring AND the column of header over body
153
+ all come from the shared pattern now — what is left here is the two
154
+ rows this panel puts inside those two slots. */
160
155
  .map-quality-head {
161
156
  display: flex;
162
157
  align-items: center;
@@ -449,20 +444,19 @@ let MapQualityWidget = (() => {
449
444
  elementId: element.id,
450
445
  label: title,
451
446
  }, html `<div class="map-quality-head">
452
- <span>${title}</span>
453
- <button
454
- class="map-quality-close"
455
- type="button"
456
- data-testid="map-quality-close"
457
- aria-label=${translateKey(this.std, 'com.labre.validation.map-quality.close', 'Close')}
458
- @pointerdown=${this.swallow}
459
- @pointerup=${this.swallow}
460
- @click=${this._close}
461
- >
462
- ×
463
- </button>
464
- </div>
465
- ${this._renderNudges(element)}`);
447
+ <span>${title}</span>
448
+ <button
449
+ class="map-quality-close"
450
+ type="button"
451
+ data-testid="map-quality-close"
452
+ aria-label=${translateKey(this.std, 'com.labre.validation.map-quality.close', 'Close')}
453
+ @pointerdown=${this.swallow}
454
+ @pointerup=${this.swallow}
455
+ @click=${this._close}
456
+ >
457
+ ×
458
+ </button>
459
+ </div>`, this._renderNudges(element));
466
460
  }
467
461
  };
468
462
  })();
@@ -121,6 +121,13 @@ export declare class ReadingProposalWidget extends EditorAnchoredPanel {
121
121
  * cannot choose a document, so with no picker there is nothing to offer and
122
122
  * the affordance is HIDDEN rather than disabled — the `QuickSearchProvider`
123
123
  * precedent, and the same rule `queryPivotProperties` follows.
124
+ *
125
+ * "Linked" is the TOLERANT question (`resolvePivotBinding`): the reading is
126
+ * resolved on the element carrying the role, but a host is free to have
127
+ * stamped the composite a plain click selects, and a component that is bound
128
+ * must never be reported as unlinked. Offering the link is the strict
129
+ * question by construction — the button only shows when nothing in the chain
130
+ * is bound, and it still stamps the role-carrying element.
124
131
  */
125
132
  private _renderRecord;
126
133
  /** The drift line: informative, never blocking, with one way to settle it. */
@@ -138,6 +145,17 @@ export declare class ReadingProposalWidget extends EditorAnchoredPanel {
138
145
  * board, and the seam has no field for it: the drift line reports it so the
139
146
  * user can settle it on whichever side is wrong, and the library does not
140
147
  * invent a transport for a fact the host never agreed to receive.
148
+ *
149
+ * The patch is built on the element that CARRIES the binding, not on the one
150
+ * the reading was resolved for. They are the same element in every ordinary
151
+ * case; they differ only when the composite is what a host bound, and there
152
+ * the resolved element is the only honest choice. `elementId` is half of the
153
+ * occurrence key (`pivotDocId` + `elementId`) that the materiality publisher
154
+ * tracks and RETRACTS on: publishing under the role-carrying element's id,
155
+ * against a record it does not itself point at, would create an occurrence
156
+ * no watcher ever tracked and therefore none can ever retract — host data
157
+ * left attributed to an occurrence that does not exist, which is exactly the
158
+ * leak ADR 0006 § 4.3 forbids.
141
159
  */
142
160
  private readonly _updateRecord;
143
161
  render(): TemplateResult | typeof nothing;
@@ -34,7 +34,7 @@ var __runInitializers = (this && this.__runInitializers) || function (thisArg, i
34
34
  };
35
35
  import { buildOccurrencePatch, getUniverseRegistry, publishOccurrenceMaterialities, translateKey, } from '../../affine-shared/services/index.js';
36
36
  import { getRegisteredCommands, runCommand, WidgetViewExtension, } from '../../std/index.js';
37
- import { isPivotBound } from '../../std/gfx/index.js';
37
+ import { resolvePivotBinding } from '../../std/gfx/index.js';
38
38
  import { effect } from '@preact/signals-core';
39
39
  import { css, html, nothing } from 'lit';
40
40
  import { state } from 'lit/decorators.js';
@@ -157,13 +157,27 @@ let ReadingProposalWidget = (() => {
157
157
  * board, and the seam has no field for it: the drift line reports it so the
158
158
  * user can settle it on whichever side is wrong, and the library does not
159
159
  * invent a transport for a fact the host never agreed to receive.
160
+ *
161
+ * The patch is built on the element that CARRIES the binding, not on the one
162
+ * the reading was resolved for. They are the same element in every ordinary
163
+ * case; they differ only when the composite is what a host bound, and there
164
+ * the resolved element is the only honest choice. `elementId` is half of the
165
+ * occurrence key (`pivotDocId` + `elementId`) that the materiality publisher
166
+ * tracks and RETRACTS on: publishing under the role-carrying element's id,
167
+ * against a record it does not itself point at, would create an occurrence
168
+ * no watcher ever tracked and therefore none can ever retract — host data
169
+ * left attributed to an occurrence that does not exist, which is exactly the
170
+ * leak ADR 0006 § 4.3 forbids.
160
171
  */
161
172
  this._updateRecord = (elementId, pivotDocId) => (event) => {
162
173
  event.stopPropagation();
163
174
  const element = this.gfx.surface?.getElementById(elementId);
164
175
  if (!element)
165
176
  return;
166
- publishOccurrenceMaterialities(this.std, buildOccurrencePatch(element, pivotDocId));
177
+ const bound = resolvePivotBinding(element);
178
+ if (!bound || bound.pivotDocId !== pivotDocId)
179
+ return;
180
+ publishOccurrenceMaterialities(this.std, buildOccurrencePatch(bound, pivotDocId));
167
181
  // The disagreement has been answered; the next local change re-checks it
168
182
  // from scratch, so nothing here has to remember it.
169
183
  const manager = this._manager;
@@ -180,11 +194,14 @@ let ReadingProposalWidget = (() => {
180
194
  static { this.styles = [
181
195
  editorAnchoredPanelStyles,
182
196
  css `
197
+ /* Pinned by the shared frame, which owns its padding; all this adds is
198
+ the weight that makes it read as a title. */
183
199
  .reading-title {
184
200
  font-weight: 600;
185
- margin-bottom: 8px;
186
201
  }
187
202
 
203
+ /* The first field sits directly under the pinned header, so the rule that
204
+ separates two fields would draw a second line against the header's. */
188
205
  .reading-field + .reading-field {
189
206
  margin-top: 10px;
190
207
  padding-top: 10px;
@@ -484,13 +501,20 @@ let ReadingProposalWidget = (() => {
484
501
  * cannot choose a document, so with no picker there is nothing to offer and
485
502
  * the affordance is HIDDEN rather than disabled — the `QuickSearchProvider`
486
503
  * precedent, and the same rule `queryPivotProperties` follows.
504
+ *
505
+ * "Linked" is the TOLERANT question (`resolvePivotBinding`): the reading is
506
+ * resolved on the element carrying the role, but a host is free to have
507
+ * stamped the composite a plain click selects, and a component that is bound
508
+ * must never be reported as unlinked. Offering the link is the strict
509
+ * question by construction — the button only shows when nothing in the chain
510
+ * is bound, and it still stamps the role-carrying element.
487
511
  */
488
512
  _renderRecord(reading, writable) {
489
513
  const surface = this.gfx.surface;
490
514
  const element = surface?.getElementById(reading.elementId);
491
515
  if (!element)
492
516
  return nothing;
493
- const bound = isPivotBound(element);
517
+ const bound = resolvePivotBinding(element) !== undefined;
494
518
  const picker = this.std.getOptional(PivotRecordPickerProvider);
495
519
  return this._field('reading-record', translateKey(this.std, 'com.labre.reading.field.record', 'Record'), html `<div class="reading-value">
496
520
  ${bound
@@ -573,14 +597,13 @@ let ReadingProposalWidget = (() => {
573
597
  elementId,
574
598
  label: translateKey(this.std, 'com.labre.reading.panel.label', 'Proposed record'),
575
599
  }, html `<div class="reading-title">
576
- ${translateKey(this.std, 'com.labre.reading.panel.title', 'What this map says about this component')}
577
- </div>
578
- ${this._renderNodeType(reading, profile)}
579
- ${this._renderNature(reading, profile, record, writable)}
580
- ${this._renderRelations(reading)} ${this._renderValueFlow(reading)}
581
- ${this._renderPhase(reading)} ${this._renderNaming(reading)}
582
- ${this._renderRecord(reading, writable)}
583
- ${writable ? this._renderDrift(reading) : nothing}`);
600
+ ${translateKey(this.std, 'com.labre.reading.panel.title', 'What this map says about this component')}
601
+ </div>`, html `${this._renderNodeType(reading, profile)}
602
+ ${this._renderNature(reading, profile, record, writable)}
603
+ ${this._renderRelations(reading)} ${this._renderValueFlow(reading)}
604
+ ${this._renderPhase(reading)} ${this._renderNaming(reading)}
605
+ ${this._renderRecord(reading, writable)}
606
+ ${writable ? this._renderDrift(reading) : nothing}`);
584
607
  }
585
608
  };
586
609
  })();
@@ -364,6 +364,14 @@ export interface ReadingDrift {
364
364
  * fields, a dangling record, a still-loading one — and the caller shows the
365
365
  * local reading alone. That is the `queryPivotProperties` contract: "this
366
366
  * editor has no host data layer" is not an error to put in front of a user.
367
+ *
368
+ * The binding is resolved through {@link resolvePivotBinding} rather than read
369
+ * off `element` directly: a business framework draws its artefacts as
370
+ * composites, and a host that stamped the GROUP a click selects — rather than
371
+ * the circle that carries the role — is still describing this component. The
372
+ * whole record side of the reading therefore starts from the RESOLVED binding,
373
+ * drift included, so a panel and a drift line can never disagree about which
374
+ * record they are talking about.
367
375
  */
368
376
  export declare function readRecord(std: BlockStdScope, element: GfxPrimitiveElementModel, profile: ReadingProfile): RecordReading | undefined;
369
377
  /**
@@ -1,7 +1,7 @@
1
1
  import { getUniverseRegistry, hostLanguage, queryPivotProperties, } from '../../affine-shared/services/index.js';
2
2
  import { createIdentifier } from '../../global/di/index.js';
3
3
  import { LifeCycleWatcher, } from '../../std/index.js';
4
- import { GfxControllerIdentifier, GfxPrimitiveElementModel, isPivotBound, readElementTags, roleIsA, } from '../../std/gfx/index.js';
4
+ import { GfxControllerIdentifier, GfxPrimitiveElementModel, readElementTags, resolvePivotBinding, roleIsA, } from '../../std/gfx/index.js';
5
5
  import { effect, signal } from '@preact/signals-core';
6
6
  // Straight at the two leaf modules rather than at the barrel, for the reason
7
7
  // `validation.ts` states: `index.js` also re-exports the RENDERER, and a
@@ -458,14 +458,23 @@ function propertyStrings(snapshot, key) {
458
458
  * fields, a dangling record, a still-loading one — and the caller shows the
459
459
  * local reading alone. That is the `queryPivotProperties` contract: "this
460
460
  * editor has no host data layer" is not an error to put in front of a user.
461
+ *
462
+ * The binding is resolved through {@link resolvePivotBinding} rather than read
463
+ * off `element` directly: a business framework draws its artefacts as
464
+ * composites, and a host that stamped the GROUP a click selects — rather than
465
+ * the circle that carries the role — is still describing this component. The
466
+ * whole record side of the reading therefore starts from the RESOLVED binding,
467
+ * drift included, so a panel and a drift line can never disagree about which
468
+ * record they are talking about.
461
469
  */
462
470
  export function readRecord(std, element, profile) {
463
- if (!isPivotBound(element))
471
+ const bound = resolvePivotBinding(element);
472
+ if (!bound)
464
473
  return undefined;
465
474
  const keys = profile.recordKeys;
466
475
  if (!keys?.nature && !keys?.phase)
467
476
  return undefined;
468
- const state = queryPivotProperties(std, element.pivotDocId);
477
+ const state = queryPivotProperties(std, bound.pivotDocId);
469
478
  if (!state)
470
479
  return undefined;
471
480
  const current = state.value;
@@ -482,7 +491,7 @@ export function readRecord(std, element, profile) {
482
491
  ? resolveRecordNature(getUniverseRegistry(std).tag(tagId), raw)
483
492
  : { resolved: [], unknown: [] };
484
493
  return {
485
- pivotDocId: element.pivotDocId,
494
+ pivotDocId: bound.pivotDocId,
486
495
  ...(resolved.length ? { nature: resolved } : {}),
487
496
  ...(unknown.length ? { unknownNature: unknown } : {}),
488
497
  ...(phase.length ? { phase: phase[0] } : {}),
@@ -732,8 +741,10 @@ export class ReadingManager extends LifeCycleWatcher {
732
741
  if (!surface || !element)
733
742
  return;
734
743
  // Only a LINKED element can drift: without a record there is nothing to
735
- // disagree with, and the reading is just a reading.
736
- if (!isPivotBound(element)) {
744
+ // disagree with, and the reading is just a reading. "Linked" is the same
745
+ // tolerant question the panel asks — a binding carried by the composite is
746
+ // this component's binding (`resolvePivotBinding`).
747
+ if (!resolvePivotBinding(element)) {
737
748
  if (this.drift$.value?.elementId === elementId)
738
749
  this.drift$.value = null;
739
750
  return;
@@ -48,7 +48,14 @@ const TAG_SET_ID = 'tag.set';
48
48
  * ids already written on an element go unread until the flag comes back, which
49
49
  * costs nothing, because they were never needed to load or paint anything.
50
50
  */
51
- /** The tick on a selected value, drawn inline like the neighbouring modules'. */
51
+ /**
52
+ * The tick on a selected value, drawn inline like the neighbouring modules'.
53
+ *
54
+ * Inline because this package does not depend on `@blocksuite/icons`, and to
55
+ * the RIGHT of the label, rendered only when the value is on: `data-option` on
56
+ * the row gives it the house geometry and the primary colour (`menu-button.ts`),
57
+ * the same one the Regular/Semibold menu uses.
58
+ */
52
59
  const CheckIcon = html `<svg
53
60
  width="20"
54
61
  height="20"
@@ -187,7 +194,15 @@ function renderSection(ctx, target, def) {
187
194
  const options = offeredValues(def, selected).map(value => {
188
195
  const on = selected.includes(value.id);
189
196
  const valueLabel = value.label || value.id;
197
+ // `data-toggle` beside `data-option`: the row draws like a native option,
198
+ // but clicking the value in FORCE is how it is un-picked (`toggleValue`),
199
+ // whatever the tag's cardinality. Without it the selected row inherits
200
+ // `editor-menu-action`'s inert `pointer-events: none` — the tick lands
201
+ // under the pointer, the click reaches the section instead, and a
202
+ // qualification can be set but never cleared with a real mouse.
190
203
  return html `<editor-menu-action
204
+ data-option
205
+ data-toggle
191
206
  data-testid="element-tag-option"
192
207
  data-tag-id=${def.id}
193
208
  data-value-id=${value.id}
@@ -196,8 +211,8 @@ function renderSection(ctx, target, def) {
196
211
  aria-pressed=${on}
197
212
  @click=${() => toggleValue(ctx, target, def, value.id)}
198
213
  >
199
- ${on ? CheckIcon : html `<span style="width: 20px;"></span>`}
200
214
  <span class="label">${valueLabel}</span>
215
+ ${on ? CheckIcon : nothing}
201
216
  </editor-menu-action>`;
202
217
  });
203
218
  return html `<div
@@ -9,6 +9,9 @@ import { ValidationManager } from './validation.js';
9
9
  * `@blocksuite/icons`, which this package does not depend on and which is not
10
10
  * worth a new dependency for one glyph — the same call the framework toolbars
11
11
  * already make for their own icons.
12
+ *
13
+ * It sits at the END of the row and only when the profile is in force:
14
+ * `data-option` gives the row the native geometry and the primary colour.
12
15
  */
13
16
  const CheckIcon = html `<svg
14
17
  width="20"
@@ -136,6 +139,10 @@ function renderSection(label, testid, items) {
136
139
  function renderMapQualitySection(ctx, element) {
137
140
  const label = translateKey(ctx.std, 'com.labre.validation.map-quality.open', 'Map quality…');
138
141
  return renderSection(translateKey(ctx.std, 'com.labre.validation.map-quality.section', 'Map quality'), 'validation-map-quality-section', [
142
+ // No `data-option`, and no spacer: this row OPENS something, it is not
143
+ // one of N choices. It held a 20 px gutter only to line its label up with
144
+ // the profile rows above, whose tick used to sit on the left; the tick
145
+ // has moved right, so the gutter is gone from both.
139
146
  html `<editor-menu-action
140
147
  data-testid="validation-map-quality-open"
141
148
  aria-label=${label}
@@ -143,7 +150,6 @@ function renderMapQualitySection(ctx, element) {
143
150
  ctx.std.getOptional(ValidationManager)?.openMapQuality(element);
144
151
  }}
145
152
  >
146
- <span style="width: 20px;"></span>
147
153
  <span class="label">${label}</span>
148
154
  </editor-menu-action>`,
149
155
  ]);
@@ -159,6 +165,7 @@ function renderSections(ctx, target, quality) {
159
165
  const options = profiles.map(profile => {
160
166
  const selected = profile.id === active?.id;
161
167
  return html `<editor-menu-action
168
+ data-option
162
169
  data-testid="validation-profile-option"
163
170
  data-profile-id=${profile.id}
164
171
  data-selected=${selected ? 'true' : nothing}
@@ -166,10 +173,10 @@ function renderSections(ctx, target, quality) {
166
173
  aria-pressed=${selected}
167
174
  @click=${() => pickProfile(ctx, element, profile)}
168
175
  >
169
- ${selected ? CheckIcon : html `<span style="width: 20px;"></span>`}
170
176
  <span class="label"
171
177
  >${translateKey(ctx.std, profile.labelKey, profile.fallback)}</span
172
178
  >
179
+ ${selected ? CheckIcon : nothing}
173
180
  </editor-menu-action>`;
174
181
  });
175
182
  sections.push(renderSection(translateKey(ctx.std, 'com.labre.validation.profile.section', 'Profile'), 'validation-profile-section', options));
@@ -382,12 +382,13 @@ export interface BackgroundSurfaceDef {
382
382
  /**
383
383
  * A filled strip of FIXED width, painted over one margin of the card.
384
384
  *
385
- * The width of a band IS the margin it covers
386
- * ({@link BackgroundGeometry.margin}), never a second number: the margin is
385
+ * The thickness of a band IS the margin it covers
386
+ * ({@link BackgroundGeometry.margin}) — `margin.left` for a `'left'` band,
387
+ * `margin.top` for a `'top'` one — never a second number: the margin is
387
388
  * already the room the plot gives up to the furniture, and letting a
388
- * declaration state a different width would be two numbers that must agree and
389
- * one day will not. An element narrower than its own margin clamps the band to
390
- * the element, which is the only case where the two can disagree.
389
+ * declaration state a different thickness would be two numbers that must agree
390
+ * and one day will not. An element smaller than its own margin clamps the band
391
+ * to the element, which is the only case where the two can disagree.
391
392
  *
392
393
  * A band is part of the CARD, not a layer over it: it is painted over the
393
394
  * card's fill and under its border, so the frame keeps outlining the whole
@@ -395,16 +396,18 @@ export interface BackgroundSurfaceDef {
395
396
  * them, and anchored in plot ratios — the plot edge is the band's inner edge,
396
397
  * so `x: 0` with a negative `dx` walks back INTO the band.
397
398
  *
398
- * Named consumer: the BPMN pool, a lane whose participant name is written up a
399
+ * Named consumers: the BPMN pool, a lane whose participant name is written up a
399
400
  * filled band along its left edge, with a {@link divider} between that band and
400
- * the flow area.
401
+ * the flow area; and the C4 board, whose diagram title is written across a
402
+ * header strip along its top edge.
401
403
  *
402
- * Only `'left'` for now — same philosophy as the wash direction
403
- * ({@link BackgroundWashDef.stops}): the union grows the day a framework asks
404
- * for a top or a right band, not in anticipation of one.
404
+ * `'left'` and `'top'` — the two a framework has actually asked for. The union
405
+ * grows the day one asks for a right or a bottom band, not in anticipation of
406
+ * one, which is the same philosophy the wash direction follows
407
+ * ({@link BackgroundWashDef.stops}).
405
408
  */
406
409
  export interface BackgroundSideBandDef {
407
- side: 'left';
410
+ side: 'left' | 'top';
408
411
  /** Tint over the whole strip. Absent means the card shows through. */
409
412
  fill?: BackgroundColor;
410
413
  /** The line between the band and the plot. Absent means none. */