@formicoidea/labre-core 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.
@@ -194,8 +194,15 @@ function renderSection(ctx, target, def) {
194
194
  const options = offeredValues(def, selected).map(value => {
195
195
  const on = selected.includes(value.id);
196
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.
197
203
  return html `<editor-menu-action
198
204
  data-option
205
+ data-toggle
199
206
  data-testid="element-tag-option"
200
207
  data-tag-id=${def.id}
201
208
  data-value-id=${value.id}
@@ -247,6 +247,19 @@ export class EditorMenuAction extends LitElement {
247
247
  pointer-events: none;
248
248
  }
249
249
 
250
+ /*
251
+ * ...unless the row is a TOGGLE rather than one of N. The rule above reads
252
+ * a selected row as inert, which is right for a single choice — re-picking
253
+ * the option already in force is a no-op, so swallowing the click costs
254
+ * nothing. A toggle is the opposite: re-clicking the value in force is the
255
+ * ONLY way to un-pick it (the qualification dropdown's tag values), and an
256
+ * inert row leaves the user with a tag that cannot be removed. Such a row
257
+ * opts the pointer back in through data-toggle.
258
+ */
259
+ :host([data-selected][data-toggle]) {
260
+ pointer-events: auto;
261
+ }
262
+
250
263
  /*
251
264
  * An OPTION row: one of N choices, the one in force carrying
252
265
  * data-selected. Opt-in through data-option, because a plain command row is
@@ -1,7 +1,24 @@
1
1
  import { ShapeElementModel } from '../affine-model/index.js';
2
2
  import { GfxElementModelView } from '../std/gfx/index.js';
3
3
  import { PolygonVertexEditingOverlay } from './overlay/polygon-vertex-editing-overlay.js';
4
- export declare class ShapeElementView extends GfxElementModelView<ShapeElementModel> {
4
+ /**
5
+ * The view every shape gets, and the base a framework NODE view extends when
6
+ * its model is a {@link ShapeElementModel} subclass.
7
+ *
8
+ * Generic over the model, with the plain shape as the default, so
9
+ * `WardleyNodeView extends ShapeElementView<WardleyNodeElementModel>` reads its
10
+ * own `kind` off `this.model` without a cast. Every existing call site names no
11
+ * type argument and is unchanged.
12
+ *
13
+ * A framework node that extends this inherits the polygon VERTEX EDITOR, which
14
+ * is the whole reason to: {@link enterVertexEditingMode} is what the toolbar's
15
+ * "Edit vertices" action calls, and the action checks `view instanceof
16
+ * ShapeElementView` — a node view outside this hierarchy makes that button
17
+ * silently do nothing. What such a subclass usually does NOT want is the other
18
+ * thing wired here, the double-click-to-edit; {@link editsTextOnDblClick} is
19
+ * the one line it has to say so.
20
+ */
21
+ export declare class ShapeElementView<T extends ShapeElementModel = ShapeElementModel> extends GfxElementModelView<T> {
5
22
  static type: string;
6
23
  /** Overlay for polygon vertex editing (only active for polygon shapes). */
7
24
  private _vertexEditingOverlay;
@@ -16,6 +33,17 @@ export declare class ShapeElementView extends GfxElementModelView<ShapeElementMo
16
33
  * visually correct on every drag frame.
17
34
  */
18
35
  private _syncConnectorPaths;
36
+ /**
37
+ * Whether a double-click on THIS element opens its own inner-text editor.
38
+ *
39
+ * True for every plain shape, which is what this editor has always done: a
40
+ * shape is a box you write in. It is a HOOK rather than a constant because a
41
+ * framework node is often not one — its name may be a text element grouped
42
+ * beside it, or the element may be a glyph's own wiring that must carry no
43
+ * words at all — and a subclass that overrode `onCreated` to dodge this one
44
+ * wiring would lose the vertex editor set up beside it.
45
+ */
46
+ protected editsTextOnDblClick(): boolean;
19
47
  private _initDblClickToEdit;
20
48
  /**
21
49
  * Whether a vertex is currently being dragged.
@@ -61,6 +89,19 @@ export declare class ShapeElementView extends GfxElementModelView<ShapeElementMo
61
89
  * The overlay is shown when a polygon is selected and removed when
62
90
  * deselected or when a non-polygon shape is selected.
63
91
  */
92
+ /**
93
+ * Whether THIS element's corners may be moved by hand.
94
+ *
95
+ * True for every plain polygon. The companion of {@link editsTextOnDblClick},
96
+ * and a hook for the same reason: a framework polygon's outline is often the
97
+ * NOTATION rather than a drawing — a Wardley accelerator points right and a
98
+ * decelerator points left, and that direction is the whole of what separates
99
+ * them — so hovering one must not offer a handle that can only turn a
100
+ * statement into a blob. Declining here skips the selection overlay, the
101
+ * pointer wiring and {@link enterVertexEditingMode} together, which is the
102
+ * point: a half-wired overlay is worse than none.
103
+ */
104
+ protected editsVerticesOnSelection(): boolean;
64
105
  private _initPolygonVertexEditing;
65
106
  private _ensureVertexEditingOverlay;
66
107
  private _removeVertexEditingOverlay;
@@ -6,6 +6,23 @@ import { GfxElementModelView, GfxViewInteractionExtension, } from '../std/gfx/in
6
6
  import { normalizeShapeBound } from './element-renderer/index.js';
7
7
  import { PolygonVertexEditingOverlay } from './overlay/polygon-vertex-editing-overlay.js';
8
8
  import { mountShapeTextEditor } from './text/edgeless-shape-text-editor.js';
9
+ /**
10
+ * The view every shape gets, and the base a framework NODE view extends when
11
+ * its model is a {@link ShapeElementModel} subclass.
12
+ *
13
+ * Generic over the model, with the plain shape as the default, so
14
+ * `WardleyNodeView extends ShapeElementView<WardleyNodeElementModel>` reads its
15
+ * own `kind` off `this.model` without a cast. Every existing call site names no
16
+ * type argument and is unchanged.
17
+ *
18
+ * A framework node that extends this inherits the polygon VERTEX EDITOR, which
19
+ * is the whole reason to: {@link enterVertexEditingMode} is what the toolbar's
20
+ * "Edit vertices" action calls, and the action checks `view instanceof
21
+ * ShapeElementView` — a node view outside this hierarchy makes that button
22
+ * silently do nothing. What such a subclass usually does NOT want is the other
23
+ * thing wired here, the double-click-to-edit; {@link editsTextOnDblClick} is
24
+ * the one line it has to say so.
25
+ */
9
26
  export class ShapeElementView extends GfxElementModelView {
10
27
  constructor() {
11
28
  super(...arguments);
@@ -78,8 +95,23 @@ export class ShapeElementView extends GfxElementModelView {
78
95
  ConnectorPathGenerator.updatePath(connector, null, elementGetter);
79
96
  }
80
97
  }
98
+ /**
99
+ * Whether a double-click on THIS element opens its own inner-text editor.
100
+ *
101
+ * True for every plain shape, which is what this editor has always done: a
102
+ * shape is a box you write in. It is a HOOK rather than a constant because a
103
+ * framework node is often not one — its name may be a text element grouped
104
+ * beside it, or the element may be a glyph's own wiring that must carry no
105
+ * words at all — and a subclass that overrode `onCreated` to dodge this one
106
+ * wiring would lose the vertex editor set up beside it.
107
+ */
108
+ editsTextOnDblClick() {
109
+ return true;
110
+ }
81
111
  _initDblClickToEdit() {
82
112
  this.on('dblclick', () => {
113
+ if (!this.editsTextOnDblClick())
114
+ return;
83
115
  const edgeless = this.std.view.getBlock(this.std.store.root.id);
84
116
  if (edgeless &&
85
117
  !this.model.isLocked() &&
@@ -333,9 +365,26 @@ export class ShapeElementView extends GfxElementModelView {
333
365
  * The overlay is shown when a polygon is selected and removed when
334
366
  * deselected or when a non-polygon shape is selected.
335
367
  */
368
+ /**
369
+ * Whether THIS element's corners may be moved by hand.
370
+ *
371
+ * True for every plain polygon. The companion of {@link editsTextOnDblClick},
372
+ * and a hook for the same reason: a framework polygon's outline is often the
373
+ * NOTATION rather than a drawing — a Wardley accelerator points right and a
374
+ * decelerator points left, and that direction is the whole of what separates
375
+ * them — so hovering one must not offer a handle that can only turn a
376
+ * statement into a blob. Declining here skips the selection overlay, the
377
+ * pointer wiring and {@link enterVertexEditingMode} together, which is the
378
+ * point: a half-wired overlay is worse than none.
379
+ */
380
+ editsVerticesOnSelection() {
381
+ return true;
382
+ }
336
383
  _initPolygonVertexEditing() {
337
384
  if (this.model.shapeType !== ShapeType.Polygon)
338
385
  return;
386
+ if (!this.editsVerticesOnSelection())
387
+ return;
339
388
  // Subscribe to selection changes to manage overlay lifecycle
340
389
  this.disposable.add(this.gfx.selection.slots.updated.subscribe(() => {
341
390
  const selectedIds = this.gfx.selection.selectedIds;
@@ -481,6 +530,11 @@ export class ShapeElementView extends GfxElementModelView {
481
530
  enterVertexEditingMode() {
482
531
  if (this.model.shapeType !== ShapeType.Polygon)
483
532
  return;
533
+ // The same hook the selection wiring reads, asked again here: this is the
534
+ // other door into the mode, and an element that declined the wiring would
535
+ // otherwise get an overlay with no pointer handlers behind it.
536
+ if (!this.editsVerticesOnSelection())
537
+ return;
484
538
  this._ensureVertexEditingOverlay();
485
539
  this._enterVertexEditingMode();
486
540
  }
@@ -1,4 +1,5 @@
1
1
  export * from './config.js';
2
2
  export * from './icons.js';
3
+ export * from './palette-color-action.js';
3
4
  export * from './senior-tool.js';
4
5
  export * from './shape-menu-config.js';
@@ -1,4 +1,5 @@
1
1
  export * from './config.js';
2
2
  export * from './icons.js';
3
+ export * from './palette-color-action.js';
3
4
  export * from './senior-tool.js';
4
5
  export * from './shape-menu-config.js';
@@ -0,0 +1,25 @@
1
+ import { type Color, type Palette, ShapeElementModel } from '../../affine-model/index.js';
2
+ import type { ToolbarAction } from '../../affine-shared/services/index.js';
3
+ export declare function neutralPalettes(): Palette[];
4
+ /**
5
+ * A fill / stroke colour picker identical to the shape one, seeded with the
6
+ * caller's swatches (`.palettes`) instead of the default editor palette.
7
+ *
8
+ * Frameworks whose nodes are {@link ShapeElementModel} subclasses (EDGY,
9
+ * Wardley, …) all want the same picker with their own colours in front of it,
10
+ * so the ~100 lines that wire `edgeless-shape-color-picker` to the models live
11
+ * here once rather than being copied per framework.
12
+ */
13
+ /**
14
+ * The last word on what a picked swatch actually WRITES as a model's fill.
15
+ *
16
+ * The identity by default, which is what a colour picker means everywhere. It
17
+ * exists because a swatch is a hue and some elements are washes: a Wardley
18
+ * ZONE is drawn over the map it groups, so its fill carries an alpha, and a
19
+ * picked swatch that replaced `#c6dbfc99` with an opaque `#5b9cf6` would hide
20
+ * the very map the zone is there to annotate. A framework that has such
21
+ * elements says so here — per model, because the same picker serves its
22
+ * washes and its opaque artefacts.
23
+ */
24
+ export type FillColorFor = (model: ShapeElementModel, value: Color) => Color;
25
+ export declare function paletteColorAction(id: string, palettes: Palette[], fillColorFor?: FillColorFor): ToolbarAction;
@@ -0,0 +1,123 @@
1
+ import { EdgelessCRUDIdentifier } from '../../affine-block-surface/index.js';
2
+ import { packColor, } from '../../affine-components/color-picker/index.js';
3
+ import { DefaultTheme, isTransparent, LineWidth, resolveColor, ShapeElementModel, StrokeStyle, } from '../../affine-model/index.js';
4
+ import { getMostCommonValue } from '../../affine-shared/utils/index.js';
5
+ import { html } from 'lit';
6
+ /**
7
+ * The neutrals of the default editor palette: greys, white, black, transparent.
8
+ *
9
+ * A framework that seeds the picker with its OWN swatches drops the historical
10
+ * editor colours — they say nothing in that notation — but keeps the neutrals,
11
+ * which every drawing needs and no notation owns.
12
+ */
13
+ const NEUTRAL_KEY = /grey|gray|white|black|transparent/i;
14
+ export function neutralPalettes() {
15
+ return DefaultTheme.Palettes.filter(p => NEUTRAL_KEY.test(p.key));
16
+ }
17
+ // Mirror of the shape color action's text-color rule.
18
+ function getTextColor(fillColor) {
19
+ if (fillColor === DefaultTheme.black)
20
+ return DefaultTheme.white;
21
+ if (fillColor === DefaultTheme.white)
22
+ return DefaultTheme.black;
23
+ return DefaultTheme.shapeTextColor;
24
+ }
25
+ export function paletteColorAction(id, palettes, fillColorFor = (_model, value) => value) {
26
+ return {
27
+ id,
28
+ when(ctx) {
29
+ return ctx.getSurfaceModelsByType(ShapeElementModel).length > 0;
30
+ },
31
+ content(ctx) {
32
+ const models = ctx.getSurfaceModelsByType(ShapeElementModel);
33
+ if (!models.length)
34
+ return null;
35
+ const enableCustomColor = ctx.features.getFlag('enable_color_picker');
36
+ const theme = ctx.theme.edgeless$.value;
37
+ const firstModel = models[0];
38
+ const originalFillColor = firstModel.fillColor;
39
+ const originalStrokeColor = firstModel.strokeColor;
40
+ const mapped = models.map(({ filled, fillColor, strokeColor, strokeWidth, strokeStyle }) => ({
41
+ fillColor: filled
42
+ ? resolveColor(fillColor, theme)
43
+ : DefaultTheme.transparent,
44
+ strokeColor: resolveColor(strokeColor, theme),
45
+ strokeWidth,
46
+ strokeStyle,
47
+ }));
48
+ const fillColor = getMostCommonValue(mapped, 'fillColor') ??
49
+ resolveColor(DefaultTheme.shapeFillColor, theme);
50
+ const strokeColor = getMostCommonValue(mapped, 'strokeColor') ??
51
+ resolveColor(DefaultTheme.shapeStrokeColor, theme);
52
+ const strokeWidth = getMostCommonValue(mapped, 'strokeWidth') ?? LineWidth.Four;
53
+ const strokeStyle = getMostCommonValue(mapped, 'strokeStyle') ?? StrokeStyle.Solid;
54
+ const pickColorWrapper = (field, pickCallback) => (e) => {
55
+ e.stopPropagation();
56
+ switch (e.detail.type) {
57
+ case 'pick':
58
+ pickCallback(e.detail.detail);
59
+ break;
60
+ case 'start':
61
+ ctx.store.captureSync();
62
+ models.forEach(model => model.stash(field));
63
+ break;
64
+ case 'end':
65
+ ctx.store.transact(() => {
66
+ models.forEach(model => model.pop(field));
67
+ });
68
+ }
69
+ };
70
+ const onPickFillColor = pickColorWrapper('fillColor', palette => {
71
+ const value = palette.value;
72
+ const filled = isTransparent(value);
73
+ const crud = ctx.std.get(EdgelessCRUDIdentifier);
74
+ models.forEach(model => {
75
+ // Per model, so a selection mixing a wash and an opaque artefact
76
+ // gets the right fill on each — and `packColor` runs on what the
77
+ // hook returned, since that is the value the document stores.
78
+ const props = packColor('fillColor', fillColorFor(model, value));
79
+ if (filled && !model.filled) {
80
+ const color = getTextColor(value);
81
+ Object.assign(props, { filled, color });
82
+ }
83
+ crud.updateElement(model.id, props);
84
+ });
85
+ });
86
+ const onPickStrokeColor = pickColorWrapper('strokeColor', palette => {
87
+ const props = packColor('strokeColor', palette.value);
88
+ const crud = ctx.std.get(EdgelessCRUDIdentifier);
89
+ models.forEach(model => crud.updateElement(model.id, props));
90
+ });
91
+ const onPickStrokeStyle = (e) => {
92
+ e.stopPropagation();
93
+ const { type, value } = e.detail;
94
+ const crud = ctx.std.get(EdgelessCRUDIdentifier);
95
+ const props = type === 'size'
96
+ ? { strokeWidth: value }
97
+ : { strokeStyle: value };
98
+ for (const model of models) {
99
+ crud.updateElement(model.id, props);
100
+ }
101
+ };
102
+ return html `
103
+ <edgeless-shape-color-picker
104
+ @pickFillColor=${onPickFillColor}
105
+ @pickStrokeColor=${onPickStrokeColor}
106
+ @pickStrokeStyle=${onPickStrokeStyle}
107
+ .palettes=${palettes}
108
+ .payload=${{
109
+ fillColor,
110
+ strokeColor,
111
+ strokeWidth,
112
+ strokeStyle,
113
+ originalFillColor,
114
+ originalStrokeColor,
115
+ theme,
116
+ enableCustomColor,
117
+ }}
118
+ >
119
+ </edgeless-shape-color-picker>
120
+ `;
121
+ },
122
+ };
123
+ }
@@ -1,16 +1,31 @@
1
1
  import { ShapeElementModel } from '../shape/index.js';
2
- export type WardleyNodeKind = 'component' | 'anchor' | 'pipeline' | 'handle' | 'market' | 'ecosystem' | 'method';
2
+ export type WardleyNodeKind = 'component' | 'anchor' | 'pipeline' | 'handle' | 'market' | 'ecosystem' | 'method' | 'porter' | 'accelerator' | 'decelerator' | 'area';
3
3
  /**
4
4
  * A Wardley map node. Extends {@link ShapeElementModel} (a native ellipse) so it
5
5
  * inherits ALL shape behaviour — editable stroke width / colors, native resize,
6
6
  * center connector anchor, the shape context toolbar — for free. `kind`
7
7
  * discriminates the plain `component`, the `anchor` (which the renderer
8
8
  * decorates with an inscribed person glyph), the two pieces of a pipeline (the
9
- * `pipeline` body + its square `handle`), the `market` outer circle, and the
10
- * `ecosystem` grey backing disk. Composite nodes (pipeline / market / ecosystem)
11
- * are built by grouping several of these + native connectors + a text label. The
12
- * text label is a SEPARATE native text element grouped with the node, not stored
13
- * here.
9
+ * `pipeline` body + its square `handle`), the `market` outer circle, the
10
+ * `ecosystem` grey backing disk, the `porter` circle — a Porter's-forces
11
+ * glyph, which is the one kind that is NOT a link in the value chain: it marks
12
+ * an external competition force acting on the map — and the `accelerator` /
13
+ * `decelerator` arrows, which are not links in the chain either: they annotate
14
+ * the CLIMATE, saying that something is speeding evolution up or slowing it
15
+ * down. Those two are the first kinds drawn as a native POLYGON rather than an
16
+ * ellipse or a rect — and the `area` is the third: a translucent ZONE of the
17
+ * map, drawn as a rect or as a polygon over the components it groups, and the
18
+ * only kind whose two shapes are the same kind (the `shapeType` says which).
19
+ * Composite nodes (pipeline / market / ecosystem / porter / accelerator /
20
+ * decelerator) are built by grouping several of these + native connectors + a
21
+ * text label. The text label is a SEPARATE native text element grouped with the
22
+ * node, not stored here — the `porter` and the `area` being the exceptions that
23
+ * prove it, since the porter's one letter and the area's name are the shape's
24
+ * own inner text.
25
+ *
26
+ * `kind` is ADDITIVE by construction: a value is only ever appended, so a
27
+ * document written before a value was appended carries none of it and opens
28
+ * unchanged. `area` is the most recent, and needs no migration.
14
29
  */
15
30
  export declare class WardleyNodeElementModel extends ShapeElementModel {
16
31
  get type(): string;
@@ -40,11 +40,26 @@ import { ShapeElementModel } from '../shape/index.js';
40
40
  * center connector anchor, the shape context toolbar — for free. `kind`
41
41
  * discriminates the plain `component`, the `anchor` (which the renderer
42
42
  * decorates with an inscribed person glyph), the two pieces of a pipeline (the
43
- * `pipeline` body + its square `handle`), the `market` outer circle, and the
44
- * `ecosystem` grey backing disk. Composite nodes (pipeline / market / ecosystem)
45
- * are built by grouping several of these + native connectors + a text label. The
46
- * text label is a SEPARATE native text element grouped with the node, not stored
47
- * here.
43
+ * `pipeline` body + its square `handle`), the `market` outer circle, the
44
+ * `ecosystem` grey backing disk, the `porter` circle — a Porter's-forces
45
+ * glyph, which is the one kind that is NOT a link in the value chain: it marks
46
+ * an external competition force acting on the map — and the `accelerator` /
47
+ * `decelerator` arrows, which are not links in the chain either: they annotate
48
+ * the CLIMATE, saying that something is speeding evolution up or slowing it
49
+ * down. Those two are the first kinds drawn as a native POLYGON rather than an
50
+ * ellipse or a rect — and the `area` is the third: a translucent ZONE of the
51
+ * map, drawn as a rect or as a polygon over the components it groups, and the
52
+ * only kind whose two shapes are the same kind (the `shapeType` says which).
53
+ * Composite nodes (pipeline / market / ecosystem / porter / accelerator /
54
+ * decelerator) are built by grouping several of these + native connectors + a
55
+ * text label. The text label is a SEPARATE native text element grouped with the
56
+ * node, not stored here — the `porter` and the `area` being the exceptions that
57
+ * prove it, since the porter's one letter and the area's name are the shape's
58
+ * own inner text.
59
+ *
60
+ * `kind` is ADDITIVE by construction: a value is only ever appended, so a
61
+ * document written before a value was appended carries none of it and opens
62
+ * unchanged. `area` is the most recent, and needs no migration.
48
63
  */
49
64
  let WardleyNodeElementModel = (() => {
50
65
  let _classSuper = ShapeElementModel;
@@ -10,6 +10,20 @@ export declare const ABANDON_WINDOW_MS = 15000;
10
10
  * BlockUsageDuration when it closes).
11
11
  */
12
12
  export declare const IDLE_GAP_MS = 30000;
13
+ /**
14
+ * The flavours whose lifecycle is reported. Canvas blocks only: the surface
15
+ * (every framework element mutation lands on it), the cards placed on it and
16
+ * the media that can be dropped there.
17
+ *
18
+ * Prose is deliberately out (PO decision, 2026-09-03). Per-paragraph edit
19
+ * sessions are the volume driver of the whole taxonomy and answer no product
20
+ * question the document itself cannot answer later — which documents carry
21
+ * prose next to a map is a corpus query, not a telemetry one. What evaporates
22
+ * if not captured is behaviour on the canvas: hesitation, abandonment, time
23
+ * spent — so that is what stays on the bus. Reversible in one line.
24
+ */
25
+ export declare const CANVAS_FLAVOURS: ReadonlySet<string>;
26
+ export declare function isCanvasFlavour(flavour: string): boolean;
13
27
  /**
14
28
  * The subset of the store `blockUpdated` payload the tracker relies on —
15
29
  * structurally compatible with `Store['slots']['blockUpdated']` payloads.
@@ -29,7 +43,8 @@ export type BlockLifecyclePayload = {
29
43
  * BlockCreated is intentionally NOT emitted here — it stays a UI-intent
30
44
  * event emitted at insertion sites (slash menu, toolbars), to avoid double
31
45
  * counting. Remote and initial-load mutations are ignored (`isLocal` only):
32
- * the taxonomy describes user intent, not store mechanics.
46
+ * the taxonomy describes user intent, not store mechanics. Non-canvas
47
+ * flavours are ignored too — see {@link CANVAS_FLAVOURS}.
33
48
  *
34
49
  * Pure logic, injectable clock — see the unit tests.
35
50
  */
@@ -10,6 +10,30 @@ export const ABANDON_WINDOW_MS = 15_000;
10
10
  * BlockUsageDuration when it closes).
11
11
  */
12
12
  export const IDLE_GAP_MS = 30_000;
13
+ /**
14
+ * The flavours whose lifecycle is reported. Canvas blocks only: the surface
15
+ * (every framework element mutation lands on it), the cards placed on it and
16
+ * the media that can be dropped there.
17
+ *
18
+ * Prose is deliberately out (PO decision, 2026-09-03). Per-paragraph edit
19
+ * sessions are the volume driver of the whole taxonomy and answer no product
20
+ * question the document itself cannot answer later — which documents carry
21
+ * prose next to a map is a corpus query, not a telemetry one. What evaporates
22
+ * if not captured is behaviour on the canvas: hesitation, abandonment, time
23
+ * spent — so that is what stays on the bus. Reversible in one line.
24
+ */
25
+ export const CANVAS_FLAVOURS = new Set([
26
+ 'affine:surface',
27
+ 'affine:note',
28
+ 'affine:frame',
29
+ 'affine:edgeless-text',
30
+ 'affine:image',
31
+ 'affine:attachment',
32
+ 'affine:bookmark',
33
+ ]);
34
+ export function isCanvasFlavour(flavour) {
35
+ return CANVAS_FLAVOURS.has(flavour) || flavour.startsWith('affine:embed-');
36
+ }
13
37
  /**
14
38
  * Turns raw store mutations into the block lifecycle taxonomy (see README):
15
39
  * BlockEdited / BlockDeleted / BlockAbandoned / BlockUsageDuration.
@@ -17,7 +41,8 @@ export const IDLE_GAP_MS = 30_000;
17
41
  * BlockCreated is intentionally NOT emitted here — it stays a UI-intent
18
42
  * event emitted at insertion sites (slash menu, toolbars), to avoid double
19
43
  * counting. Remote and initial-load mutations are ignored (`isLocal` only):
20
- * the taxonomy describes user intent, not store mechanics.
44
+ * the taxonomy describes user intent, not store mechanics. Non-canvas
45
+ * flavours are ignored too — see {@link CANVAS_FLAVOURS}.
21
46
  *
22
47
  * Pure logic, injectable clock — see the unit tests.
23
48
  */
@@ -31,7 +56,7 @@ export class BlockLifecycleTracker {
31
56
  this._sessions = new Map();
32
57
  }
33
58
  handle(payload) {
34
- if (!payload.isLocal)
59
+ if (!payload.isLocal || !isCanvasFlavour(payload.flavour))
35
60
  return;
36
61
  const t = this._now();
37
62
  const { id, flavour } = payload;
@@ -121,11 +121,21 @@ export interface CommandDescriptor<P = void> {
121
121
  * Emitted centrally by {@link runCommand} — never by the command body.
122
122
  * `framework` is the code-side id; the reporter maps it through
123
123
  * `FrameworkDescriptor.telemetryKey` so the wire value stays historical.
124
+ *
125
+ * `board: true` marks the command that puts a framework's BOARD on the
126
+ * canvas (a Wardley background, a BPMN pool, a C4 board…). Every framework
127
+ * names its board differently in `element` — `background:classic`, `pool`,
128
+ * `board`, `cynefin` — so "boards created per framework" cannot be read off
129
+ * a prefix; it is declared here, at the command, and the reporter forwards
130
+ * it as the `role` dimension.
124
131
  */
125
- telemetry?: {
126
- framework: FrameworkId;
127
- element: string;
128
- };
132
+ telemetry?: CommandTelemetry;
133
+ }
134
+ /** See {@link CommandDescriptor.telemetry}. */
135
+ export interface CommandTelemetry {
136
+ framework: FrameworkId;
137
+ element: string;
138
+ board?: true;
129
139
  }
130
140
  /**
131
141
  * A command with its parameter contract erased — what the REGISTRY holds.
@@ -189,10 +199,7 @@ export interface CommandManifestEntry {
189
199
  * command is. Derived from `CommandDescriptor.params`, never authored twice.
190
200
  */
191
201
  params?: CommandParam[];
192
- telemetry?: {
193
- framework: FrameworkId;
194
- element: string;
195
- };
202
+ telemetry?: CommandTelemetry;
196
203
  }
197
204
  /**
198
205
  * The per-framework identity consumers 2-5 need, and that nothing owned before
@@ -50,6 +50,10 @@ export function reportCommandTelemetry(track, command, invocation) {
50
50
  // invoked the command. This is the metric that will arbitrate the 14
51
51
  // senior slots against the sidepanel against the palette.
52
52
  control: invocation.source,
53
+ // Only present on a framework's board command: the ratio "boards created
54
+ // / elements added" per framework is the signal that the board gesture is
55
+ // understood at all. Absent otherwise, so no existing event grows a key.
56
+ ...(telemetry.board ? { role: 'board' } : {}),
53
57
  });
54
58
  }
55
59
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@formicoidea/labre-core",
3
3
  "description": "Labre editor — the full BlockSuite-derived editor as one package.",
4
- "version": "0.35.0",
4
+ "version": "0.36.0",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "author": "lajola",