@formicoidea/labre-framework-c4 0.33.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.
- package/dist/actions.d.ts +179 -0
- package/dist/actions.js +375 -0
- package/dist/background.d.ts +77 -0
- package/dist/background.js +223 -0
- package/dist/commands.d.ts +4 -0
- package/dist/commands.js +221 -0
- package/dist/component.d.ts +192 -0
- package/dist/component.js +188 -0
- package/dist/consts.d.ts +331 -0
- package/dist/consts.js +384 -0
- package/dist/descriptor.d.ts +12 -0
- package/dist/descriptor.js +10 -0
- package/dist/effects.d.ts +9 -0
- package/dist/effects.js +6 -0
- package/dist/element-renderer.d.ts +18 -0
- package/dist/element-renderer.js +14 -0
- package/dist/element-view.d.ts +51 -0
- package/dist/element-view.js +146 -0
- package/dist/export.d.ts +184 -0
- package/dist/export.js +454 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +52 -0
- package/dist/interchange.d.ts +74 -0
- package/dist/interchange.js +143 -0
- package/dist/legend.d.ts +37 -0
- package/dist/legend.js +123 -0
- package/dist/levels.d.ts +70 -0
- package/dist/levels.js +46 -0
- package/dist/morph.d.ts +89 -0
- package/dist/morph.js +229 -0
- package/dist/node/node-renderer.d.ts +6 -0
- package/dist/node/node-renderer.js +304 -0
- package/dist/node/node-view.d.ts +45 -0
- package/dist/node/node-view.js +80 -0
- package/dist/node/type-line-watcher.d.ts +70 -0
- package/dist/node/type-line-watcher.js +142 -0
- package/dist/presets.d.ts +84 -0
- package/dist/presets.js +149 -0
- package/dist/profiles.d.ts +2 -0
- package/dist/profiles.js +177 -0
- package/dist/roles.d.ts +116 -0
- package/dist/roles.js +303 -0
- package/dist/rules.d.ts +95 -0
- package/dist/rules.js +1261 -0
- package/dist/toolbar/c4-menu.d.ts +11 -0
- package/dist/toolbar/c4-menu.js +14 -0
- package/dist/toolbar/c4-senior-button.d.ts +19 -0
- package/dist/toolbar/c4-senior-button.js +23 -0
- package/dist/toolbar/config.d.ts +150 -0
- package/dist/toolbar/config.js +436 -0
- package/dist/toolbar/icons.d.ts +90 -0
- package/dist/toolbar/icons.js +157 -0
- package/dist/toolbar/senior-tool.d.ts +1 -0
- package/dist/toolbar/senior-tool.js +11 -0
- package/dist/translations.d.ts +18 -0
- package/dist/translations.js +42 -0
- package/dist/type-line.d.ts +175 -0
- package/dist/type-line.js +244 -0
- package/dist/view.d.ts +33 -0
- package/dist/view.js +148 -0
- package/package.json +34 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { EmptyTool } from '@formicoidea/labre-core/gfx/pointer';
|
|
2
|
+
import { EdgelessCommandMenu } from '@formicoidea/labre-core/widgets/edgeless-toolbar';
|
|
3
|
+
/**
|
|
4
|
+
* The popover above the toolbar for the C4 toolbox. It declares nothing:
|
|
5
|
+
* {@link EdgelessCommandMenu} renders `c4Commands` for the `senior-menu`
|
|
6
|
+
* surface (`docs/adr/0008`).
|
|
7
|
+
*/
|
|
8
|
+
export declare class EdgelessC4Menu extends EdgelessCommandMenu {
|
|
9
|
+
protected owner: "c4";
|
|
10
|
+
type: typeof EmptyTool;
|
|
11
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { EmptyTool } from '@formicoidea/labre-core/gfx/pointer';
|
|
2
|
+
import { EdgelessCommandMenu } from '@formicoidea/labre-core/widgets/edgeless-toolbar';
|
|
3
|
+
/**
|
|
4
|
+
* The popover above the toolbar for the C4 toolbox. It declares nothing:
|
|
5
|
+
* {@link EdgelessCommandMenu} renders `c4Commands` for the `senior-menu`
|
|
6
|
+
* surface (`docs/adr/0008`).
|
|
7
|
+
*/
|
|
8
|
+
export class EdgelessC4Menu extends EdgelessCommandMenu {
|
|
9
|
+
constructor() {
|
|
10
|
+
super(...arguments);
|
|
11
|
+
this.owner = 'c4';
|
|
12
|
+
this.type = EmptyTool;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { DddSeniorButtonBase } from '@formicoidea/labre-ddd-shared';
|
|
2
|
+
/**
|
|
3
|
+
* Main toolbar button (the coloured C4 tile) that opens the C4 toolbox
|
|
4
|
+
* sub-menu above the toolbar.
|
|
5
|
+
*
|
|
6
|
+
* It reuses the shared senior-button base rather than restating the
|
|
7
|
+
* toggle/popper wiring a fifth time. The base lives in `ddd-shared` because the
|
|
8
|
+
* three DDD buttons were the first to need it and it was lifted out of them —
|
|
9
|
+
* the class itself knows nothing about DDD: it is a glyph, a tooltip key and a
|
|
10
|
+
* menu tag, which is the entirety of what a senior button is. Copying ninety
|
|
11
|
+
* lines of identical CSS and popper handling here to avoid the package name
|
|
12
|
+
* would be the worst of both.
|
|
13
|
+
*/
|
|
14
|
+
export declare class EdgelessC4SeniorButton extends DddSeniorButtonBase {
|
|
15
|
+
protected menuTag: "edgeless-c4-menu";
|
|
16
|
+
protected label: string;
|
|
17
|
+
protected labelKey: string;
|
|
18
|
+
protected icon: import("lit-html").TemplateResult<2>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { DddSeniorButtonBase } from '@formicoidea/labre-ddd-shared';
|
|
2
|
+
import { c4ToolbarIcon } from './icons.js';
|
|
3
|
+
/**
|
|
4
|
+
* Main toolbar button (the coloured C4 tile) that opens the C4 toolbox
|
|
5
|
+
* sub-menu above the toolbar.
|
|
6
|
+
*
|
|
7
|
+
* It reuses the shared senior-button base rather than restating the
|
|
8
|
+
* toggle/popper wiring a fifth time. The base lives in `ddd-shared` because the
|
|
9
|
+
* three DDD buttons were the first to need it and it was lifted out of them —
|
|
10
|
+
* the class itself knows nothing about DDD: it is a glyph, a tooltip key and a
|
|
11
|
+
* menu tag, which is the entirety of what a senior button is. Copying ninety
|
|
12
|
+
* lines of identical CSS and popper handling here to avoid the package name
|
|
13
|
+
* would be the worst of both.
|
|
14
|
+
*/
|
|
15
|
+
export class EdgelessC4SeniorButton extends DddSeniorButtonBase {
|
|
16
|
+
constructor() {
|
|
17
|
+
super(...arguments);
|
|
18
|
+
this.menuTag = 'edgeless-c4-menu';
|
|
19
|
+
this.label = 'C4 model';
|
|
20
|
+
this.labelKey = 'com.labre.framework.c4';
|
|
21
|
+
this.icon = c4ToolbarIcon;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { ActionPlacement, type ToolbarContext, type ToolbarModuleConfig } from '@formicoidea/labre-core/shared/services';
|
|
2
|
+
import { type TemplateResult } from 'lit';
|
|
3
|
+
/**
|
|
4
|
+
* The selected C4 board's contextual toolbar: the resize toggle, and the
|
|
5
|
+
* automatic legend of what is actually drawn on the board.
|
|
6
|
+
*
|
|
7
|
+
* ## The legend is a BUTTON, not a command — PO arbitration, 27/08/2026
|
|
8
|
+
*
|
|
9
|
+
* Every other C4 gesture is a registered `CommandDescriptor`, which is the
|
|
10
|
+
* bottleneck `docs/adr/0008` asks for: one behaviour, one availability rule, one
|
|
11
|
+
* telemetry emission, reachable from the sub-menu, the catalogue, the palette,
|
|
12
|
+
* Settings › Shortcuts and the agent. The legend is the arbitrated exception.
|
|
13
|
+
* The PO's call is that generating one belongs to a board you have SELECTED and
|
|
14
|
+
* to nothing else: it is not an artefact to pick off a palette, and an entry in
|
|
15
|
+
* a catalogue of things C4 draws would offer it to a user with no board in front
|
|
16
|
+
* of them. So there is no `c4.legend` command, this button is the only way to
|
|
17
|
+
* reach it, and the telemetry it owes is emitted by hand below.
|
|
18
|
+
*
|
|
19
|
+
* That is the same shape — and the same exception — the Context Map board makes
|
|
20
|
+
* (`ddd-context-map/src/toolbar/board-config.ts`), down to the payload, so the
|
|
21
|
+
* two frameworks' legends stay comparable on one dashboard. The cost is the one
|
|
22
|
+
* the bottleneck exists to avoid and is accepted knowingly: this `track()` call
|
|
23
|
+
* is a second emitter, and it is on whoever edits it to keep the wire values
|
|
24
|
+
* matching what `reportCommandTelemetry` would have sent.
|
|
25
|
+
*
|
|
26
|
+
* ## Two modules on one element, and why
|
|
27
|
+
*
|
|
28
|
+
* The resize toggle is registered ALWAYS-ON, because a stored board must stay
|
|
29
|
+
* usable with the C4 button switched off (`docs/adr/0009`). The legend button is
|
|
30
|
+
* a SECOND module on the same element, through the `custom:` flavour slot, and
|
|
31
|
+
* it is registered by the flag-gated half — which is exactly where it sat while
|
|
32
|
+
* it was a command, and the arbitration changed nothing about that. It is also
|
|
33
|
+
* the shape BPMN, Wardley and the Context Map already use to hang the Validation
|
|
34
|
+
* dropdown off a background whose base row is always-on: `renderToolbar`
|
|
35
|
+
* collects the entries of every module contributing to the element, so the user
|
|
36
|
+
* sees one row either way.
|
|
37
|
+
*
|
|
38
|
+
* There is no rename entry, and there is nothing missing: both C4 frames edit
|
|
39
|
+
* their one word in place on a double-click (`element-view.ts`), which is the
|
|
40
|
+
* gesture the primitive already gives them.
|
|
41
|
+
*
|
|
42
|
+
* ## The "⋮", and why the export sits in the ALWAYS-ON half
|
|
43
|
+
*
|
|
44
|
+
* `ActionPlacement.More` partitions an entry out of the row and into the
|
|
45
|
+
* overflow menu, and the entries it collects come from EVERY module contributing
|
|
46
|
+
* to this element — so a slice adds itself there without owning this file. The
|
|
47
|
+
* mermaid export is the first taker: it is the rarest thing anybody does to a
|
|
48
|
+
* board, and the `z.` id prefix is the sort key that keeps it last.
|
|
49
|
+
*
|
|
50
|
+
* It is declared HERE, in the always-on module, rather than beside the legend in
|
|
51
|
+
* the flag-gated one — which is where BPMN puts its own export
|
|
52
|
+
* (`bpmnPoolToolbarExtension` carries `bpmn.exportXml` and is registered by
|
|
53
|
+
* `BpmnRenderViewExtension`). The two entries look alike and are gated by
|
|
54
|
+
* different mechanisms, and that is the point: the legend button CALLS an action
|
|
55
|
+
* directly, so nothing but the module's registration can hide it, while this one
|
|
56
|
+
* asks the registry for a command that only the flag-gated half registers and
|
|
57
|
+
* hides itself when it is not there. With the C4 button off the row is the resize
|
|
58
|
+
* toggle alone either way, and nothing on it can be clicked into a no-op.
|
|
59
|
+
*
|
|
60
|
+
* Declaring it in the always-on module is what makes that guard the SINGLE thing
|
|
61
|
+
* deciding whether the export is offered — the same rule the palette, the
|
|
62
|
+
* catalogue and the agent read — instead of the guard plus a second registration
|
|
63
|
+
* that could one day disagree with it.
|
|
64
|
+
*/
|
|
65
|
+
export declare const c4BoardToolbarConfig: {
|
|
66
|
+
readonly actions: [{
|
|
67
|
+
readonly id: "a.toggle-resize";
|
|
68
|
+
readonly tooltip: "Enable / lock resizing";
|
|
69
|
+
readonly icon: TemplateResult<1>;
|
|
70
|
+
readonly active: (ctx: ToolbarContext) => boolean;
|
|
71
|
+
readonly run: (ctx: ToolbarContext) => void;
|
|
72
|
+
}, {
|
|
73
|
+
id: string;
|
|
74
|
+
placement: ActionPlacement;
|
|
75
|
+
when: (ctx: ToolbarContext) => boolean;
|
|
76
|
+
generate: (ctx: ToolbarContext) => {
|
|
77
|
+
icon: TemplateResult;
|
|
78
|
+
label: string;
|
|
79
|
+
run: (runCtx: ToolbarContext) => void;
|
|
80
|
+
};
|
|
81
|
+
}];
|
|
82
|
+
readonly when: (ctx: ToolbarContext) => boolean;
|
|
83
|
+
};
|
|
84
|
+
export declare const c4BoardToolbarExtension: import("@formicoidea/labre-core/store").ExtensionType;
|
|
85
|
+
/**
|
|
86
|
+
* The legend button — the flag-gated half of the row (see the note above).
|
|
87
|
+
*
|
|
88
|
+
* `b.` sorts it after the resize toggle, so the two modules render as the one
|
|
89
|
+
* row a user sees rather than in registration order.
|
|
90
|
+
*/
|
|
91
|
+
export declare const c4LegendToolbarConfig: {
|
|
92
|
+
readonly actions: [{
|
|
93
|
+
readonly id: "b.legend";
|
|
94
|
+
readonly tooltip: "Generate the legend (notation present)";
|
|
95
|
+
readonly icon: TemplateResult<1>;
|
|
96
|
+
readonly run: (ctx: ToolbarContext) => void;
|
|
97
|
+
}];
|
|
98
|
+
readonly when: (ctx: ToolbarContext) => boolean;
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* The board's flag-gated row, WHOLE: the legend button and the Validation
|
|
102
|
+
* dropdown, in one module.
|
|
103
|
+
*
|
|
104
|
+
* ## Why they cannot be two modules
|
|
105
|
+
*
|
|
106
|
+
* `renderToolbar` merges exactly four slots per element — `<flavour>`,
|
|
107
|
+
* `custom:<flavour>`, and the two `affine:surface:*` wildcards — and
|
|
108
|
+
* `ToolbarModuleExtension` binds by DI variant, so a second module claiming
|
|
109
|
+
* `custom:affine:surface:c4Board` throws `DuplicateServiceDefinitionError`
|
|
110
|
+
* before the editor finishes setting up. Two slots, and C4 has three things to
|
|
111
|
+
* put on a selected board: the resize toggle (always-on, `<flavour>`), the
|
|
112
|
+
* legend and the level of requirement.
|
|
113
|
+
*
|
|
114
|
+
* The last three — the legend, the LEVEL this board declares and the level of
|
|
115
|
+
* requirement — are gated by the same flag and appear together or not at all, so
|
|
116
|
+
* one module is the honest grouping rather than a workaround: `c4` off takes
|
|
117
|
+
* away the gesture that CREATES legend elements, the choice of which diagram
|
|
118
|
+
* this sheet is, and the choice of how hard to check it, and leaves the stored
|
|
119
|
+
* board its handles and everything already written on it.
|
|
120
|
+
*
|
|
121
|
+
* Sorting keeps the row readable across the merge — `b.legend` and `c.level`
|
|
122
|
+
* from the config above, `z.validation` from {@link validationToolbarConfig} —
|
|
123
|
+
* so the user sees resize, legend, the diagram's level, then the level of
|
|
124
|
+
* requirement, whatever order the modules were registered in.
|
|
125
|
+
*/
|
|
126
|
+
export declare const c4BoardToolingToolbarConfig: ToolbarModuleConfig;
|
|
127
|
+
export declare const c4BoardToolingToolbarExtension: import("@formicoidea/labre-core/store").ExtensionType;
|
|
128
|
+
/**
|
|
129
|
+
* The BOUNDARY claims no toolbar flavour at all, and that is a decision rather
|
|
130
|
+
* than an omission.
|
|
131
|
+
*
|
|
132
|
+
* Two of the fourteen rules — `c4.homeless-component` and
|
|
133
|
+
* `c4.person-in-boundary` — are framed against the boundary, so their findings
|
|
134
|
+
* are attributed to it. An earlier draft of this file concluded that the
|
|
135
|
+
* boundary therefore needed a picker of its own, or those two would sit at the
|
|
136
|
+
* default level whatever the author chose.
|
|
137
|
+
*
|
|
138
|
+
* The PO arbitrated the other way on 28/08/2026: **the board alone arbitrates
|
|
139
|
+
* the checklist.** One diagram, one level of requirement, one place to set it —
|
|
140
|
+
* a second picker on a frame drawn INSIDE the first is a way to make a board
|
|
141
|
+
* disagree with itself, and no reader could tell which answer the diagram was
|
|
142
|
+
* being held to.
|
|
143
|
+
*
|
|
144
|
+
* The engine carries the consequence rather than the UI working around it:
|
|
145
|
+
* `inheritChosenProfiles` (`packages/affine/blocks/surface/src/extensions/validation.ts`)
|
|
146
|
+
* makes a frame that names no profile inherit the innermost containing frame's
|
|
147
|
+
* choice, so a boundary drawn on a board set to Review checklist is itself on
|
|
148
|
+
* Review checklist and the two boundary-anchored rules harden with the rest.
|
|
149
|
+
* Nothing here has to know that; the absence of a module is the whole change.
|
|
150
|
+
*/
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import { EdgelessCRUDIdentifier, validationToolbarConfig, } from '@formicoidea/labre-core/blocks/surface';
|
|
2
|
+
import { dddLegendIcon } from '@formicoidea/labre-ddd-shared';
|
|
3
|
+
import { C4BoardElementModel } from '@formicoidea/labre-core/model';
|
|
4
|
+
import { ActionPlacement, TelemetryProvider, ToolbarModuleExtension, translateKey, } from '@formicoidea/labre-core/shared/services';
|
|
5
|
+
import { BlockFlavourIdentifier, getRegisteredCommands, runCommand, } from '@formicoidea/labre-core/std';
|
|
6
|
+
import { html, nothing } from 'lit';
|
|
7
|
+
import { createC4Legend } from '../actions.js';
|
|
8
|
+
import { C4_BOARD_LEVEL_MENU } from '../levels.js';
|
|
9
|
+
import { c4ExportMermaidIcon } from './icons.js';
|
|
10
|
+
const ResizeIcon = html `<svg
|
|
11
|
+
width="24"
|
|
12
|
+
height="24"
|
|
13
|
+
viewBox="0 0 24 24"
|
|
14
|
+
fill="none"
|
|
15
|
+
stroke="currentColor"
|
|
16
|
+
stroke-width="1.6"
|
|
17
|
+
stroke-linecap="round"
|
|
18
|
+
stroke-linejoin="round"
|
|
19
|
+
>
|
|
20
|
+
<path d="M9 5H5v4M15 19h4v-4" />
|
|
21
|
+
<path d="M5 5l6 6M19 19l-6-6" />
|
|
22
|
+
</svg>`;
|
|
23
|
+
/**
|
|
24
|
+
* The two glyphs the level picker needs, drawn inline rather than pulled from
|
|
25
|
+
* `@labre/affine-components` — a dependency this package does not have and
|
|
26
|
+
* which is not worth adding for two paths. The same call
|
|
27
|
+
* `validation-toolbar.ts` makes for its own tick, and the same call
|
|
28
|
+
* {@link ResizeIcon} above already makes.
|
|
29
|
+
*/
|
|
30
|
+
const ChevronDownIcon = html `<svg
|
|
31
|
+
width="16"
|
|
32
|
+
height="16"
|
|
33
|
+
viewBox="0 0 24 24"
|
|
34
|
+
fill="none"
|
|
35
|
+
stroke="currentColor"
|
|
36
|
+
stroke-width="2"
|
|
37
|
+
stroke-linecap="round"
|
|
38
|
+
stroke-linejoin="round"
|
|
39
|
+
>
|
|
40
|
+
<path d="M6 9l6 6 6-6" />
|
|
41
|
+
</svg>`;
|
|
42
|
+
const CheckIcon = html `<svg
|
|
43
|
+
width="20"
|
|
44
|
+
height="20"
|
|
45
|
+
viewBox="0 0 24 24"
|
|
46
|
+
fill="none"
|
|
47
|
+
stroke="currentColor"
|
|
48
|
+
stroke-width="2"
|
|
49
|
+
stroke-linecap="round"
|
|
50
|
+
stroke-linejoin="round"
|
|
51
|
+
>
|
|
52
|
+
<path d="M5 12.5l4.5 4.5L19 7.5" />
|
|
53
|
+
</svg>`;
|
|
54
|
+
const findCommand = (ctx, id) => getRegisteredCommands(ctx.std).find(candidate => candidate.id === id);
|
|
55
|
+
/**
|
|
56
|
+
* A "⋮" entry that INVOKES a registered command instead of restating what it
|
|
57
|
+
* does — the shape `docs/adr/0010` M3 introduced, and the reason the export has
|
|
58
|
+
* one behaviour, one availability rule and one telemetry emission whether it is
|
|
59
|
+
* reached from here, from the catalogue, from the palette, from
|
|
60
|
+
* Settings › Shortcuts or from the agent.
|
|
61
|
+
*
|
|
62
|
+
* Two things the widget imposes: a menu line is drawn from `label` (a tooltip on
|
|
63
|
+
* a line that is already words would be a second copy of them), and
|
|
64
|
+
* `placement: ActionPlacement.More` is what partitions it out of the row in the
|
|
65
|
+
* first place — `renderToolbar` splits on exactly that flag, and the entries it
|
|
66
|
+
* collects come from every module contributing to the element, so a framework
|
|
67
|
+
* can add to the "⋮" without owning it.
|
|
68
|
+
*
|
|
69
|
+
* `generate` rather than a static entry because the i18n seam needs `std`:
|
|
70
|
+
* `translateKey` is what reaches the host's catalogue, and a hard-coded English
|
|
71
|
+
* label would be the one wording a host could not override.
|
|
72
|
+
*
|
|
73
|
+
* Lifted from the BPMN pool's row, where `bpmn.exportXml` sits in exactly this
|
|
74
|
+
* position.
|
|
75
|
+
*/
|
|
76
|
+
function commandMoreAction(id, commandId, labelKey, labelFallback, icon) {
|
|
77
|
+
return {
|
|
78
|
+
id,
|
|
79
|
+
placement: ActionPlacement.More,
|
|
80
|
+
when: (ctx) => {
|
|
81
|
+
const command = findCommand(ctx, commandId);
|
|
82
|
+
return command !== undefined && (command.when?.(ctx.std) ?? true);
|
|
83
|
+
},
|
|
84
|
+
generate: (ctx) => ({
|
|
85
|
+
icon,
|
|
86
|
+
label: translateKey(ctx.std, labelKey, labelFallback),
|
|
87
|
+
run: (runCtx) => {
|
|
88
|
+
const command = findCommand(runCtx, commandId);
|
|
89
|
+
if (!command)
|
|
90
|
+
return;
|
|
91
|
+
// The same `source` the row's own entries report: the "⋮" is a
|
|
92
|
+
// degradation of the row, not a surface of its own, and the
|
|
93
|
+
// `ElementCreationSource` union deliberately names places rather than
|
|
94
|
+
// widths (`docs/adr/0008`).
|
|
95
|
+
runCommand(runCtx.std, command, {
|
|
96
|
+
surface: 'contextual-toolbar',
|
|
97
|
+
source: 'toolbar:general',
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
}),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The selected C4 board's contextual toolbar: the resize toggle, and the
|
|
105
|
+
* automatic legend of what is actually drawn on the board.
|
|
106
|
+
*
|
|
107
|
+
* ## The legend is a BUTTON, not a command — PO arbitration, 27/08/2026
|
|
108
|
+
*
|
|
109
|
+
* Every other C4 gesture is a registered `CommandDescriptor`, which is the
|
|
110
|
+
* bottleneck `docs/adr/0008` asks for: one behaviour, one availability rule, one
|
|
111
|
+
* telemetry emission, reachable from the sub-menu, the catalogue, the palette,
|
|
112
|
+
* Settings › Shortcuts and the agent. The legend is the arbitrated exception.
|
|
113
|
+
* The PO's call is that generating one belongs to a board you have SELECTED and
|
|
114
|
+
* to nothing else: it is not an artefact to pick off a palette, and an entry in
|
|
115
|
+
* a catalogue of things C4 draws would offer it to a user with no board in front
|
|
116
|
+
* of them. So there is no `c4.legend` command, this button is the only way to
|
|
117
|
+
* reach it, and the telemetry it owes is emitted by hand below.
|
|
118
|
+
*
|
|
119
|
+
* That is the same shape — and the same exception — the Context Map board makes
|
|
120
|
+
* (`ddd-context-map/src/toolbar/board-config.ts`), down to the payload, so the
|
|
121
|
+
* two frameworks' legends stay comparable on one dashboard. The cost is the one
|
|
122
|
+
* the bottleneck exists to avoid and is accepted knowingly: this `track()` call
|
|
123
|
+
* is a second emitter, and it is on whoever edits it to keep the wire values
|
|
124
|
+
* matching what `reportCommandTelemetry` would have sent.
|
|
125
|
+
*
|
|
126
|
+
* ## Two modules on one element, and why
|
|
127
|
+
*
|
|
128
|
+
* The resize toggle is registered ALWAYS-ON, because a stored board must stay
|
|
129
|
+
* usable with the C4 button switched off (`docs/adr/0009`). The legend button is
|
|
130
|
+
* a SECOND module on the same element, through the `custom:` flavour slot, and
|
|
131
|
+
* it is registered by the flag-gated half — which is exactly where it sat while
|
|
132
|
+
* it was a command, and the arbitration changed nothing about that. It is also
|
|
133
|
+
* the shape BPMN, Wardley and the Context Map already use to hang the Validation
|
|
134
|
+
* dropdown off a background whose base row is always-on: `renderToolbar`
|
|
135
|
+
* collects the entries of every module contributing to the element, so the user
|
|
136
|
+
* sees one row either way.
|
|
137
|
+
*
|
|
138
|
+
* There is no rename entry, and there is nothing missing: both C4 frames edit
|
|
139
|
+
* their one word in place on a double-click (`element-view.ts`), which is the
|
|
140
|
+
* gesture the primitive already gives them.
|
|
141
|
+
*
|
|
142
|
+
* ## The "⋮", and why the export sits in the ALWAYS-ON half
|
|
143
|
+
*
|
|
144
|
+
* `ActionPlacement.More` partitions an entry out of the row and into the
|
|
145
|
+
* overflow menu, and the entries it collects come from EVERY module contributing
|
|
146
|
+
* to this element — so a slice adds itself there without owning this file. The
|
|
147
|
+
* mermaid export is the first taker: it is the rarest thing anybody does to a
|
|
148
|
+
* board, and the `z.` id prefix is the sort key that keeps it last.
|
|
149
|
+
*
|
|
150
|
+
* It is declared HERE, in the always-on module, rather than beside the legend in
|
|
151
|
+
* the flag-gated one — which is where BPMN puts its own export
|
|
152
|
+
* (`bpmnPoolToolbarExtension` carries `bpmn.exportXml` and is registered by
|
|
153
|
+
* `BpmnRenderViewExtension`). The two entries look alike and are gated by
|
|
154
|
+
* different mechanisms, and that is the point: the legend button CALLS an action
|
|
155
|
+
* directly, so nothing but the module's registration can hide it, while this one
|
|
156
|
+
* asks the registry for a command that only the flag-gated half registers and
|
|
157
|
+
* hides itself when it is not there. With the C4 button off the row is the resize
|
|
158
|
+
* toggle alone either way, and nothing on it can be clicked into a no-op.
|
|
159
|
+
*
|
|
160
|
+
* Declaring it in the always-on module is what makes that guard the SINGLE thing
|
|
161
|
+
* deciding whether the export is offered — the same rule the palette, the
|
|
162
|
+
* catalogue and the agent read — instead of the guard plus a second registration
|
|
163
|
+
* that could one day disagree with it.
|
|
164
|
+
*/
|
|
165
|
+
export const c4BoardToolbarConfig = {
|
|
166
|
+
actions: [
|
|
167
|
+
{
|
|
168
|
+
id: 'a.toggle-resize',
|
|
169
|
+
tooltip: 'Enable / lock resizing',
|
|
170
|
+
icon: ResizeIcon,
|
|
171
|
+
active(ctx) {
|
|
172
|
+
const models = ctx.getSurfaceModelsByType(C4BoardElementModel);
|
|
173
|
+
return models.length > 0 && models.every(model => model.resizeEnabled);
|
|
174
|
+
},
|
|
175
|
+
run(ctx) {
|
|
176
|
+
const models = ctx.getSurfaceModelsByType(C4BoardElementModel);
|
|
177
|
+
if (!models.length)
|
|
178
|
+
return;
|
|
179
|
+
const enable = !models.every(model => model.resizeEnabled);
|
|
180
|
+
ctx.std.store.captureSync();
|
|
181
|
+
const crud = ctx.std.get(EdgelessCRUDIdentifier);
|
|
182
|
+
for (const model of models) {
|
|
183
|
+
crud.updateElement(model.id, { resizeEnabled: enable });
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
// …and, in the "⋮", the one thing you do to a finished diagram rather than
|
|
188
|
+
// to the board it is drawn on: take it away as a file.
|
|
189
|
+
commandMoreAction('z.export-mermaid', 'c4.exportMermaid', 'com.labre.commands.c4.exportMermaid', 'Export as mermaid', c4ExportMermaidIcon),
|
|
190
|
+
],
|
|
191
|
+
when: (ctx) => ctx.getSurfaceModelsByType(C4BoardElementModel).length > 0,
|
|
192
|
+
};
|
|
193
|
+
export const c4BoardToolbarExtension = ToolbarModuleExtension({
|
|
194
|
+
id: BlockFlavourIdentifier('affine:surface:c4Board'),
|
|
195
|
+
config: c4BoardToolbarConfig,
|
|
196
|
+
});
|
|
197
|
+
/**
|
|
198
|
+
* The board the level picker is about, or `null`.
|
|
199
|
+
*
|
|
200
|
+
* ONE board, deliberately: a level is one statement about one sheet, and a
|
|
201
|
+
* selection spanning two boards has no honest current value to show — the same
|
|
202
|
+
* call the generic Validation dropdown makes about the profile it offers.
|
|
203
|
+
*/
|
|
204
|
+
function selectedBoard(ctx) {
|
|
205
|
+
const models = ctx.getSurfaceModels();
|
|
206
|
+
if (models.length !== 1)
|
|
207
|
+
return null;
|
|
208
|
+
const [model] = models;
|
|
209
|
+
return model instanceof C4BoardElementModel ? model : null;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Put the board on `option`, and report it.
|
|
213
|
+
*
|
|
214
|
+
* `captureSync` first, so one click is one undo — the rule every write on this
|
|
215
|
+
* toolbar follows. Choosing **Free sketch** CLEARS the field rather than writing
|
|
216
|
+
* a fourth value, which is the very move `ValidationManager.setProfile` makes
|
|
217
|
+
* for the default profile and for its reason: the default must leave a document
|
|
218
|
+
* byte-identical to one drawn before the field existed.
|
|
219
|
+
*
|
|
220
|
+
* A choice that changes nothing writes nothing and reports nothing — an undo
|
|
221
|
+
* checkpoint for a no-op is a click the user has to press twice to get back
|
|
222
|
+
* past.
|
|
223
|
+
*/
|
|
224
|
+
function pickLevel(ctx, option) {
|
|
225
|
+
const board = selectedBoard(ctx);
|
|
226
|
+
if (!board)
|
|
227
|
+
return;
|
|
228
|
+
const previous = board.level;
|
|
229
|
+
if (previous === option.level)
|
|
230
|
+
return;
|
|
231
|
+
ctx.std.store.captureSync();
|
|
232
|
+
if (option.level === undefined)
|
|
233
|
+
board.clearField('level');
|
|
234
|
+
else
|
|
235
|
+
board.level = option.level;
|
|
236
|
+
ctx.std.getOptional(TelemetryProvider)?.track('FrameworkViewLevelSet', {
|
|
237
|
+
page: 'whiteboard editor',
|
|
238
|
+
segment: 'element toolbar',
|
|
239
|
+
module: 'c4 toolbar',
|
|
240
|
+
control: 'level',
|
|
241
|
+
framework: 'c4',
|
|
242
|
+
// The absence is a value the dashboard needs as much as the three others:
|
|
243
|
+
// "put back to a free sketch" is the gesture that says the author changed
|
|
244
|
+
// their mind about what the sheet is.
|
|
245
|
+
level: option.level ?? 'none',
|
|
246
|
+
...(previous !== undefined ? { previousLevel: previous } : {}),
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* The level picker: which of C4's three diagrams this board draws.
|
|
251
|
+
*
|
|
252
|
+
* ## Why it is a DECLARED fact and not a rename
|
|
253
|
+
*
|
|
254
|
+
* A board's title is free text and always was — "Payments", "Internet Banking",
|
|
255
|
+
* whatever the author writes on it — and nothing in it says which of the three
|
|
256
|
+
* sheets it is. The level is a second, small, closed statement sitting beside
|
|
257
|
+
* the title, and picking one leaves the words alone: the author keeps their
|
|
258
|
+
* name, the rules get a fact they can read (`rules.ts`, C15 and C16).
|
|
259
|
+
*
|
|
260
|
+
* ## Why it is flag-gated tooling
|
|
261
|
+
*
|
|
262
|
+
* Declaring a level is deciding how the diagram is to be READ and judged, which
|
|
263
|
+
* is exactly what `docs/adr/0009` calls tooling: with the `c4` flag off, a board
|
|
264
|
+
* already carrying a level keeps it written, keeps painting and simply stops
|
|
265
|
+
* being offered the choice — and stops being checked, because the rules go with
|
|
266
|
+
* the same flag. Nothing a stored document needs to load or paint is behind it.
|
|
267
|
+
*
|
|
268
|
+
* ## Shape
|
|
269
|
+
*
|
|
270
|
+
* A dropdown, like the Validation entry it sits beside, and for the same reason:
|
|
271
|
+
* four mutually exclusive options with a current value are a menu, not four
|
|
272
|
+
* buttons competing for a toolbar's width. The trigger NAMES the level in force,
|
|
273
|
+
* so a reader of the row knows what the sheet claims without opening anything.
|
|
274
|
+
*/
|
|
275
|
+
const levelPickerAction = {
|
|
276
|
+
// Between the legend (`b.`) and the generic Validation dropdown (`z.`): the
|
|
277
|
+
// level is a statement about the sheet, read before the level of requirement
|
|
278
|
+
// applied to it.
|
|
279
|
+
id: 'c.level',
|
|
280
|
+
when: (ctx) => selectedBoard(ctx) !== null,
|
|
281
|
+
content(ctx) {
|
|
282
|
+
const board = selectedBoard(ctx);
|
|
283
|
+
if (!board)
|
|
284
|
+
return null;
|
|
285
|
+
const menuLabel = translateKey(ctx.std, C4_BOARD_LEVEL_MENU.labelKey, C4_BOARD_LEVEL_MENU.labelFallback);
|
|
286
|
+
const wordsFor = (option) => translateKey(ctx.std, option.labelKey, option.labelFallback);
|
|
287
|
+
const current = C4_BOARD_LEVEL_MENU.options.find(option => option.level === board.level) ??
|
|
288
|
+
// A board carrying a level this build does not know — a peer on a newer
|
|
289
|
+
// version, an import — names it rather than silently reading as a sketch.
|
|
290
|
+
undefined;
|
|
291
|
+
const options = C4_BOARD_LEVEL_MENU.options.map(option => {
|
|
292
|
+
const selected = option.level === board.level;
|
|
293
|
+
return html `<editor-menu-action
|
|
294
|
+
data-testid="c4-level-option"
|
|
295
|
+
data-level=${option.level ?? 'none'}
|
|
296
|
+
data-selected=${selected ? 'true' : nothing}
|
|
297
|
+
aria-label=${wordsFor(option)}
|
|
298
|
+
aria-pressed=${selected}
|
|
299
|
+
@click=${() => pickLevel(ctx, option)}
|
|
300
|
+
>
|
|
301
|
+
${selected ? CheckIcon : html `<span style="width: 20px;"></span>`}
|
|
302
|
+
<span class="label">${wordsFor(option)}</span>
|
|
303
|
+
</editor-menu-action>`;
|
|
304
|
+
});
|
|
305
|
+
return html `<editor-menu-button
|
|
306
|
+
data-testid="c4-level-entry"
|
|
307
|
+
.contentPadding=${'8px'}
|
|
308
|
+
.button=${html `
|
|
309
|
+
<editor-icon-button
|
|
310
|
+
data-testid="c4-level-button"
|
|
311
|
+
aria-label=${menuLabel}
|
|
312
|
+
.tooltip=${menuLabel}
|
|
313
|
+
.justify=${'space-between'}
|
|
314
|
+
.labelHeight=${'20px'}
|
|
315
|
+
>
|
|
316
|
+
<span class="label"
|
|
317
|
+
>${current === undefined ? menuLabel : wordsFor(current)}</span
|
|
318
|
+
>
|
|
319
|
+
${ChevronDownIcon}
|
|
320
|
+
</editor-icon-button>
|
|
321
|
+
`}
|
|
322
|
+
>
|
|
323
|
+
<div
|
|
324
|
+
data-testid="c4-level-menu"
|
|
325
|
+
data-orientation="vertical"
|
|
326
|
+
data-size="large"
|
|
327
|
+
>
|
|
328
|
+
<div
|
|
329
|
+
role="group"
|
|
330
|
+
aria-label=${menuLabel}
|
|
331
|
+
style="display: flex; flex-direction: column;"
|
|
332
|
+
>
|
|
333
|
+
${options}
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
</editor-menu-button>`;
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
/**
|
|
340
|
+
* The legend button — the flag-gated half of the row (see the note above).
|
|
341
|
+
*
|
|
342
|
+
* `b.` sorts it after the resize toggle, so the two modules render as the one
|
|
343
|
+
* row a user sees rather than in registration order.
|
|
344
|
+
*/
|
|
345
|
+
export const c4LegendToolbarConfig = {
|
|
346
|
+
actions: [
|
|
347
|
+
{
|
|
348
|
+
id: 'b.legend',
|
|
349
|
+
tooltip: 'Generate the legend (notation present)',
|
|
350
|
+
icon: dddLegendIcon,
|
|
351
|
+
run(ctx) {
|
|
352
|
+
createC4Legend(ctx.std);
|
|
353
|
+
ctx.std
|
|
354
|
+
.getOptional(TelemetryProvider)
|
|
355
|
+
?.track('FrameworkLegendCreated', {
|
|
356
|
+
// The WIRE values, and they are the ones `reportCommandTelemetry`
|
|
357
|
+
// would have sent for a `kind: 'legend'` command — `framework` from
|
|
358
|
+
// the descriptor's `telemetryKey`, `element: 'legend'` as Wardley
|
|
359
|
+
// and the Context Map both emit, so the three are one metric.
|
|
360
|
+
framework: 'c4',
|
|
361
|
+
element: 'legend',
|
|
362
|
+
page: 'whiteboard editor',
|
|
363
|
+
segment: 'element toolbar',
|
|
364
|
+
module: 'c4 toolbar',
|
|
365
|
+
});
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
],
|
|
369
|
+
when: (ctx) => ctx.getSurfaceModelsByType(C4BoardElementModel).length > 0,
|
|
370
|
+
};
|
|
371
|
+
/**
|
|
372
|
+
* The board's flag-gated row, WHOLE: the legend button and the Validation
|
|
373
|
+
* dropdown, in one module.
|
|
374
|
+
*
|
|
375
|
+
* ## Why they cannot be two modules
|
|
376
|
+
*
|
|
377
|
+
* `renderToolbar` merges exactly four slots per element — `<flavour>`,
|
|
378
|
+
* `custom:<flavour>`, and the two `affine:surface:*` wildcards — and
|
|
379
|
+
* `ToolbarModuleExtension` binds by DI variant, so a second module claiming
|
|
380
|
+
* `custom:affine:surface:c4Board` throws `DuplicateServiceDefinitionError`
|
|
381
|
+
* before the editor finishes setting up. Two slots, and C4 has three things to
|
|
382
|
+
* put on a selected board: the resize toggle (always-on, `<flavour>`), the
|
|
383
|
+
* legend and the level of requirement.
|
|
384
|
+
*
|
|
385
|
+
* The last three — the legend, the LEVEL this board declares and the level of
|
|
386
|
+
* requirement — are gated by the same flag and appear together or not at all, so
|
|
387
|
+
* one module is the honest grouping rather than a workaround: `c4` off takes
|
|
388
|
+
* away the gesture that CREATES legend elements, the choice of which diagram
|
|
389
|
+
* this sheet is, and the choice of how hard to check it, and leaves the stored
|
|
390
|
+
* board its handles and everything already written on it.
|
|
391
|
+
*
|
|
392
|
+
* Sorting keeps the row readable across the merge — `b.legend` and `c.level`
|
|
393
|
+
* from the config above, `z.validation` from {@link validationToolbarConfig} —
|
|
394
|
+
* so the user sees resize, legend, the diagram's level, then the level of
|
|
395
|
+
* requirement, whatever order the modules were registered in.
|
|
396
|
+
*/
|
|
397
|
+
export const c4BoardToolingToolbarConfig = {
|
|
398
|
+
actions: [
|
|
399
|
+
...c4LegendToolbarConfig.actions,
|
|
400
|
+
// Which of the three C4 diagrams this sheet draws — a fact about the board,
|
|
401
|
+
// written on the board, read by two of the sixteen rules.
|
|
402
|
+
levelPickerAction,
|
|
403
|
+
// The generic dropdown, not a C4 variant of it: the config names no
|
|
404
|
+
// framework — it reads the registered rules and profiles — so this is the
|
|
405
|
+
// very same object wardley, bpmn and the context map register.
|
|
406
|
+
...validationToolbarConfig.actions,
|
|
407
|
+
],
|
|
408
|
+
when: c4LegendToolbarConfig.when,
|
|
409
|
+
};
|
|
410
|
+
export const c4BoardToolingToolbarExtension = ToolbarModuleExtension({
|
|
411
|
+
id: BlockFlavourIdentifier('custom:affine:surface:c4Board'),
|
|
412
|
+
config: c4BoardToolingToolbarConfig,
|
|
413
|
+
});
|
|
414
|
+
/**
|
|
415
|
+
* The BOUNDARY claims no toolbar flavour at all, and that is a decision rather
|
|
416
|
+
* than an omission.
|
|
417
|
+
*
|
|
418
|
+
* Two of the fourteen rules — `c4.homeless-component` and
|
|
419
|
+
* `c4.person-in-boundary` — are framed against the boundary, so their findings
|
|
420
|
+
* are attributed to it. An earlier draft of this file concluded that the
|
|
421
|
+
* boundary therefore needed a picker of its own, or those two would sit at the
|
|
422
|
+
* default level whatever the author chose.
|
|
423
|
+
*
|
|
424
|
+
* The PO arbitrated the other way on 28/08/2026: **the board alone arbitrates
|
|
425
|
+
* the checklist.** One diagram, one level of requirement, one place to set it —
|
|
426
|
+
* a second picker on a frame drawn INSIDE the first is a way to make a board
|
|
427
|
+
* disagree with itself, and no reader could tell which answer the diagram was
|
|
428
|
+
* being held to.
|
|
429
|
+
*
|
|
430
|
+
* The engine carries the consequence rather than the UI working around it:
|
|
431
|
+
* `inheritChosenProfiles` (`packages/affine/blocks/surface/src/extensions/validation.ts`)
|
|
432
|
+
* makes a frame that names no profile inherit the innermost containing frame's
|
|
433
|
+
* choice, so a boundary drawn on a board set to Review checklist is itself on
|
|
434
|
+
* Review checklist and the two boundary-anchored rules harden with the rest.
|
|
435
|
+
* Nothing here has to know that; the absence of a module is the whole change.
|
|
436
|
+
*/
|