@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
package/dist/morph.js ADDED
@@ -0,0 +1,229 @@
1
+ import { morphLabel, } from '@formicoidea/labre-core/blocks/surface';
2
+ import { C4NodeElementModel, GroupElementModel, TextElementModel, } from '@formicoidea/labre-core/model';
3
+ import { c4CommandIcons, c4Commands } from './commands.js';
4
+ import { NODE_LABEL } from './consts.js';
5
+ import { c4MorphClears, c4MorphProps } from './presets.js';
6
+ import { C4_ROLE, C4_ROLE_OF_KIND } from './roles.js';
7
+ import { c4MorphedTypeLine } from './type-line.js';
8
+ /**
9
+ * What a C4 artefact may BECOME — the declaration behind the "Change type"
10
+ * dropdown on a selected component's contextual toolbar.
11
+ *
12
+ * ## Families, and the one thing that makes them safe here
13
+ *
14
+ * A person and an external person are the same actor, said with a different
15
+ * ownership; a container, a database, a mobile app and a browser app are the
16
+ * same level of the model, drawn with different silhouettes. Realising halfway
17
+ * through a container diagram that the box should have been the cylinder is
18
+ * modelling, not a mistake — and the only way through it today is delete,
19
+ * re-draw, re-connect, and retype three tiers of words.
20
+ *
21
+ * The three families below are DATA. Nothing derives them, and the role tree
22
+ * would have been the wrong source in the same way it is for BPMN: `roleIsA`
23
+ * makes `c4:database` a `c4:container`, so a derivation would reach in one
24
+ * direction and not the other, and it says nothing at all about
25
+ * `person`/`person-ext`, which are ONE role and still two artefacts.
26
+ *
27
+ * What makes these three families cheap is a property they happen to have and
28
+ * a later one may not: **every member of a family lays its words out
29
+ * identically**. `c4TierBoxes` differs on exactly one thing — the person's head
30
+ * clearance, which both people share — and `NODE_SIZE` on exactly one, the
31
+ * person's taller silhouette, which both people also share. So a morph inside
32
+ * a family needs no re-layout at all: the three tiers stay where they are, the
33
+ * group's derived box stays what it was, and the geometry a morph promises not
34
+ * to touch is genuinely untouched. `component` is deliberately in NO family
35
+ * partly for that reason and mostly for a better one: a component is a part of
36
+ * a container, not another drawing of one, and offering the swap would invite
37
+ * a diagram that mixes two levels of the model — the one thing C4 exists to
38
+ * stop. The two boundaries are frames rather than artefacts and are excluded
39
+ * for the reason `roles.ts` gives.
40
+ */
41
+ export const C4_MORPH_FAMILIES = [
42
+ // Declaration order is menu order, and each family opens on the INTERNAL,
43
+ // undecorated member: the plain artefact is the honest first draft and the
44
+ // variant is the refinement — the same call `commands.ts` makes about the
45
+ // order of the senior sub-menu.
46
+ ['person', 'person-ext'],
47
+ ['system', 'system-ext'],
48
+ ['container', 'database', 'mobile', 'browser'],
49
+ ];
50
+ /**
51
+ * The creation command that draws each kind, keyed BY that kind.
52
+ *
53
+ * Derived from `telemetry.element`, which is where the kind is already written
54
+ * down (`node:database`) and is documented as a historical value that must not
55
+ * be renamed. Deriving rather than restating is what stops a second table of
56
+ * labels and icons drifting from the one the sub-menu and the catalogue read.
57
+ */
58
+ const NODE_COMMANDS = new Map(c4Commands.flatMap(command => {
59
+ const element = command.telemetry?.element;
60
+ return element?.startsWith('node:')
61
+ ? [[element.slice('node:'.length), command]]
62
+ : [];
63
+ }));
64
+ /** A kind's wording: the creation command's own key and English. */
65
+ function labelOf(kind) {
66
+ const command = NODE_COMMANDS.get(kind);
67
+ return {
68
+ key: command?.labelKey,
69
+ fallback: command?.labelFallback ?? kind,
70
+ };
71
+ }
72
+ /** A kind's icon: the creation command's own, reused rather than redrawn. */
73
+ function iconOf(kind) {
74
+ const iconKey = NODE_COMMANDS.get(kind)?.iconKey;
75
+ return (iconKey && c4CommandIcons[iconKey]) || c4CommandIcons['c4.container'];
76
+ }
77
+ /* ── Resolving the composite ───────────────────────────────────────────── */
78
+ /**
79
+ * The `c4Node` shape a selected GROUP is the component of — `undefined` when
80
+ * the group is not one.
81
+ *
82
+ * A C4 component is a native `group` holding the shape and its three lines of
83
+ * words (PO recette, 28/08/2026), so what a click selects is the group and what
84
+ * carries `kind` is the shape. This is the whole of the indirection, and it is
85
+ * also the gate: a plain group somebody lassoed round three rectangles, a
86
+ * Wardley component (a group of a circle and its label) and a group of two C4
87
+ * components all answer `undefined`, so none of them is ever offered the menu.
88
+ *
89
+ * TWO shapes is a refusal rather than a first-wins pick. A group holding two
90
+ * components is a group somebody made of two components — morphing "it" would
91
+ * mean picking one of them by document order, and the honest answer to an
92
+ * ambiguous selection is the same one the generic module gives to a mixed
93
+ * family: nothing.
94
+ */
95
+ export function c4NodeOfComponent(model) {
96
+ if (!(model instanceof GroupElementModel))
97
+ return undefined;
98
+ let found;
99
+ for (const child of model.childElements) {
100
+ if (!(child instanceof C4NodeElementModel))
101
+ continue;
102
+ if (found)
103
+ return undefined;
104
+ found = child;
105
+ }
106
+ return found;
107
+ }
108
+ /** One tier of a component, by the role it carries. */
109
+ function tierOf(model, role) {
110
+ if (!(model instanceof GroupElementModel))
111
+ return undefined;
112
+ return model.childElements.find((child) => child instanceof TextElementModel && child.role === role);
113
+ }
114
+ /**
115
+ * Write one tier, in place, if the decision says to.
116
+ *
117
+ * `decide` reads the tier as it is STORED and answers with the whole line to
118
+ * write, or `null` for "this text is the author's, leave it". The comparison
119
+ * that decides whether anything happens is against the TRIMMED text — the same
120
+ * string `decide` was given — because a decision made on `'[Container] '`
121
+ * returns `'[Person]'`, and comparing that against the untrimmed original
122
+ * would call a no-op a change and spend a transaction rewriting the padding
123
+ * away.
124
+ *
125
+ * In place, in one transaction, and inside the caller's `captureSync`: the
126
+ * `Y.Text` instance is what any bound editor holds, so it is mutated rather
127
+ * than replaced, and the rewrite is part of the same single ctrl+z as the kind
128
+ * that made it necessary.
129
+ */
130
+ function rewriteTier(model, role, decide) {
131
+ const tier = tierOf(model, role);
132
+ if (!tier || tier.isLocked())
133
+ return;
134
+ const text = tier.text.toString().trim();
135
+ const next = decide(text);
136
+ if (next === null || next === text)
137
+ return;
138
+ tier.surface.store.transact(() => {
139
+ tier.text.delete(0, tier.text.length);
140
+ tier.text.insert(0, next);
141
+ });
142
+ }
143
+ /**
144
+ * Keep the component's own words saying what the shape now is — the
145
+ * `afterMorph` half of the C4 spec.
146
+ *
147
+ * ## The two tiers a morph may touch, and the one rule they share
148
+ *
149
+ * The rule is the same for both and it is deliberately timid: rewrite ONLY
150
+ * what the notation itself wrote, and never what an author typed. A morph is
151
+ * not allowed to take away words somebody put on the picture.
152
+ *
153
+ * - **The type line.** `[Container: Java]` is two statements in one: the
154
+ * bracketed word is derived from `kind`, the technology is the author's.
155
+ * The watcher that keeps the halves apart (`node/type-line-watcher.ts`) only
156
+ * runs when somebody finishes TYPING into the tier, so a morph — which
157
+ * changes the kind with nobody touching the words — has to make the same
158
+ * statement for itself. {@link c4MorphedTypeLine} owns that decision.
159
+ * - **The title.** This is the one a user actually sees go wrong, and it is
160
+ * the one the type line does NOT cover: `NODE_LABEL` differs inside the
161
+ * container family (`Container` / `Database` / `Mobile app` / `Web app`), so
162
+ * an untouched component morphed from a container to a database keeps a
163
+ * cylinder captioned "Container" — a picture flatly contradicting its own
164
+ * name. The rule is placeholder→placeholder and nothing else: the title is
165
+ * rewritten only when it is EXACTLY the source kind's own prompt, which is
166
+ * what a component nobody has named still carries. A component called
167
+ * "Customer database" keeps that name whatever it becomes, which is the PO's
168
+ * "the label is intact" for every title that is real content.
169
+ */
170
+ function rewriteTiers(model, from, to) {
171
+ rewriteTier(model, C4_ROLE['type-line'], text => c4MorphedTypeLine(from, to, text));
172
+ rewriteTier(model, C4_ROLE.title, text => c4MorphedTitle(from, to, text));
173
+ }
174
+ /**
175
+ * The name a component should carry once its shape has morphed — or `null`
176
+ * when the name is the AUTHOR's and must not be touched.
177
+ *
178
+ * Exactly one case rewrites: the title is the source kind's own creation
179
+ * prompt, letter for letter, which is what a component nobody has named still
180
+ * says. Everything else — a name typed over it, a prompt with a word added, a
181
+ * cleared title, another kind's prompt — is content, and content survives a
182
+ * morph untouched.
183
+ *
184
+ * This is the tier the type line does NOT cover, and the only one where a user
185
+ * sees the contradiction: {@link NODE_LABEL} differs across the container
186
+ * family where `C4_TYPE_WORD` does not, so a fresh container morphed to a
187
+ * database would otherwise be a cylinder captioned "Container".
188
+ *
189
+ * Pure and total over every string, like its sibling in `type-line.ts`: the
190
+ * input is a canvas text element somebody may have typed anything into.
191
+ */
192
+ export function c4MorphedTitle(from, to, rawText) {
193
+ return (rawText ?? '').trim() === NODE_LABEL[from] ? NODE_LABEL[to] : null;
194
+ }
195
+ /**
196
+ * C4's morph declaration, handed to the generic `morphToolbarConfig`.
197
+ *
198
+ * `modelType` is the native `GroupElementModel` and not `C4NodeElementModel`,
199
+ * because the group is what a click on a component selects. Everything that
200
+ * makes that safe is in {@link c4NodeOfComponent}: the toolbar's own
201
+ * homogeneity test only proves that every selected element is A group, and the
202
+ * resolution proves that every one of them is one of OURS.
203
+ *
204
+ * `propsOf` is the shipped creation builder minus `type` / `xywh` / `text`
205
+ * ({@link c4MorphProps}), so the palette and the morph cannot disagree about
206
+ * what a database looks like; `clearOf` is empty on today's table and derived
207
+ * rather than assumed so (`presets.ts`).
208
+ */
209
+ export const C4_MORPH_SPEC = {
210
+ framework: 'c4',
211
+ families: C4_MORPH_FAMILIES,
212
+ modelType: GroupElementModel,
213
+ resolveTarget: c4NodeOfComponent,
214
+ kindOf: (model) => model instanceof C4NodeElementModel ? model.kind : undefined,
215
+ // The SHAPE's role, which is the only role a C4 artefact has: the group
216
+ // carries none by design, and the three tiers carry roles of their own
217
+ // (`c4:title`, `c4:type-line`, `c4:description`) that say which LINE they
218
+ // are and not what kind of box they belong to — so a morph never rewrites
219
+ // them. Note that this table collapses: `person` and `person-ext` are both
220
+ // `c4:person`, so a morph inside those two families reports the same role
221
+ // twice, which is the truth — what changed is the ownership, not the meaning.
222
+ roleOf: kind => C4_ROLE_OF_KIND[kind],
223
+ propsOf: c4MorphProps,
224
+ clearOf: c4MorphClears,
225
+ afterMorph: rewriteTiers,
226
+ labelOf,
227
+ iconOf,
228
+ label: morphLabel('com.labre.morph.toolbar.label', 'Change type'),
229
+ };
@@ -0,0 +1,6 @@
1
+ import { type ElementRenderer } from '@formicoidea/labre-core/blocks/surface';
2
+ import { type C4NodeElementModel } from '@formicoidea/labre-core/model';
3
+ export declare const c4Node: ElementRenderer<C4NodeElementModel>;
4
+ export declare const C4NodeRendererExtension: import("@formicoidea/labre-core/store").ExtensionType & {
5
+ identifier: import("@formicoidea/labre-core/_pkgs/global/di").ServiceIdentifier<ElementRenderer<C4NodeElementModel>>;
6
+ };
@@ -0,0 +1,304 @@
1
+ import { ElementRendererExtension, } from '@formicoidea/labre-core/blocks/surface';
2
+ import { shape as shapeRenderer } from '@formicoidea/labre-core/gfx/shape';
3
+ import { DefaultTheme, } from '@formicoidea/labre-core/model';
4
+ import { NODE_RADIUS } from '../consts.js';
5
+ /**
6
+ * Renderer for a C4 node — the glyph layer, and nothing else.
7
+ *
8
+ * Every path, radius and offset below is read off the PO's reference model
9
+ * (`C4Model_default.svg`, a Visio export of the official stencil) and expressed
10
+ * as a FRACTION of the node box, so a resized element keeps the stencil's own
11
+ * proportions instead of the stencil's own pixels.
12
+ *
13
+ * ## What the native shape does, and what this file does
14
+ *
15
+ * The native shape renderer runs FIRST and owns the fill, the stroke, the theme
16
+ * and the TITLE — the element's inner text, editable in place exactly like any
17
+ * other shape's. This file then paints, in the element-local frame, only the
18
+ * silhouettes a rectangle cannot be: `person` / `person-ext` (a circular head
19
+ * fused into a strongly rounded body), `database` (a cylinder), `mobile` (a
20
+ * phone: a dark bezel with a lighter screen inset in it, a speaker slot and a
21
+ * button) and `browser` (a window: a dark frame, a chrome band with three dots
22
+ * and an address bar, and a lighter screen under it). For all five the native
23
+ * rect is created unfilled and unstroked — the glyph IS the body.
24
+ *
25
+ * ## The two tiers this file no longer paints
26
+ *
27
+ * It used to paint the type line and the description under the title, off two
28
+ * fields on the model. It does not any more, and the difference is the PO's
29
+ * recette of 28/08/2026: a C4 component is the shape and its own words, GROUPED
30
+ * — the type line and the description are real canvas TEXT elements, created
31
+ * beside the shape and edited in place like any other text. Painted tiers could
32
+ * only ever be read, and a picture an architect cannot type on is a form waiting
33
+ * to be invented. `actions.ts` places them; the text renderer draws them.
34
+ *
35
+ * `system`, `system-ext`, `container` and `component` have no glyph at all: the
36
+ * stencil draws them as plain SQUARE-cornered rectangles, and what tells them
37
+ * apart is the COLOUR they are created in (`NODE_PALETTE`) plus the role stamped
38
+ * on them. That is C4's own answer, and it is why this pack has nine kinds and
39
+ * five element roles rather than nine glyphs.
40
+ *
41
+ * Both fill and stroke are read off the MODEL, never off the palette table: a
42
+ * node's colours are editable from the shape toolbar like any other shape's, and
43
+ * a glyph that painted the table's blue would silently ignore the user's own
44
+ * choice. The palette is what the creation site SEEDS them with.
45
+ */
46
+ /**
47
+ * The kinds C4 draws BARE — a plain native shape with nothing on it.
48
+ *
49
+ * Written as the short list rather than the long one, so that the glyph kinds
50
+ * are DERIVED from the model's union instead of restated beside it: a kind added
51
+ * to `C4NodeKind` is a glyph kind by default, and the exhaustiveness check at the
52
+ * bottom of {@link paintGlyph} then refuses to compile until it is drawn.
53
+ */
54
+ const BARE_KINDS = {
55
+ system: true,
56
+ 'system-ext': true,
57
+ container: true,
58
+ component: true,
59
+ };
60
+ const isBare = (kind) => Object.hasOwn(BARE_KINDS, kind);
61
+ const TAU = Math.PI * 2;
62
+ /* ── The stencil's geometry, as fractions of the node box ──────────────── */
63
+ /**
64
+ * The person (`mID 1`), measured off its single path.
65
+ *
66
+ * The head arc runs between `x=68.89` and `x=37.41` on the body's top edge with
67
+ * `rx=26.362`, `ry=26.504` and `large-arc-flag=1`; solving the SVG centre
68
+ * equation puts its centre 21.263 units ABOVE that edge, so the head stands
69
+ * `26.504 + 21.263 = 47.767` clear of a body 74.409 tall — a silhouette 122.18
70
+ * tall in total, which is what {@link PERSON_BOX} is twice.
71
+ */
72
+ const PERSON = {
73
+ /** Head radius, as a fraction of the box width, and of its height. */
74
+ headOfWidth: 26.362 / 106.3,
75
+ headOfHeight: 26.504 / 122.18,
76
+ /** Where the body's top edge sits, as a multiple of the head radius. */
77
+ bodyTopPerHead: 47.767 / 26.433,
78
+ /** The body's corner radius (`19.842`), as a fraction of the box width. */
79
+ radiusOfWidth: 19.842 / 106.3,
80
+ };
81
+ /** The cylinder's lid and floor: `ry ≈ 9.97` of a 74.409-tall box. */
82
+ const DATABASE_RY = 9.97 / 74.409;
83
+ /** The phone (`mID 6`): a screen inset in a bezel, a speaker slot, a button. */
84
+ const MOBILE = {
85
+ screen: {
86
+ x: 9.2126 / 106.3,
87
+ y: 2.766 / 74.409,
88
+ w: 87.874 / 106.3,
89
+ h: 68.882 / 74.409,
90
+ },
91
+ screenRadius: 1.4173 / 106.3,
92
+ /** The home button, centred in the LEFT bezel column. */
93
+ button: { x: 4.985 / 106.3, y: 38.039 / 74.409, r: 3.0301 / 106.3 },
94
+ /** The speaker slot, a vertical line down the RIGHT bezel column. */
95
+ speaker: { x: 101.411 / 106.3, y0: 29.851 / 74.409, y1: 44.561 / 74.409 },
96
+ };
97
+ /** The browser window (`mID 11`): a chrome band over a screen. */
98
+ const BROWSER = {
99
+ screen: {
100
+ x: 2.126 / 106.3,
101
+ y: 9.214 / 74.409,
102
+ w: 102.05 / 106.3,
103
+ h: 63.071 / 74.409,
104
+ },
105
+ screenRadius: 1.4173 / 106.3,
106
+ dot: { y: 4.766 / 74.409, r: 3.0301 / 106.3 },
107
+ dotsX: [6.5001 / 106.3, 13.978 / 106.3, 21.455 / 106.3],
108
+ bar: {
109
+ x: 26.169 / 106.3,
110
+ y: 1.731 / 74.409,
111
+ w: 77.862 / 106.3,
112
+ h: 6.07 / 74.409,
113
+ r: 2.8346 / 106.3,
114
+ },
115
+ };
116
+ /* ── Paths ─────────────────────────────────────────────────────────────── */
117
+ /**
118
+ * Trace a rounded rectangle, and say whether there was one to trace.
119
+ *
120
+ * An element can be dragged to nothing: the resize manager takes the absolute
121
+ * value of the dragged extents but sets no minimum size, and every dimension
122
+ * here has the stroke width subtracted from it first — so a 2-unit border on a
123
+ * 1-unit box gives -0.5. `arcTo` throws `IndexSizeError` on a negative radius
124
+ * rather than clamping, and the surface render loop wraps no renderer in a
125
+ * `try`, so one such throw aborts the rest of the frame with an unbalanced save
126
+ * stack. Returning `false` lets the caller skip the fill and the stroke too,
127
+ * rather than painting a degenerate path.
128
+ */
129
+ function roundedRectPath(ctx, x, y, w, h, r) {
130
+ if (!(w > 0) || !(h > 0))
131
+ return false;
132
+ const rr = Math.max(0, Math.min(r, w / 2, h / 2));
133
+ ctx.beginPath();
134
+ ctx.moveTo(x + rr, y);
135
+ ctx.arcTo(x + w, y, x + w, y + h, rr);
136
+ ctx.arcTo(x + w, y + h, x, y + h, rr);
137
+ ctx.arcTo(x, y + h, x, y, rr);
138
+ ctx.arcTo(x, y, x + w, y, rr);
139
+ ctx.closePath();
140
+ return true;
141
+ }
142
+ /** Fill and outline a rounded rectangle, skipping a degenerate one entirely. */
143
+ function fillStrokeRect(ctx, x, y, w, h, r) {
144
+ if (!roundedRectPath(ctx, x, y, w, h, r))
145
+ return;
146
+ ctx.fill();
147
+ ctx.stroke();
148
+ }
149
+ /* ── The glyphs ────────────────────────────────────────────────────────── */
150
+ /**
151
+ * Paint one glyph kind, in the element-local frame, with `fillStyle` /
152
+ * `strokeStyle` / `lineWidth` already set from the model.
153
+ *
154
+ * @param inset half the stroke width — the body box is drawn inside it, so the
155
+ * outline lands within the element's bounds rather than straddling them.
156
+ */
157
+ function paintGlyph(kind, ctx, fill, stroke, w, h, inset) {
158
+ const cx = w / 2;
159
+ const x0 = inset;
160
+ const y0 = inset;
161
+ const x1 = w - inset;
162
+ const y1 = h - inset;
163
+ const bw = x1 - x0;
164
+ const bh = y1 - y0;
165
+ // ── The person: a head fused into a strongly rounded body ────────────
166
+ if (kind === 'person' || kind === 'person-ext') {
167
+ // A single radius from whichever dimension is the tighter, so the head stays
168
+ // CIRCULAR at any aspect ratio the author drags the element to. At the
169
+ // stencil's own ratio the two agree to within half a unit, which is the
170
+ // whole reason the person keeps a box of its own.
171
+ const headR = Math.max(0, Math.min(w * PERSON.headOfWidth, h * PERSON.headOfHeight));
172
+ const headY = y0 + headR;
173
+ const bodyTop = y0 + headR * PERSON.bodyTopPerHead;
174
+ // The head goes FIRST so the body covers its lower arc: what is left is a
175
+ // head standing clear of a pair of shoulders, which is the stencil's own
176
+ // person — its path draws exactly this, the body's top edge running into the
177
+ // head arc and back out of it.
178
+ if (headR > 0) {
179
+ ctx.beginPath();
180
+ ctx.arc(cx, headY, headR, 0, TAU);
181
+ ctx.fill();
182
+ ctx.stroke();
183
+ }
184
+ fillStrokeRect(ctx, x0, bodyTop, bw, y1 - bodyTop, w * PERSON.radiusOfWidth);
185
+ return;
186
+ }
187
+ // ── The database: a cylinder ─────────────────────────────────────────
188
+ if (kind === 'database') {
189
+ // Both radii subtract the stroke first, so both go NEGATIVE on an element
190
+ // dragged narrower (or shorter) than its own border — and a negative radius
191
+ // is the one thing `ellipse` throws on. Clamped where they are computed,
192
+ // exactly as the BPMN data store's are.
193
+ const rx = Math.max(0, bw / 2);
194
+ const ry = Math.max(0, bh * DATABASE_RY);
195
+ const mx = (x0 + x1) / 2;
196
+ const top = y0 + ry;
197
+ const bottom = y1 - ry;
198
+ ctx.beginPath();
199
+ ctx.moveTo(x0, top);
200
+ ctx.lineTo(x0, bottom);
201
+ // Floor, left to right through the lowest point.
202
+ ctx.ellipse(mx, bottom, rx, ry, 0, Math.PI, 0, true);
203
+ ctx.lineTo(x1, top);
204
+ // Back up the front of the lid, right to left.
205
+ ctx.ellipse(mx, top, rx, ry, 0, 0, Math.PI, false);
206
+ ctx.closePath();
207
+ ctx.fill();
208
+ ctx.stroke();
209
+ // The lid's own far edge, which the body path does not include — the rim the
210
+ // stencil draws as two extra `.st10` arcs.
211
+ ctx.beginPath();
212
+ ctx.ellipse(mx, top, rx, ry, 0, 0, TAU);
213
+ ctx.stroke();
214
+ return;
215
+ }
216
+ // ── The two devices: a dark frame with a lighter screen inset in it ──
217
+ // The stencil paints the OUTER rectangle in the darker of the node's two
218
+ // colours and the screen in the lighter, which is the opposite of a band
219
+ // painted over a body — so both are drawn here in full, and the native rect
220
+ // under them paints nothing.
221
+ ctx.fillStyle = stroke;
222
+ ctx.strokeStyle = stroke;
223
+ // Every INNER feature below is placed against the element box (`0…w`, `0…h`)
224
+ // rather than against the stroke-inset one the outer rectangle uses. The
225
+ // stencil states them that way — its screen has equal bezel columns left and
226
+ // right — and shifting them by half a border would make those columns unequal
227
+ // by exactly that half, which is visible at the sizes these are drawn at.
228
+ if (kind === 'mobile') {
229
+ fillStrokeRect(ctx, x0, y0, bw, bh, NODE_RADIUS.mobile);
230
+ ctx.fillStyle = fill;
231
+ fillStrokeRect(ctx, w * MOBILE.screen.x, h * MOBILE.screen.y, w * MOBILE.screen.w, h * MOBILE.screen.h, w * MOBILE.screenRadius);
232
+ // The button, in the left bezel column…
233
+ const buttonR = Math.max(0, w * MOBILE.button.r);
234
+ if (buttonR > 0) {
235
+ ctx.beginPath();
236
+ ctx.arc(w * MOBILE.button.x, h * MOBILE.button.y, buttonR, 0, TAU);
237
+ ctx.fill();
238
+ }
239
+ // …and the speaker slot down the right one.
240
+ ctx.strokeStyle = fill;
241
+ ctx.beginPath();
242
+ ctx.moveTo(w * MOBILE.speaker.x, h * MOBILE.speaker.y0);
243
+ ctx.lineTo(w * MOBILE.speaker.x, h * MOBILE.speaker.y1);
244
+ ctx.stroke();
245
+ return;
246
+ }
247
+ if (kind === 'browser') {
248
+ fillStrokeRect(ctx, x0, y0, bw, bh, NODE_RADIUS.browser);
249
+ ctx.fillStyle = fill;
250
+ fillStrokeRect(ctx, w * BROWSER.screen.x, h * BROWSER.screen.y, w * BROWSER.screen.w, h * BROWSER.screen.h, w * BROWSER.screenRadius);
251
+ // The three window dots, and the address bar beside them.
252
+ const dotR = Math.max(0, w * BROWSER.dot.r);
253
+ const dotY = h * BROWSER.dot.y;
254
+ if (dotR > 0) {
255
+ for (const dotX of BROWSER.dotsX) {
256
+ ctx.beginPath();
257
+ ctx.arc(w * dotX, dotY, dotR, 0, TAU);
258
+ ctx.fill();
259
+ }
260
+ }
261
+ fillStrokeRect(ctx, w * BROWSER.bar.x, h * BROWSER.bar.y, w * BROWSER.bar.w, h * BROWSER.bar.h, w * BROWSER.bar.r);
262
+ return;
263
+ }
264
+ /**
265
+ * Every glyph kind is drawn above, and this is what keeps that true: `kind` is
266
+ * narrowed to `never` here only if the branches are exhaustive over
267
+ * {@link C4GlyphKind}, so a kind added to the model's union without a picture
268
+ * of its own stops the build.
269
+ *
270
+ * Which is the whole point of closing the last branch rather than letting it
271
+ * fall through. A renderer that silently paints a browser band on somebody's
272
+ * new artefact is worse than one that paints nothing: the first is a wrong
273
+ * picture nobody is told about, the second is a missing one everybody sees.
274
+ */
275
+ const unhandled = kind;
276
+ void unhandled;
277
+ }
278
+ export const c4Node = (model, ctx, matrix, renderer, rc, bound) => {
279
+ const [, , w, h] = model.deserializedXYWH;
280
+ const cx = w / 2;
281
+ const cy = h / 2;
282
+ // Capture the element-local transform BEFORE the shape renderer mutates the
283
+ // matrix, so the glyph can be drawn in the same space afterwards.
284
+ const glyphMatrix = DOMMatrix.fromMatrix(matrix)
285
+ .translateSelf(cx, cy)
286
+ .rotateSelf(model.rotate)
287
+ .translateSelf(-cx, -cy);
288
+ // Native shape: fill, stroke, theme and the TITLE, all handled natively.
289
+ shapeRenderer(model, ctx, matrix, renderer, rc, bound);
290
+ const stroke = renderer.getColorValue(model.strokeColor, DefaultTheme.shapeStrokeColor, true);
291
+ const fill = renderer.getColorValue(model.fillColor, DefaultTheme.shapeFillColor, true);
292
+ const strokeWidth = model.strokeWidth || 1;
293
+ ctx.setTransform(glyphMatrix);
294
+ ctx.strokeStyle = stroke;
295
+ ctx.fillStyle = fill;
296
+ ctx.lineWidth = strokeWidth;
297
+ ctx.lineCap = 'round';
298
+ ctx.lineJoin = 'round';
299
+ const kind = model.kind;
300
+ if (!isBare(kind)) {
301
+ paintGlyph(kind, ctx, fill, stroke, w, h, strokeWidth / 2);
302
+ }
303
+ };
304
+ export const C4NodeRendererExtension = ElementRendererExtension('c4Node', c4Node);
@@ -0,0 +1,45 @@
1
+ import { type C4NodeElementModel } from '@formicoidea/labre-core/model';
2
+ import { GfxElementModelView } from '@formicoidea/labre-core/std/gfx';
3
+ /**
4
+ * View for a C4 node. Registering it ensures `gfx.view.get(model)` returns a
5
+ * view (required so move / select / connector interactions work).
6
+ *
7
+ * ## Double-clicking the body edits the NAME — which is not on this element
8
+ *
9
+ * Since the PO's follow-up to the recette of 28/08/2026 a C4 element's name is a
10
+ * canvas `text` child stamped `c4:title`, grouped with the shape, and the shape
11
+ * itself carries no text at all. Which leaves a gesture with nowhere obvious to
12
+ * go: the body is the biggest target in the component, double-clicking a shape
13
+ * is how everybody in this editor starts typing, and the shape's own inline
14
+ * editor would write into the one field that must stay empty. A "shadow" name,
15
+ * invisible under the real one, disagreeing with it, exported by neither.
16
+ *
17
+ * So the gesture is ROUTED: a double-click on the body opens the title child's
18
+ * own editor, `mountTextElementEditor` — the very same call `TextElementView`
19
+ * makes when you double-click those words directly. The author gets one editor
20
+ * for one name whichever half of the component they aimed at, and the shape's
21
+ * text path is never entered.
22
+ *
23
+ * The seam is cheap because the platform already had both halves: the title is
24
+ * found through the group (`c4ComponentSiblings`, the same pure resolution the
25
+ * exporter uses) and opened through the text package's own public mount. No new
26
+ * editor, no new selection handling, no reimplementation of anything.
27
+ *
28
+ * ## …except on an element that has no title child
29
+ *
30
+ * One drawn before this change keeps its name in the shape's inner text, which
31
+ * is where that iteration put it, and `mountShapeTextEditor` is then exactly
32
+ * right — it is still that element's name. The same test the exporter makes
33
+ * (`c4StatedName`), so the words a double-click edits are the words the file
34
+ * comes out with. A node whose group was released and whose title was deleted
35
+ * lands here too, and gets the old behaviour rather than nothing at all.
36
+ *
37
+ * Hit testing is `C4NodeElementModel.includesPoint`'s doing: an empty unfilled
38
+ * shape would normally be a target only near its border, and the override forces
39
+ * the interior test, so the whole body answers this gesture on all nine kinds.
40
+ */
41
+ export declare class C4NodeView extends GfxElementModelView<C4NodeElementModel> {
42
+ #private;
43
+ static type: string;
44
+ onCreated(): void;
45
+ }
@@ -0,0 +1,80 @@
1
+ import { mountTextElementEditor } from '@formicoidea/labre-core/gfx/text';
2
+ import { GroupElementModel, ShapeElementModel, TextElementModel, } from '@formicoidea/labre-core/model';
3
+ import { mountShapeTextEditor } from '@formicoidea/labre-core/gfx/shape';
4
+ import { GfxElementModelView } from '@formicoidea/labre-core/std/gfx';
5
+ import { c4ComponentSiblings } from '../component.js';
6
+ import { C4_ROLE } from '../roles.js';
7
+ /**
8
+ * View for a C4 node. Registering it ensures `gfx.view.get(model)` returns a
9
+ * view (required so move / select / connector interactions work).
10
+ *
11
+ * ## Double-clicking the body edits the NAME — which is not on this element
12
+ *
13
+ * Since the PO's follow-up to the recette of 28/08/2026 a C4 element's name is a
14
+ * canvas `text` child stamped `c4:title`, grouped with the shape, and the shape
15
+ * itself carries no text at all. Which leaves a gesture with nowhere obvious to
16
+ * go: the body is the biggest target in the component, double-clicking a shape
17
+ * is how everybody in this editor starts typing, and the shape's own inline
18
+ * editor would write into the one field that must stay empty. A "shadow" name,
19
+ * invisible under the real one, disagreeing with it, exported by neither.
20
+ *
21
+ * So the gesture is ROUTED: a double-click on the body opens the title child's
22
+ * own editor, `mountTextElementEditor` — the very same call `TextElementView`
23
+ * makes when you double-click those words directly. The author gets one editor
24
+ * for one name whichever half of the component they aimed at, and the shape's
25
+ * text path is never entered.
26
+ *
27
+ * The seam is cheap because the platform already had both halves: the title is
28
+ * found through the group (`c4ComponentSiblings`, the same pure resolution the
29
+ * exporter uses) and opened through the text package's own public mount. No new
30
+ * editor, no new selection handling, no reimplementation of anything.
31
+ *
32
+ * ## …except on an element that has no title child
33
+ *
34
+ * One drawn before this change keeps its name in the shape's inner text, which
35
+ * is where that iteration put it, and `mountShapeTextEditor` is then exactly
36
+ * right — it is still that element's name. The same test the exporter makes
37
+ * (`c4StatedName`), so the words a double-click edits are the words the file
38
+ * comes out with. A node whose group was released and whose title was deleted
39
+ * lands here too, and gets the old behaviour rather than nothing at all.
40
+ *
41
+ * Hit testing is `C4NodeElementModel.includesPoint`'s doing: an empty unfilled
42
+ * shape would normally be a target only near its border, and the override forces
43
+ * the interior test, so the whole body answers this gesture on all nine kinds.
44
+ */
45
+ export class C4NodeView extends GfxElementModelView {
46
+ static { this.type = 'c4Node'; }
47
+ onCreated() {
48
+ super.onCreated();
49
+ this.on('dblclick', () => {
50
+ const edgeless = this.std.view.getBlock(this.std.store.root.id);
51
+ if (!edgeless || this.model.isLocked())
52
+ return;
53
+ const title = this.#title();
54
+ if (title) {
55
+ if (!title.isLocked())
56
+ mountTextElementEditor(title, edgeless);
57
+ return;
58
+ }
59
+ // No title child: the name really is the shape's own text.
60
+ if (this.model instanceof ShapeElementModel) {
61
+ mountShapeTextEditor(this.model, edgeless);
62
+ }
63
+ });
64
+ }
65
+ /** The `c4:title` text grouped with this shape, if there is one. */
66
+ #title() {
67
+ const surface = this.gfx.surface;
68
+ if (!surface)
69
+ return null;
70
+ const groups = surface.elementModels.filter((element) => element instanceof GroupElementModel);
71
+ for (const siblingId of c4ComponentSiblings(this.model.id, groups)) {
72
+ const sibling = surface.getElementById(siblingId);
73
+ if (sibling instanceof TextElementModel &&
74
+ sibling.role === C4_ROLE.title) {
75
+ return sibling;
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+ }