@formicoidea/labre-framework-wardley 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,552 @@
1
+ import { DefaultTool } from '@formicoidea/labre-core/blocks/surface';
2
+ import { ConnectorTool } from '@formicoidea/labre-core/gfx/connector';
3
+ import { createGroupCommand } from '@formicoidea/labre-core/gfx/group';
4
+ import { EmptyTool } from '@formicoidea/labre-core/gfx/pointer';
5
+ import {
6
+ ConnectorMode,
7
+ FontFamily,
8
+ PointStyle,
9
+ ShapeStyle,
10
+ StrokeStyle,
11
+ type WardleyBgVariant,
12
+ } from '@formicoidea/labre-core/model';
13
+ import {
14
+ EditPropsStore,
15
+ TelemetryProvider,
16
+ } from '@formicoidea/labre-core/shared/services';
17
+ import { EdgelessToolbarToolMixin } from '@formicoidea/labre-core/widgets/edgeless-toolbar';
18
+ import { Bound } from '@formicoidea/labre-core/global/gfx';
19
+ import type { GfxController } from '@formicoidea/labre-core/std/gfx';
20
+ import { css, html, LitElement } from 'lit';
21
+
22
+ import { REF_WIDTH } from '../consts';
23
+ import {
24
+ ECOSYSTEM_LABEL,
25
+ ECOSYSTEM_SIZE,
26
+ HANDLE_SIZE,
27
+ INERTIA_COLOR,
28
+ INERTIA_SIZE,
29
+ LABEL_DEFAULT,
30
+ LABEL_FONT_SIZE,
31
+ LABEL_GAP,
32
+ LINK_GREY,
33
+ LINK_STROKE_WIDTH,
34
+ MARKET_DOT_RING,
35
+ MARKET_DOT_SIZE,
36
+ MARKET_DOT_STROKE_WIDTH,
37
+ MARKET_LABEL,
38
+ MARKET_LINK_COLOR,
39
+ MARKET_LINK_WIDTH,
40
+ MARKET_SIZE,
41
+ METHOD_FILL,
42
+ METHOD_LABEL,
43
+ METHOD_SIZE,
44
+ NODE_FILL,
45
+ NODE_SIZE,
46
+ NODE_STROKE,
47
+ NODE_STROKE_WIDTH,
48
+ PIPELINE_FILL,
49
+ PIPELINE_HEIGHT,
50
+ PIPELINE_LABEL,
51
+ PIPELINE_WIDTH,
52
+ WARDLEY_RED,
53
+ } from '../node/consts';
54
+ import {
55
+ wardleyAnchorIcon,
56
+ wardleyArrowIcon,
57
+ wardleyBackgroundIcon,
58
+ wardleyBenefitIcon,
59
+ wardleyComponentIcon,
60
+ wardleyEcosystemIcon,
61
+ wardleyInertiaIcon,
62
+ wardleyLinkIcon,
63
+ wardleyMarketIcon,
64
+ wardleyMethodIcon,
65
+ wardleyEvolutionGradientIcon,
66
+ wardleyOpportunityIcon,
67
+ wardleyPipelineIcon,
68
+ } from './icons';
69
+
70
+ /**
71
+ * Per-variant default label overrides applied at creation (all remain editable
72
+ * afterwards via the inline editor / toggles). The gradient itself is driven by
73
+ * `variant` in the renderer.
74
+ */
75
+ const BACKGROUND_VARIANT_DEFAULTS: Record<
76
+ WardleyBgVariant,
77
+ Record<string, unknown>
78
+ > = {
79
+ classic: {},
80
+ // The Y axis becomes "Opportunity"; phase labels keep the classic defaults.
81
+ opportunity: {
82
+ yAxisTitle: 'Opportunity',
83
+ showVisibilityLabels: false,
84
+ showCornerLabels: false,
85
+ },
86
+ // The Y axis splits into Benefit (top) / Investment (bottom) around a zero
87
+ // line drawn by the renderer.
88
+ benefit: {
89
+ yAxisTitle: '',
90
+ visibilityHigh: 'Benefit',
91
+ visibilityLow: 'Investment',
92
+ showCornerLabels: false,
93
+ },
94
+ // Keeps the classic labels (Value Chain / Uncharted / Industrialized…); only
95
+ // the grey gradient differs.
96
+ 'evolution-gradient': {},
97
+ };
98
+
99
+ type Surface = NonNullable<GfxController['surface']>;
100
+
101
+ /** Height of the native free-text labels (Inter, size 18). */
102
+ const LABEL_H = LABEL_FONT_SIZE + 8;
103
+
104
+ /**
105
+ * The single-circle node flavours: one connectable ellipse + a label to its
106
+ * right, grouped. The glyph itself (anchor silhouette, ecosystem hatching,
107
+ * method inner circle) is drawn by the node renderer from `kind`.
108
+ */
109
+ const NODE_PRESETS = {
110
+ component: { d: NODE_SIZE, fill: NODE_FILL, label: LABEL_DEFAULT.component },
111
+ anchor: { d: NODE_SIZE, fill: NODE_FILL, label: LABEL_DEFAULT.anchor },
112
+ // Ecosystem: glyph = double border + hatched donut; connectors attach to
113
+ // this outer circle's center.
114
+ ecosystem: { d: ECOSYSTEM_SIZE, fill: NODE_FILL, label: ECOSYSTEM_LABEL },
115
+ // Method: the FILL color encodes the chosen method (editable).
116
+ method: { d: METHOD_SIZE, fill: METHOD_FILL, label: METHOD_LABEL },
117
+ } as const;
118
+
119
+ /**
120
+ * The popover that opens above the toolbar for the Wardley toolbox. Each item
121
+ * creates a pre-formatted Wardley object. Nodes (component / anchor) are a
122
+ * native ellipse + a native text label, grouped together.
123
+ */
124
+ export class EdgelessWardleyMenu extends EdgelessToolbarToolMixin(LitElement) {
125
+ static override styles = css`
126
+ :host {
127
+ position: absolute;
128
+ display: flex;
129
+ z-index: -1;
130
+ }
131
+ .menu-content {
132
+ display: flex;
133
+ align-items: center;
134
+ justify-content: center;
135
+ }
136
+ .button-group-container {
137
+ display: flex;
138
+ align-items: center;
139
+ gap: 14px;
140
+ fill: var(--affine-icon-color);
141
+ }
142
+ .button-group-container svg {
143
+ width: 24px;
144
+ height: 24px;
145
+ }
146
+ `;
147
+
148
+ override type = EmptyTool;
149
+
150
+ private _createBackground(variant: WardleyBgVariant = 'classic') {
151
+ const { gfx } = this;
152
+ if (!gfx.surface) return;
153
+
154
+ let width = REF_WIDTH;
155
+ for (const el of gfx.surface.getElementsByType('wardley')) {
156
+ const [, , ew, eh] = el.deserializedXYWH;
157
+ width = Math.max(width, ew, (eh * 16) / 9);
158
+ }
159
+ const height = (width * 9) / 16;
160
+
161
+ const { centerX, centerY } = gfx.viewport;
162
+ const id = gfx.surface.addElement({
163
+ type: 'wardley',
164
+ variant,
165
+ ...BACKGROUND_VARIANT_DEFAULTS[variant],
166
+ xywh: new Bound(
167
+ centerX - width / 2,
168
+ centerY - height / 2,
169
+ width,
170
+ height
171
+ ).serialize(),
172
+ });
173
+ this._track('FrameworkElementAdded', `background:${variant}`);
174
+ this._finish(id);
175
+ }
176
+
177
+ /** Add a native ellipse wardley node centred on (cx, cy). */
178
+ private _addEllipseNode(
179
+ surface: Surface,
180
+ kind: keyof typeof NODE_PRESETS | 'market',
181
+ cx: number,
182
+ cy: number,
183
+ d: number,
184
+ fillColor: string,
185
+ strokeWidth = NODE_STROKE_WIDTH
186
+ ) {
187
+ return surface.addElement({
188
+ type: 'wardleyNode',
189
+ kind,
190
+ shapeType: 'ellipse',
191
+ filled: true,
192
+ fillColor,
193
+ strokeColor: NODE_STROKE,
194
+ strokeWidth,
195
+ shapeStyle: ShapeStyle.General,
196
+ roughness: 0,
197
+ xywh: new Bound(cx - d / 2, cy - d / 2, d, d).serialize(),
198
+ });
199
+ }
200
+
201
+ /** Add a native free-text label (same Inter family as the axis labels). */
202
+ private _addLabel(
203
+ surface: Surface,
204
+ text: string,
205
+ x: number,
206
+ y: number,
207
+ textAlign: 'left' | 'center' = 'left'
208
+ ) {
209
+ return surface.addElement({
210
+ type: 'text',
211
+ text,
212
+ fontFamily: FontFamily.Inter,
213
+ fontSize: LABEL_FONT_SIZE,
214
+ color: NODE_STROKE,
215
+ textAlign,
216
+ xywh: new Bound(x, y, 120, LABEL_H).serialize(),
217
+ });
218
+ }
219
+
220
+ /** Group elements; returns the group id (or the first id if grouping failed). */
221
+ private _group(ids: string[]) {
222
+ const [, result] = this.edgeless.std.command.exec(createGroupCommand, {
223
+ elements: ids,
224
+ });
225
+ return result.groupId || ids[0];
226
+ }
227
+
228
+ /**
229
+ * Create a single-circle node (component / anchor / ecosystem / method):
230
+ * one connectable native ellipse + a label to its right, grouped so they
231
+ * move together (enter the group to reposition / edit the label).
232
+ */
233
+ private _createNode(kind: keyof typeof NODE_PRESETS) {
234
+ const surface = this.gfx.surface;
235
+ if (!surface) return;
236
+
237
+ const { d, fill, label } = NODE_PRESETS[kind];
238
+ const { centerX: cx, centerY: cy } = this.gfx.viewport;
239
+
240
+ const nodeId = this._addEllipseNode(surface, kind, cx, cy, d, fill);
241
+ const labelId = this._addLabel(
242
+ surface,
243
+ label,
244
+ cx + d / 2 + LABEL_GAP,
245
+ cy - LABEL_H / 2
246
+ );
247
+
248
+ this._track('FrameworkElementAdded', `node:${kind}`);
249
+ this._finish(this._group([nodeId, labelId]));
250
+ }
251
+
252
+ private _createInertia() {
253
+ const { gfx } = this;
254
+ if (!gfx.surface) return;
255
+
256
+ const { w, h } = INERTIA_SIZE;
257
+ const { centerX, centerY } = gfx.viewport;
258
+ const id = gfx.surface.addElement({
259
+ type: 'shape',
260
+ shapeType: 'rect',
261
+ filled: true,
262
+ fillColor: INERTIA_COLOR,
263
+ strokeColor: INERTIA_COLOR,
264
+ strokeWidth: 0,
265
+ shapeStyle: ShapeStyle.General,
266
+ roughness: 0,
267
+ radius: 0,
268
+ xywh: new Bound(centerX - w / 2, centerY - h / 2, w, h).serialize(),
269
+ });
270
+ this._track('FrameworkElementAdded', 'node:inertia');
271
+ this._finish(id);
272
+ }
273
+
274
+ /**
275
+ * Create a pipeline: a wide thin native rect body (white semi-transparent,
276
+ * NON-connectable) + a node-sized square handle straddling its top edge (the
277
+ * only connection point, center anchor) + a native text label. The handle and
278
+ * label are grouped, then grouped again with the body so the whole pipeline
279
+ * moves as one. Pure composition of native elements — no custom type / view.
280
+ */
281
+ private _createPipeline() {
282
+ const { gfx } = this;
283
+ if (!gfx.surface) return;
284
+
285
+ const { centerX: cx, centerY: cy } = gfx.viewport;
286
+ const W = PIPELINE_WIDTH;
287
+ const H = PIPELINE_HEIGHT;
288
+ const d = HANDLE_SIZE;
289
+ const top = cy - H / 2;
290
+
291
+ // Body: a WardleyNode rect, made non-connectable by `kind: 'pipeline'`.
292
+ const bodyId = gfx.surface.addElement({
293
+ type: 'wardleyNode',
294
+ kind: 'pipeline',
295
+ shapeType: 'rect',
296
+ filled: true,
297
+ fillColor: PIPELINE_FILL,
298
+ strokeColor: NODE_STROKE,
299
+ strokeWidth: NODE_STROKE_WIDTH,
300
+ shapeStyle: ShapeStyle.General,
301
+ roughness: 0,
302
+ radius: 0,
303
+ xywh: new Bound(cx - W / 2, top, W, H).serialize(),
304
+ });
305
+
306
+ // Handle: a node-sized WardleyNode square straddling the top edge. Inherits
307
+ // `centerAnchorOnly` so connectors attach to its center only.
308
+ const handleId = gfx.surface.addElement({
309
+ type: 'wardleyNode',
310
+ kind: 'handle',
311
+ shapeType: 'rect',
312
+ filled: true,
313
+ fillColor: NODE_FILL,
314
+ strokeColor: NODE_STROKE,
315
+ strokeWidth: NODE_STROKE_WIDTH,
316
+ shapeStyle: ShapeStyle.General,
317
+ roughness: 0,
318
+ radius: 0,
319
+ xywh: new Bound(cx - d / 2, top - d / 2, d, d).serialize(),
320
+ });
321
+
322
+ // Label centered horizontally on the pipeline, sitting ABOVE the handle.
323
+ const labelId = this._addLabel(
324
+ gfx.surface,
325
+ PIPELINE_LABEL,
326
+ cx - 60,
327
+ top - d / 2 - LABEL_H - LABEL_GAP,
328
+ 'center'
329
+ );
330
+
331
+ // Nested groups: (handle + label), then (body + that group).
332
+ const innerId = this._group([handleId, labelId]);
333
+ this._track('FrameworkElementAdded', 'node:pipeline');
334
+ this._finish(this._group([bodyId, innerId]));
335
+ }
336
+
337
+ /**
338
+ * Create a market: a large thin-bordered circle (the connectable market node)
339
+ * containing 3 small thick-bordered component nodes wired into a triangle by
340
+ * native attached connectors (thin, dark, no arrows — they auto-route between
341
+ * the node centers and follow on move/resize). A label sits to the right and
342
+ * everything is grouped into one object.
343
+ */
344
+ private _createMarket() {
345
+ const surface = this.gfx.surface;
346
+ if (!surface) return;
347
+
348
+ const { centerX: cx, centerY: cy } = this.gfx.viewport;
349
+ const R = MARKET_SIZE / 2;
350
+ const rho = MARKET_DOT_RING;
351
+ const sin60 = Math.sqrt(3) / 2;
352
+
353
+ // Outer circle = the market node (connectable, center-only).
354
+ const circleId = this._addEllipseNode(
355
+ surface,
356
+ 'market',
357
+ cx,
358
+ cy,
359
+ MARKET_SIZE,
360
+ NODE_FILL
361
+ );
362
+
363
+ // 3 inner component nodes (thick border, no label) at the triangle vertices.
364
+ const verts = [
365
+ [0, -rho],
366
+ [rho * sin60, rho / 2],
367
+ [-rho * sin60, rho / 2],
368
+ ];
369
+ const dotIds = verts.map(([vx, vy]) =>
370
+ this._addEllipseNode(
371
+ surface,
372
+ 'component',
373
+ cx + vx,
374
+ cy + vy,
375
+ MARKET_DOT_SIZE,
376
+ NODE_FILL,
377
+ MARKET_DOT_STROKE_WIDTH
378
+ )
379
+ );
380
+
381
+ // Triangle: 3 attached connectors (auto-route center-to-center, clipped).
382
+ const connIds = [
383
+ [dotIds[0], dotIds[1]],
384
+ [dotIds[1], dotIds[2]],
385
+ [dotIds[2], dotIds[0]],
386
+ ].map(([a, b]) =>
387
+ surface.addElement({
388
+ type: 'connector',
389
+ mode: ConnectorMode.Straight,
390
+ source: { id: a },
391
+ target: { id: b },
392
+ stroke: MARKET_LINK_COLOR,
393
+ strokeStyle: StrokeStyle.Solid,
394
+ strokeWidth: MARKET_LINK_WIDTH,
395
+ frontEndpointStyle: PointStyle.None,
396
+ rearEndpointStyle: PointStyle.None,
397
+ })
398
+ );
399
+
400
+ const labelId = this._addLabel(
401
+ surface,
402
+ MARKET_LABEL,
403
+ cx + R + LABEL_GAP,
404
+ cy - LABEL_H / 2
405
+ );
406
+
407
+ this._track('FrameworkElementAdded', 'node:market');
408
+ this._finish(this._group([circleId, ...dotIds, ...connIds, labelId]));
409
+ }
410
+
411
+ /**
412
+ * Activate the native connector tool, pre-styled for a Wardley link (grey,
413
+ * solid, no arrow) or evolution arrow (red, dashed, FILLED triangle). The
414
+ * user then draws from one node to another (endpoints attach to centers).
415
+ */
416
+ private _activateConnector(kind: 'link' | 'arrow') {
417
+ const props =
418
+ kind === 'arrow'
419
+ ? {
420
+ mode: ConnectorMode.Straight,
421
+ stroke: WARDLEY_RED,
422
+ strokeStyle: StrokeStyle.Dash,
423
+ strokeWidth: LINK_STROKE_WIDTH,
424
+ frontEndpointStyle: PointStyle.None,
425
+ rearEndpointStyle: PointStyle.Triangle,
426
+ }
427
+ : {
428
+ mode: ConnectorMode.Straight,
429
+ stroke: LINK_GREY,
430
+ strokeStyle: StrokeStyle.Solid,
431
+ strokeWidth: LINK_STROKE_WIDTH,
432
+ frontEndpointStyle: PointStyle.None,
433
+ rearEndpointStyle: PointStyle.None,
434
+ };
435
+ this.edgeless.std.get(EditPropsStore).recordLastProps('connector', props);
436
+ this._track('FrameworkToolPicked', `connector:${kind}`);
437
+ this.gfx.tool.setTool(ConnectorTool, { mode: ConnectorMode.Straight });
438
+ // Keep the palette open (native sub-menu behaviour): it only closes on
439
+ // re-click of the senior button, another senior tool, or Escape.
440
+ }
441
+
442
+ private _finish(id: string) {
443
+ const { gfx } = this;
444
+ gfx.doc.captureSync();
445
+ gfx.tool.setTool(DefaultTool);
446
+ gfx.selection.set({ elements: [id], editing: false });
447
+ // Keep the palette open (native sub-menu behaviour) so several Wardley
448
+ // objects can be added in a row; the canvas stays selectable meanwhile.
449
+ }
450
+
451
+ private _track(
452
+ event: 'FrameworkElementAdded' | 'FrameworkToolPicked',
453
+ element: string
454
+ ) {
455
+ this.edgeless.std.getOptional(TelemetryProvider)?.track(event, {
456
+ framework: 'wardley',
457
+ element,
458
+ page: 'whiteboard editor',
459
+ segment: 'wardley toolbox',
460
+ module: 'wardley menu',
461
+ });
462
+ }
463
+
464
+ override render() {
465
+ return html`
466
+ <edgeless-slide-menu>
467
+ <div class="menu-content">
468
+ <div class="button-group-container">
469
+ <edgeless-tool-icon-button
470
+ .tooltip=${'Wardley map background'}
471
+ @click=${() => this._createBackground('classic')}
472
+ >
473
+ ${wardleyBackgroundIcon}
474
+ </edgeless-tool-icon-button>
475
+ <edgeless-tool-icon-button
476
+ .tooltip=${'Opportunity background (gradient)'}
477
+ @click=${() => this._createBackground('opportunity')}
478
+ >
479
+ ${wardleyOpportunityIcon}
480
+ </edgeless-tool-icon-button>
481
+ <edgeless-tool-icon-button
482
+ .tooltip=${'Benefit / Investment background (gradient)'}
483
+ @click=${() => this._createBackground('benefit')}
484
+ >
485
+ ${wardleyBenefitIcon}
486
+ </edgeless-tool-icon-button>
487
+ <edgeless-tool-icon-button
488
+ .tooltip=${'Evolution background (Wardley presentation)'}
489
+ @click=${() => this._createBackground('evolution-gradient')}
490
+ >
491
+ ${wardleyEvolutionGradientIcon}
492
+ </edgeless-tool-icon-button>
493
+ <edgeless-tool-icon-button
494
+ .tooltip=${'Component'}
495
+ @click=${() => this._createNode('component')}
496
+ >
497
+ ${wardleyComponentIcon}
498
+ </edgeless-tool-icon-button>
499
+ <edgeless-tool-icon-button
500
+ .tooltip=${'Component + method'}
501
+ @click=${() => this._createNode('method')}
502
+ >
503
+ ${wardleyMethodIcon}
504
+ </edgeless-tool-icon-button>
505
+ <edgeless-tool-icon-button
506
+ .tooltip=${'Market'}
507
+ @click=${this._createMarket}
508
+ >
509
+ ${wardleyMarketIcon}
510
+ </edgeless-tool-icon-button>
511
+ <edgeless-tool-icon-button
512
+ .tooltip=${'Ecosystem'}
513
+ @click=${() => this._createNode('ecosystem')}
514
+ >
515
+ ${wardleyEcosystemIcon}
516
+ </edgeless-tool-icon-button>
517
+ <edgeless-tool-icon-button
518
+ .tooltip=${'Anchor'}
519
+ @click=${() => this._createNode('anchor')}
520
+ >
521
+ ${wardleyAnchorIcon}
522
+ </edgeless-tool-icon-button>
523
+ <edgeless-tool-icon-button
524
+ .tooltip=${'Pipeline'}
525
+ @click=${this._createPipeline}
526
+ >
527
+ ${wardleyPipelineIcon}
528
+ </edgeless-tool-icon-button>
529
+ <edgeless-tool-icon-button
530
+ .tooltip=${'Link'}
531
+ @click=${() => this._activateConnector('link')}
532
+ >
533
+ ${wardleyLinkIcon}
534
+ </edgeless-tool-icon-button>
535
+ <edgeless-tool-icon-button
536
+ .tooltip=${'Arrow (evolution)'}
537
+ @click=${() => this._activateConnector('arrow')}
538
+ >
539
+ ${wardleyArrowIcon}
540
+ </edgeless-tool-icon-button>
541
+ <edgeless-tool-icon-button
542
+ .tooltip=${'Inertia'}
543
+ @click=${this._createInertia}
544
+ >
545
+ ${wardleyInertiaIcon}
546
+ </edgeless-tool-icon-button>
547
+ </div>
548
+ </div>
549
+ </edgeless-slide-menu>
550
+ `;
551
+ }
552
+ }
@@ -0,0 +1,154 @@
1
+ import { DefaultTool } from '@formicoidea/labre-core/blocks/surface';
2
+ import { EmptyTool } from '@formicoidea/labre-core/gfx/pointer';
3
+ import { EdgelessToolbarToolMixin } from '@formicoidea/labre-core/widgets/edgeless-toolbar';
4
+ import { SignalWatcher } from '@formicoidea/labre-core/global/lit';
5
+ import { css, html, LitElement } from 'lit';
6
+
7
+ import { wardleyToolbarIcon } from './icons';
8
+
9
+ /**
10
+ * Main toolbar button (colored proposal-B icon) that opens the Wardley toolbox
11
+ * sub-menu above the toolbar. Styled like the other senior tools: the tile fills
12
+ * the 96×64 slot, is anchored to the bottom so it "rises from below", and grows
13
+ * slightly on hover.
14
+ */
15
+ export class EdgelessWardleySeniorButton extends EdgelessToolbarToolMixin(
16
+ SignalWatcher(LitElement)
17
+ ) {
18
+ static override styles = css`
19
+ :host,
20
+ .wardley-button {
21
+ display: block;
22
+ width: 100%;
23
+ height: 100%;
24
+ }
25
+ /* Make this 96px button the containing block of the popup's clip wrapper
26
+ (it is appended to our shadow root) so the sub-menu anchors to THIS
27
+ button — not the whole toolbar — and can be centered over it. */
28
+ :host {
29
+ position: relative;
30
+ }
31
+ .wardley-root {
32
+ width: 100%;
33
+ height: 64px;
34
+ position: relative;
35
+ overflow: hidden;
36
+ cursor: pointer;
37
+ display: flex;
38
+ align-items: flex-end;
39
+ justify-content: center;
40
+ }
41
+ .wardley-card {
42
+ --y: -4px;
43
+ --s: 1;
44
+ position: absolute;
45
+ bottom: 0;
46
+ width: 54px;
47
+ height: 54px;
48
+ transform: translateY(var(--y)) scale(var(--s)); /* base */
49
+ translate: var(--active-x, 0) var(--active-y, 0); /* actif */
50
+ rotate: var(--active-r, -2deg);
51
+ scale: var(--active-s, 1);
52
+ transition: transform 0.3s ease, translate 0.3s ease,
53
+ rotate 0.3s ease, scale 0.3s ease;
54
+ }
55
+ .wardley-card svg {
56
+ display: block;
57
+ width: 100%;
58
+ height: 100%;
59
+ }
60
+ .wardley-root:hover .wardley-card {
61
+ --y: -10px;
62
+ --s: 1.07;
63
+ }
64
+ `;
65
+
66
+ override enableActiveBackground = true;
67
+
68
+ // `EmptyTool` is only a sentinel for the mixin's abstract `type`; we never
69
+ // activate it. The menu opens as a palette over the default (selection) tool
70
+ // so the canvas stays fully interactive while it is open — mirroring the
71
+ // native Note/Shape sub-menus.
72
+ override type = EmptyTool;
73
+
74
+ private _toggleMenu() {
75
+ // Toggle on popper presence (not tool-active state): the menu stays open on
76
+ // click-outside and only closes on re-click, another senior tool, or Escape.
77
+ if (this.popper) {
78
+ this.popper.dispose();
79
+ this.popper = null;
80
+ return;
81
+ }
82
+ this.setEdgelessTool(DefaultTool);
83
+ const menu = this.createPopper('edgeless-wardley-menu', this);
84
+ menu.element.edgeless = this.edgeless;
85
+
86
+ // Anchor the sub-menu to THIS button (the clip wrapper is now button-
87
+ // relative thanks to `:host{position:relative}`): make the menu an in-flow
88
+ // flex item sized to its content, centered over the button. Now that other
89
+ // senior tools (EDGY, Cynefin/Estuarine) sit to the right of Wardley, there
90
+ // is room on both sides, so the menu no longer needs to be pinned to the
91
+ // right edge. Native sub-menus are untouched.
92
+ const el = menu.element as HTMLElement;
93
+ const wrap = el.parentElement;
94
+ if (wrap) {
95
+ wrap.style.overflow = 'visible';
96
+ wrap.style.justifyContent = 'center';
97
+ }
98
+ Object.assign(el.style, {
99
+ position: 'static',
100
+ width: 'max-content',
101
+ maxWidth: 'calc(100vw - 16px)',
102
+ marginLeft: '0',
103
+ });
104
+
105
+ // The Wardley menu is wide (~13 items). After layout, right-align its right
106
+ // edge to the right edge of the rightmost senior tool (the right end of the
107
+ // senior toolbar), so it fills the space to the right instead of sitting
108
+ // centered with a gap. The menu then extends leftwards.
109
+ requestAnimationFrame(() => {
110
+ const rect = el.getBoundingClientRect();
111
+
112
+ // Right edge of the rightmost senior tool slot (scan across shadow roots).
113
+ let target = 0;
114
+ const seen = new Set<ShadowRoot>();
115
+ const scan = (root: ParentNode) => {
116
+ root.querySelectorAll('*').forEach(node => {
117
+ const cls = (node as HTMLElement).className;
118
+ if (
119
+ typeof cls === 'string' &&
120
+ cls.split(' ').includes('senior-tool-item')
121
+ ) {
122
+ const b = node.getBoundingClientRect();
123
+ if (b.width > 0) target = Math.max(target, b.right);
124
+ }
125
+ const sr = (node as HTMLElement).shadowRoot;
126
+ if (sr && !seen.has(sr)) {
127
+ seen.add(sr);
128
+ scan(sr);
129
+ }
130
+ });
131
+ };
132
+ scan(document);
133
+
134
+ if (target > 0) {
135
+ const dx = Math.round(target - rect.right);
136
+ if (dx) el.style.transform = `translateX(${dx}px)`;
137
+ }
138
+ });
139
+ }
140
+
141
+ override render() {
142
+ return html`<edgeless-toolbar-button
143
+ class="wardley-button"
144
+ .tooltip=${this.popper ? '' : 'Wardley map'}
145
+ .tooltipOffset=${4}
146
+ .active=${!!this.popper}
147
+ @click=${this._toggleMenu}
148
+ >
149
+ <div class="wardley-root">
150
+ <div class="wardley-card">${wardleyToolbarIcon}</div>
151
+ </div>
152
+ </edgeless-toolbar-button>`;
153
+ }
154
+ }