@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.
Files changed (61) hide show
  1. package/dist/actions.d.ts +179 -0
  2. package/dist/actions.js +375 -0
  3. package/dist/background.d.ts +77 -0
  4. package/dist/background.js +223 -0
  5. package/dist/commands.d.ts +4 -0
  6. package/dist/commands.js +221 -0
  7. package/dist/component.d.ts +192 -0
  8. package/dist/component.js +188 -0
  9. package/dist/consts.d.ts +331 -0
  10. package/dist/consts.js +384 -0
  11. package/dist/descriptor.d.ts +12 -0
  12. package/dist/descriptor.js +10 -0
  13. package/dist/effects.d.ts +9 -0
  14. package/dist/effects.js +6 -0
  15. package/dist/element-renderer.d.ts +18 -0
  16. package/dist/element-renderer.js +14 -0
  17. package/dist/element-view.d.ts +51 -0
  18. package/dist/element-view.js +146 -0
  19. package/dist/export.d.ts +184 -0
  20. package/dist/export.js +454 -0
  21. package/dist/index.d.ts +16 -0
  22. package/dist/index.js +52 -0
  23. package/dist/interchange.d.ts +74 -0
  24. package/dist/interchange.js +143 -0
  25. package/dist/legend.d.ts +37 -0
  26. package/dist/legend.js +123 -0
  27. package/dist/levels.d.ts +70 -0
  28. package/dist/levels.js +46 -0
  29. package/dist/morph.d.ts +89 -0
  30. package/dist/morph.js +229 -0
  31. package/dist/node/node-renderer.d.ts +6 -0
  32. package/dist/node/node-renderer.js +304 -0
  33. package/dist/node/node-view.d.ts +45 -0
  34. package/dist/node/node-view.js +80 -0
  35. package/dist/node/type-line-watcher.d.ts +70 -0
  36. package/dist/node/type-line-watcher.js +142 -0
  37. package/dist/presets.d.ts +84 -0
  38. package/dist/presets.js +149 -0
  39. package/dist/profiles.d.ts +2 -0
  40. package/dist/profiles.js +177 -0
  41. package/dist/roles.d.ts +116 -0
  42. package/dist/roles.js +303 -0
  43. package/dist/rules.d.ts +95 -0
  44. package/dist/rules.js +1261 -0
  45. package/dist/toolbar/c4-menu.d.ts +11 -0
  46. package/dist/toolbar/c4-menu.js +14 -0
  47. package/dist/toolbar/c4-senior-button.d.ts +19 -0
  48. package/dist/toolbar/c4-senior-button.js +23 -0
  49. package/dist/toolbar/config.d.ts +150 -0
  50. package/dist/toolbar/config.js +436 -0
  51. package/dist/toolbar/icons.d.ts +90 -0
  52. package/dist/toolbar/icons.js +157 -0
  53. package/dist/toolbar/senior-tool.d.ts +1 -0
  54. package/dist/toolbar/senior-tool.js +11 -0
  55. package/dist/translations.d.ts +18 -0
  56. package/dist/translations.js +42 -0
  57. package/dist/type-line.d.ts +175 -0
  58. package/dist/type-line.js +244 -0
  59. package/dist/view.d.ts +33 -0
  60. package/dist/view.js +148 -0
  61. 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
+ */