@itsthecsguy/linework 0.2.0 → 0.2.1

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/README.md CHANGED
@@ -258,7 +258,7 @@ Docs can't hold an SVG, so the diagram lives in Google Drive and draw.io's Googl
258
258
 
259
259
  ## Status
260
260
 
261
- All four phases of [PLAN.md](PLAN.md) are built. Two paths haven't been tried yet, because they need a real account or app: the Google Docs add-on round trip, and saving from the VS Code Draw.io Integration extension. Both use the same file format and protocol that the tests cover with draw.io's own editor.
261
+ All five phases of [PLAN.md](PLAN.md) are built. Two paths haven't been tried yet, because they need a real account or app: the Google Docs add-on round trip, and saving from the VS Code Draw.io Integration extension. Both use the same file format and protocol that the tests cover with draw.io's own editor.
262
262
 
263
263
  ## Development
264
264
 
@@ -269,4 +269,20 @@ npm run build # compile to dist/
269
269
  npm run dev -- build examples/checkout.yaml -o out/checkout.drawio.svg --png out/checkout.png
270
270
  ```
271
271
 
272
+ Every push to `main` and every pull request runs the typecheck and the tests on Node 22 and 24 in GitHub Actions. To run the suite with a browser that isn't in one of the usual places, set `LINEWORK_CHROME=/path/to/chromium`.
273
+
274
+ After a change to layout or rendering, rebuild the README gallery and look at what moved:
275
+
276
+ ```bash
277
+ npm run build && node docs/gallery/build.mjs
278
+ ```
279
+
280
+ ### Releasing
281
+
282
+ ```bash
283
+ npm run release # patch; or: npm run release -- minor | major | 1.2.3
284
+ ```
285
+
286
+ That bumps the version, commits it as `Release x.y.z`, tags `vx.y.z` and pushes both. The tag starts the release workflow, which runs the tests again, publishes to npm and opens a GitHub release with generated notes. Nobody logs in to npm: the workflow publishes through npm's [trusted publishing](https://docs.npmjs.com/trusted-publishers), which is set up once in the package's settings on npmjs.com (GitHub Actions, repository `itsthecsguy/linework`, workflow `release.yml`).
287
+
272
288
  `assets/drawio` holds the parts of the draw.io web app that linework runs: the renderer it uses headless, and the editor behind `linework edit`. draw.io is Apache-2.0; see [NOTICE](NOTICE). To update it, check out a jgraph/drawio tag in `vendor/drawio` and run `npm run assets`.
package/dist/cli.js CHANGED
File without changes
package/dist/commands.js CHANGED
@@ -32,7 +32,7 @@ Usage:
32
32
  Open the diagram in draw.io (bundled, offline) in your browser. Saving in
33
33
  the editor writes the file. Stops when you exit the editor or close the tab.
34
34
  linework import <file.mmd|doc.md|-> [--block N] [-o out.drawio.svg] [--png p.png]
35
- Convert a Mermaid diagram (flowchart, state, sequence, ER) to an linework spec.
35
+ Convert a Mermaid diagram (flowchart, state, sequence, ER) to a linework spec.
36
36
  Prints the spec, or builds it straight away with -o.
37
37
  linework icons <aws|gcp|k8s|azure> [filter]
38
38
  List icon names, for "icon: aws.lambda" on architecture nodes.
@@ -175,6 +175,23 @@ export function sceneFromLayout(d, l) {
175
175
  vertices.push(...nodeChildren(d, n, box));
176
176
  }
177
177
  const isCyl = (id) => { const n = d.nodes.find((x) => x.id === id); return !!n && kindDef(d.type, n.kind).shape === 'cylinder'; };
178
+ const isDecision = (id) => { const n = d.nodes.find((x) => x.id === id); return !!n && kindDef(d.type, n.kind).shape === 'diamond'; };
179
+ // How far an edge shares its first run with a sibling leaving the same point.
180
+ const sharedStem = (id, from) => {
181
+ const mine = l.edges.get(id).points;
182
+ let shared = 0;
183
+ for (const o of d.edges) {
184
+ const pts = o.id !== id && o.from === from && o.to !== from ? l.edges.get(o.id)?.points : undefined;
185
+ if (!pts || pts.length < 2 || mine.length < 2 || Math.hypot(pts[0].x - mine[0].x, pts[0].y - mine[0].y) > 1)
186
+ continue;
187
+ const dir = (p) => [Math.sign(Math.round(p[1].x - p[0].x)), Math.sign(Math.round(p[1].y - p[0].y))].join();
188
+ if (dir(pts) !== dir(mine))
189
+ continue;
190
+ const len = (p) => Math.hypot(p[1].x - p[0].x, p[1].y - p[0].y);
191
+ shared = Math.max(shared, Math.min(len(pts), len(mine)));
192
+ }
193
+ return shared;
194
+ };
178
195
  const edges = d.edges.map((e) => {
179
196
  const le = l.edges.get(e.id);
180
197
  const cyl = { from: isCyl(e.from), to: isCyl(e.to) };
@@ -197,7 +214,7 @@ export function sceneFromLayout(d, l) {
197
214
  ...base,
198
215
  style: base.style + portStyle(le.points, abs(e.from), abs(e.to), cyl),
199
216
  points: le.points.slice(1, -1),
200
- labelX: le.label && e.label ? (labelSpot(le.points, le.label, l.direction) ?? labelFraction(le.points, { x: le.label.x + le.label.w / 2, y: le.label.y + le.label.h / 2 })) : undefined,
217
+ labelX: le.label && e.label ? ((isDecision(e.from) ? branchSpot(le.points, le.label, l.direction, sharedStem(e.id, e.from)) : undefined) ?? labelSpot(le.points, le.label, l.direction, [...l.nodes].filter(([id]) => id !== e.from && id !== e.to).map(([, b]) => b)) ?? labelFraction(le.points, { x: le.label.x + le.label.w / 2, y: le.label.y + le.label.h / 2 })) : undefined,
201
218
  };
202
219
  });
203
220
  alignLabels(d, l, edges);
@@ -334,7 +351,7 @@ export function toMxfile(d, l) {
334
351
  * source node, which reads as a caption of that node. Returns draw.io's -1..1
335
352
  * fraction, or undefined when no run is long enough.
336
353
  */
337
- function labelSpot(pts, label, dir) {
354
+ export function labelSpot(pts, label, dir, avoid = []) {
338
355
  let total = 0;
339
356
  let best;
340
357
  // Runs along the flow belong to one edge; runs across it are often shared
@@ -348,10 +365,57 @@ function labelSpot(pts, label, dir) {
348
365
  const along = dir ? vertical === flowVertical : true;
349
366
  const better = !best || (along && !best.along) || (along === best.along && room > best.room);
350
367
  if (room >= 0 && better)
351
- best = { room, at: total + len / 2, along };
368
+ best = { room, at: total + len / 2, along, a, b, start: total, len };
352
369
  total += len;
353
370
  }
354
- return best && total ? (best.at / total) * 2 - 1 : undefined;
371
+ if (!best || !total)
372
+ return undefined;
373
+ // Halfway along a long run can be right beside a box the edge only passes, where
374
+ // the label reads as that box's caption. Slide along the run to the nearest
375
+ // spot past the box, with a little air; stay in the middle when there is none.
376
+ const { a, b, start, len } = best;
377
+ const vertical = Math.abs(b.x - a.x) < Math.abs(b.y - a.y);
378
+ const half = (vertical ? label.h : label.w) / 2 + 10;
379
+ // Beside: level with the box along the run, and within 12px of it across the run.
380
+ const beside = (at, air) => {
381
+ const t = (at - start) / len, cx = a.x + t * (b.x - a.x), cy = a.y + t * (b.y - a.y);
382
+ const px = vertical ? 12 : air, py = vertical ? air : 12;
383
+ const r = { x: cx - label.w / 2 - px, y: cy - label.h / 2 - py, w: label.w + 2 * px, h: label.h + 2 * py };
384
+ return avoid.some((o) => r.x < o.x + o.w && r.x + r.w > o.x && r.y < o.y + o.h && r.y + r.h > o.y);
385
+ };
386
+ // Level by a few px or more: a label that only grazes the box's end is fine where it is.
387
+ if (beside(best.at, -4))
388
+ for (let step = 4; step <= len / 2 - half; step += 4)
389
+ for (const at of [best.at - step, best.at + step])
390
+ if (!beside(at, 4))
391
+ return (at / total) * 2 - 1;
392
+ return (best.at / total) * 2 - 1;
393
+ }
394
+ /**
395
+ * A branch label ("yes", "no") belongs to its decision, so it sits where the
396
+ * branch leaves the diamond, not halfway to the next step: on the run out of the
397
+ * diamond's point when that has room, otherwise at the start of the first run
398
+ * along the flow, or of a long arm that forks off a short stem. `stem` is how much of the first run a sibling branch shares;
399
+ * the label starts past it, so it doesn't cover the place where they part.
400
+ */
401
+ export function branchSpot(pts, label, dir, stem = 0) {
402
+ const flowVertical = dir === 'TB' || dir === 'BT';
403
+ const lens = pts.slice(1).map((b, i) => Math.hypot(b.x - pts[i].x, b.y - pts[i].y));
404
+ const total = lens.reduce((x, y) => x + y, 0);
405
+ let before = 0;
406
+ for (let i = 0; i < lens.length; i++) {
407
+ const a = pts[i], b = pts[i + 1], len = lens[i];
408
+ const vertical = Math.abs(b.x - a.x) < Math.abs(b.y - a.y);
409
+ const size = vertical ? label.h : label.w;
410
+ const lead = i === 0 && stem > 0 ? stem + 6 : 10;
411
+ // A long arm across the flow, straight after the stem out of the point, is
412
+ // nearer the diamond than the drop at its far end.
413
+ const arm = i === 1 && len >= 96 && lens[0] <= 24;
414
+ if ((i === 0 || arm || vertical === flowVertical) && len >= lead + size + 10)
415
+ return ((before + lead + size / 2) / total) * 2 - 1;
416
+ before += len;
417
+ }
418
+ return undefined;
355
419
  }
356
420
  // draw.io positions edge labels by fraction along the path, -1..1.
357
421
  // Project ELK's label centre onto the route and convert.
@@ -162,7 +162,10 @@ function nodeSizes(d, tm, fonts) {
162
162
  if (t.lines > 2)
163
163
  t = textOf(n, fonts.maxText);
164
164
  const w = snap(Math.max(128, t.width * 1.5 + 40));
165
- sizes.set(n.id, { w, h: snap(Math.max(72, w * 0.5, t.lines * fonts.line * 2 + 24)) });
165
+ // The text takes about 0.6 of the width, which leaves it 0.4 of the height.
166
+ // Only that tall, and no taller: a diamond at half its width for a two-line
167
+ // question is the biggest thing in the flow, bigger than any step.
168
+ sizes.set(n.id, { w, h: snap(Math.max(72, w * 0.36, t.lines * fonts.line * 2.5 + 24)) });
166
169
  continue;
167
170
  }
168
171
  const extra = shapeExtra(shape, !!n.icon);
@@ -137,47 +137,108 @@ export function polish(d, l, inp) {
137
137
  const b = nodes.get(n.id);
138
138
  const cx = b.x + b.w / 2, cy = b.y + b.h / 2;
139
139
  const outs = edges.filter((e) => e.from === n.id && e.to !== n.id);
140
- // The run each edge settles into once it is clear of the diamond.
141
- const runX = (e) => {
142
- const p = e.pts;
140
+ // The run each edge settles into once it is clear of the diamond. ELK usually
141
+ // leaves on a short stub and turns; when it leaves straight down instead, the
142
+ // first segment is the run.
143
+ const runAt = (p) => {
144
+ if (p.length >= 2 && vert(p[0], p[1]) && p[1].y > b.y + b.h + 24)
145
+ return 0;
143
146
  for (let i = 1; i < p.length - 1; i++)
144
147
  if (vert(p[i], p[i + 1]) && p[i + 1].y > b.y + b.h + 1)
145
- return p[i].x;
146
- return p[p.length - 1].x;
148
+ return i;
149
+ return -1;
150
+ };
151
+ const runX = (e) => {
152
+ const i = runAt(e.pts);
153
+ return (i >= 0 ? e.pts[i] : e.pts[e.pts.length - 1]).x;
147
154
  };
155
+ // Points from the start of that run on.
148
156
  const tail = (e) => {
149
- // Points from the start of that run on.
150
- const p = e.pts;
151
- for (let i = 1; i < p.length - 1; i++)
152
- if (vert(p[i], p[i + 1]) && p[i + 1].y > b.y + b.h + 1)
153
- return p.slice(i);
154
- return p.slice(-1);
157
+ const i = runAt(e.pts);
158
+ return i >= 0 ? e.pts.slice(i) : e.pts.slice(-1);
155
159
  };
156
160
  const sorted = [...outs].sort((a, c) => runX(a) - runX(c));
157
161
  let bottomTaken = false;
162
+ // One branch per side point: a second one there would hide the first.
163
+ const taken = { left: false, right: false };
164
+ // A branch whose run sits under the diamond would have to fork off the bottom
165
+ // point next to its sibling. Move the run out past the side point instead, so
166
+ // each branch leaves from its own corner: side point, a short stub, then down.
167
+ const sideStep = (e, rest, left) => {
168
+ if (rest.length < 2)
169
+ return undefined;
170
+ const lab = l.edges.get(e.id)?.label;
171
+ // Far enough out that the branch label, centred on the run, clears the diamond.
172
+ const out = Math.max(24, lab ? Math.ceil(f.boxTo(lab).w / 2) + 8 : 0);
173
+ const beside = left ? b.x - out : b.x + b.w + out;
174
+ const end = rest[1], t = nodes.get(e.to);
175
+ // A run that drops straight into the target has to stay over the target.
176
+ const drops = rest.length === 2;
177
+ if (drops ? ['diamond', 'table'].includes(shapeOf(e.to) ?? '') || !near(end.y, t.y, 1) : !horiz(end, rest[2]) || end.y < b.y + b.h + 12)
178
+ return undefined;
179
+ // Into the target's middle when that is already clear of the diamond, else just beside the diamond.
180
+ const mid = t.x + t.w / 2;
181
+ const tries = drops && (left ? mid <= beside : mid >= beside) ? [mid, beside] : [beside];
182
+ const sideP = { x: left ? b.x : b.x + b.w, y: cy };
183
+ for (const nx of tries) {
184
+ if (drops && (nx < t.x + R || nx > t.x + t.w - R || usedAt(e.to, nx, e, 'top')))
185
+ continue;
186
+ const a = { x: nx, y: cy }, c = { x: nx, y: end.y };
187
+ if (!clear(sideP, a, [n.id]) || !clear(a, c, [n.id, e.to]) || !free(a, c, e))
188
+ continue;
189
+ if (!drops && !clear(c, rest[2], [n.id, e.to]))
190
+ continue;
191
+ return simplify([sideP, a, c, ...rest.slice(2)]);
192
+ }
193
+ return undefined;
194
+ };
158
195
  // The edge closest to straight down gets the bottom point.
159
- const straight = sorted.reduce((m, e) => (!m || Math.abs(runX(e) - cx) < Math.abs(runX(m) - cx) ? e : m), undefined);
160
- for (const e of sorted) {
196
+ const nearest = (list) => list.reduce((m, e) => (!m || Math.abs(runX(e) - cx) < Math.abs(runX(m) - cx) ? e : m), undefined);
197
+ // Or, failing that, the one whose target reaches under the bottom point, so it can drop straight.
198
+ const reaches = (e) => {
199
+ const t = nodes.get(e.to), rest = tail(e);
200
+ return rest.length === 2 && near(rest[1].y, t.y, 1) && !['diamond', 'table'].includes(shapeOf(e.to) ?? '') && cx >= t.x + 16 && cx <= t.x + t.w - 16;
201
+ };
202
+ const mid = nearest(sorted);
203
+ const straight = mid && Math.abs(runX(mid) - cx) < 12 ? mid : nearest(sorted.filter(reaches));
204
+ // The middle branch goes last: whether it can drop straight depends on where
205
+ // its siblings ended up, not on the stubs ELK gave them at the bottom point.
206
+ for (const e of [...sorted.filter((x) => x !== straight), ...(straight ? [straight] : [])]) {
161
207
  const x = runX(e), rest = tail(e);
162
208
  const bottom = { x: cx, y: b.y + b.h };
209
+ // A run that starts on the diamond itself: what follows is from its far end on.
210
+ const onEdge = rest[0].y <= b.y + b.h + 1 && rest.length > 1;
163
211
  if (e === straight && Math.abs(x - cx) < 12) {
164
- e.pts = simplify([bottom, { x: cx, y: rest[0].y }, ...rest.slice(1)].map((pt, i) => (i === 1 ? pt : pt)));
165
- if (rest.length && !near(rest[0].x, cx))
166
- e.pts = simplify([bottom, { x: cx, y: rest[0].y }, ...rest]);
212
+ const run = onEdge ? rest.slice(1) : rest;
213
+ e.pts = simplify([bottom, { x: cx, y: run[0].y }, ...run.slice(1)]);
214
+ if (!near(run[0].x, cx))
215
+ e.pts = simplify([bottom, { x: cx, y: run[0].y }, ...run]);
216
+ bottomTaken = true;
217
+ continue;
218
+ }
219
+ const t = nodes.get(e.to), floor = { x: cx, y: t.y };
220
+ if (e === straight && reaches(e) && !usedAt(e.to, cx, e, 'top') && clear(bottom, floor, [n.id, e.to]) && free(bottom, floor, e)) {
221
+ e.pts = [bottom, floor];
167
222
  bottomTaken = true;
168
223
  continue;
169
224
  }
170
225
  const left = x < cx;
171
226
  const sideP = { x: left ? b.x : b.x + b.w, y: cy };
172
227
  const corner = { x, y: cy };
173
- const sideOk = (left ? x < b.x - 10 : x > b.x + b.w + 10) && clear(sideP, corner, [n.id]) && clear(corner, rest[0], [n.id, e.to]);
174
- if (sideOk) {
175
- e.pts = simplify([sideP, corner, ...rest]);
176
- continue;
228
+ const key = left ? 'left' : 'right';
229
+ if (!taken[key]) {
230
+ const sideOk = (left ? x < b.x - 10 : x > b.x + b.w + 10) && clear(sideP, corner, [n.id]) && clear(corner, rest[0], [n.id, e.to]);
231
+ const pts = sideOk ? simplify([sideP, corner, ...rest]) : sideStep(e, rest, left);
232
+ if (pts) {
233
+ e.pts = pts;
234
+ taken[key] = true;
235
+ continue;
236
+ }
177
237
  }
178
238
  // Branches that both head down fork from the bottom point on a short stem.
179
- const y = Math.min(rest[0].y, b.y + b.h + 16);
180
- e.pts = simplify([bottom, { x: cx, y }, { x, y }, ...rest]);
239
+ // Such a run turns onto the stem below the point.
240
+ const y = onEdge ? b.y + b.h + 16 : Math.min(rest[0].y, b.y + b.h + 16);
241
+ e.pts = simplify([bottom, { x: cx, y }, { x, y }, ...(onEdge ? rest.slice(1) : rest)]);
181
242
  bottomTaken = true;
182
243
  }
183
244
  for (const e of edges.filter((x) => x.to === n.id && x.from !== n.id)) {
@@ -188,9 +249,38 @@ export function polish(d, l, inp) {
188
249
  e.pts[m - 1] = { x: cx, y: b.y };
189
250
  continue;
190
251
  }
252
+ // Arriving sideways and then dropping to a port beside the top point: carry
253
+ // the sideways run on to the middle. Stepping down first leaves a small kink.
254
+ const before = p[m - 3];
255
+ const across = { x: cx, y: prev.y };
256
+ if (before && horiz(before, prev) && !near(before.x, cx) && clear(prev, across, [e.from, n.id]) && clear(across, { x: cx, y: b.y }, [e.from, n.id])) {
257
+ e.pts = simplify([...p.slice(0, m - 2), across, { x: cx, y: b.y }]);
258
+ continue;
259
+ }
191
260
  const y = Math.max(prev.y, b.y - 16);
192
261
  e.pts = simplify([...p.slice(0, m - 1), { x: last.x, y }, { x: cx, y }, { x: cx, y: b.y }]);
193
262
  }
263
+ // Branches that join above a decision meet at one height, not a few px apart.
264
+ const joins = edges.flatMap((e) => {
265
+ const p = e.pts, m = p.length;
266
+ if (e.to !== n.id || e.from === n.id || m < 4)
267
+ return [];
268
+ const [up, a, c, top] = [p[m - 4], p[m - 3], p[m - 2], p[m - 1]];
269
+ const ok = vert(up, a) && up.y < a.y && horiz(a, c) && near(c.x, cx) && vert(c, top) && near(top.y, b.y, 1);
270
+ return ok ? [{ e, a, c }] : [];
271
+ });
272
+ if (joins.length > 1) {
273
+ const y = Math.max(...joins.map((j) => j.a.y));
274
+ for (const j of joins) {
275
+ if (near(j.a.y, y) || y - j.a.y > 24)
276
+ continue;
277
+ const na = { x: j.a.x, y }, nc = { x: cx, y };
278
+ if (!clear(na, nc, [j.e.from, n.id]))
279
+ continue;
280
+ const m = j.e.pts.length;
281
+ j.e.pts = simplify([...j.e.pts.slice(0, m - 3), na, nc, j.e.pts[m - 1]]);
282
+ }
283
+ }
194
284
  }
195
285
  dejog(true);
196
286
  // Self-loops: wide enough to read as a loop, label outside it.
@@ -137,8 +137,8 @@ export function createServer(opts) {
137
137
  return { content: [text([res.fixed ? `rerouted ${res.fixed} edges in ${file}` : 'nothing to fix', ...checkLines(res.check)].join('\n'))] };
138
138
  })());
139
139
  server.registerTool('import_mermaid', {
140
- title: 'Convert Mermaid to an linework spec',
141
- description: 'Converts a Mermaid diagram (flowchart, stateDiagram, sequenceDiagram, erDiagram) to an linework spec. Returns the spec; review the guessed node kinds, then build it. With path, builds it straight away.',
140
+ title: 'Convert Mermaid to a linework spec',
141
+ description: 'Converts a Mermaid diagram (flowchart, stateDiagram, sequenceDiagram, erDiagram) to a linework spec. Returns the spec; review the guessed node kinds, then build it. With path, builds it straight away.',
142
142
  inputSchema: z.object({ source: z.string().describe('Mermaid source, without the ``` fence.'), path: pathArg.optional() }),
143
143
  }, (args) => wrap(async () => {
144
144
  const { importMermaid } = await import('../import/mermaid.js');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itsthecsguy/linework",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Software diagrams an AI can write and a person can edit. YAML spec in, .drawio.svg out, editable in draw.io.",
5
5
  "keywords": [
6
6
  "diagram",
@@ -41,6 +41,7 @@
41
41
  "assets": "node scripts/copy-drawio-assets.mjs",
42
42
  "typecheck": "tsc -p tsconfig.json --noEmit",
43
43
  "prepack": "npm run build",
44
+ "release": "node scripts/release.mjs",
44
45
  "fonts": "node scripts/copy-fonts.mjs"
45
46
  },
46
47
  "dependencies": {
@@ -307,6 +307,6 @@ The middle is `--` for an identifying relationship (solid line) or `..` for a no
307
307
 
308
308
  ## Importing Mermaid
309
309
 
310
- `linework import file.mmd` prints an linework spec. `-o out.drawio.svg` builds it straight away. A Markdown file works too; use `--block N` when it has several Mermaid blocks. Supported: `flowchart`/`graph`, `stateDiagram`, `sequenceDiagram`, `erDiagram`.
310
+ `linework import file.mmd` prints a linework spec. `-o out.drawio.svg` builds it straight away. A Markdown file works too; use `--block N` when it has several Mermaid blocks. Supported: `flowchart`/`graph`, `stateDiagram`, `sequenceDiagram`, `erDiagram`.
311
311
 
312
312
  A flowchart with diamonds becomes a `flow`. One with subgraphs or cylinders becomes an `architecture` diagram. In that case a `<br>` second line becomes `tech`, and kinds are guessed from shapes and names: a cylinder becomes a database, "Kafka" becomes a queue, "Stripe" becomes external. Mermaid styling (`classDef`, `style`, `click`) is dropped. Review the printed spec before building, and fix any kinds it guessed wrong.
@@ -20,7 +20,7 @@ Linework handles the layout and the look. These rules are about content, which i
20
20
  - **Label edges in architecture diagrams with the protocol or the verb**: `HTTPS`, `gRPC`, `SQL`, `publishes OrderPlaced`, `reads session`. An unlabeled arrow makes the reader guess.
21
21
  - **Use `-.->` for async**: events, queues, webhooks, fire-and-forget. Readers rely on the solid/dashed distinction to see where latency and failure modes change.
22
22
  - **Point the arrow the way the request goes**, not the way data comes back. Only show a return edge when the reply is interesting.
23
- - **Label every edge out of a decision** in a flow (`yes`/`no`, or the condition).
23
+ - **Label every edge out of a decision** in a flow (`yes`/`no`, or the condition). Keep those labels to a word or two: each one sits right beside its diamond, where the branch leaves it.
24
24
  - In state diagrams, label transitions with the event that triggers them (`payment_succeeded`), not the resulting state.
25
25
 
26
26
  ## Groups