@squinch/core 0.1.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 (64) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +41 -0
  3. package/dist/api.d.ts +91 -0
  4. package/dist/api.js +232 -0
  5. package/dist/browser.d.ts +3 -0
  6. package/dist/browser.js +9 -0
  7. package/dist/diff/diff.d.ts +30 -0
  8. package/dist/diff/diff.js +365 -0
  9. package/dist/fonts.generated.d.ts +1 -0
  10. package/dist/fonts.generated.js +6 -0
  11. package/dist/grammar/parser.js +22 -0
  12. package/dist/grammar/parser.terms.js +115 -0
  13. package/dist/index.d.ts +4 -0
  14. package/dist/index.js +4 -0
  15. package/dist/layout/layout.d.ts +197 -0
  16. package/dist/layout/layout.js +1721 -0
  17. package/dist/metrics.d.ts +20 -0
  18. package/dist/metrics.generated.d.ts +4 -0
  19. package/dist/metrics.generated.js +4 -0
  20. package/dist/metrics.js +57 -0
  21. package/dist/model/build.d.ts +8 -0
  22. package/dist/model/build.js +1343 -0
  23. package/dist/model/packs.d.ts +13 -0
  24. package/dist/model/packs.js +29 -0
  25. package/dist/model/source.d.ts +10 -0
  26. package/dist/model/source.js +28 -0
  27. package/dist/model/suggest.d.ts +2 -0
  28. package/dist/model/suggest.js +24 -0
  29. package/dist/model/types.d.ts +226 -0
  30. package/dist/model/types.js +24 -0
  31. package/dist/packs/node-fs.d.ts +1 -0
  32. package/dist/packs/node-fs.js +37 -0
  33. package/dist/packs/registry.d.ts +61 -0
  34. package/dist/packs/registry.js +122 -0
  35. package/dist/packs/sanitize.d.ts +12 -0
  36. package/dist/packs/sanitize.js +127 -0
  37. package/dist/packs/sysGlyphs.d.ts +2 -0
  38. package/dist/packs/sysGlyphs.js +21 -0
  39. package/dist/render/adaptive.d.ts +13 -0
  40. package/dist/render/adaptive.js +112 -0
  41. package/dist/render/html/runtime.d.ts +1 -0
  42. package/dist/render/html/runtime.generated.d.ts +1 -0
  43. package/dist/render/html/runtime.generated.js +6 -0
  44. package/dist/render/html/runtime.js +362 -0
  45. package/dist/render/html.d.ts +39 -0
  46. package/dist/render/html.js +235 -0
  47. package/dist/render/svg.d.ts +75 -0
  48. package/dist/render/svg.js +1403 -0
  49. package/dist/render/validate.d.ts +4 -0
  50. package/dist/render/validate.js +9 -0
  51. package/dist/themes/index.d.ts +84 -0
  52. package/dist/themes/index.js +90 -0
  53. package/dist/view/dive.d.ts +55 -0
  54. package/dist/view/dive.js +57 -0
  55. package/dist/view/navigate.d.ts +38 -0
  56. package/dist/view/navigate.js +81 -0
  57. package/dist/view/resolve.d.ts +92 -0
  58. package/dist/view/resolve.js +591 -0
  59. package/fonts/inter-400.ttf +0 -0
  60. package/fonts/inter-500.ttf +0 -0
  61. package/fonts/inter-600.ttf +0 -0
  62. package/fonts/mono-400.ttf +0 -0
  63. package/metrics.json +510 -0
  64. package/package.json +89 -0
@@ -0,0 +1,591 @@
1
+ /** `->`/`~>` point one way, `<->` both, `--` neither. */
2
+ const headsOf = (a) => a === "<->" ? "both" : a === "--" ? "none" : "one";
3
+ /** Effective animation for a declared edge. Values were validated at build
4
+ * time, so anything present is trusted here. */
5
+ const animateOf = (e) => {
6
+ const a = e.attrs.animate;
7
+ if (a === "false")
8
+ return undefined;
9
+ if (a)
10
+ return a;
11
+ return e.arrow === "~>" ? "flow" : undefined;
12
+ };
13
+ /** Effective dash pattern. `packets` supplies its own, so it needs no style;
14
+ * `solid` normalizes to undefined (it is the sync default, and build errors
15
+ * it on async edges before this runs). */
16
+ const styleOf = (e) => {
17
+ const s = e.attrs.style;
18
+ if (s === "dashed" || s === "dotted")
19
+ return s;
20
+ return e.arrow === "~>" ? "dashed" : undefined;
21
+ };
22
+ const parentOf = (p) => (p.includes(".") ? p.slice(0, p.lastIndexOf(".")) : "");
23
+ const topOf = (p) => p.split(".")[0];
24
+ export function resolveView(model, view) {
25
+ const diagnostics = [];
26
+ const scope = view.scope ?? "";
27
+ const effectiveTags = (path) => {
28
+ const own = model.nodes.get(path)?.tags ?? model.containers.get(path)?.tags ?? [];
29
+ const tags = [...own];
30
+ let p = parentOf(path);
31
+ while (p) {
32
+ tags.push(...(model.containers.get(p)?.tags ?? []));
33
+ p = parentOf(p);
34
+ }
35
+ return [...new Set(tags)];
36
+ };
37
+ // a tag target expands to every element whose EFFECTIVE tags match —
38
+ // inherited tags count (SPEC: tags inherit to everything inside).
39
+ // Deterministic order: containers in declaration order, then nodes.
40
+ const tagMatches = (tag) => [
41
+ ...[...model.containers.keys()].filter((p) => p && effectiveTags(p).includes(tag)),
42
+ ...[...model.nodes.keys()].filter((p) => effectiveTags(p).includes(tag)),
43
+ ];
44
+ // ── 1. scope children ────────────────────────────────────────────────────
45
+ let visible = scope
46
+ ? [...(model.containers.get(scope)?.children ?? [])]
47
+ : [
48
+ ...[...model.nodes.keys()].filter((p) => !p.includes(".")),
49
+ ...[...model.containers.keys()].filter((p) => !p.includes(".")),
50
+ ];
51
+ // expand: inline the container's children inside a rendered frame.
52
+ // One level only, by the rule-stack design (§5 rule 1): frames do not nest —
53
+ // unless the view says `expand *`, the one deliberate ladder, which opens
54
+ // every visible container to leaf depth and lets the frames it creates nest.
55
+ // For explicit expands the rule stands: the layout's single-level flattening
56
+ // of a *partial* ladder would leave "which levels am I looking at?"
57
+ // ambiguous, so the depth an author wants piecemeal is a deeper view.
58
+ const frames = [];
59
+ const frameOf = new Map(); // child path → frame path
60
+ if (view.expandStar) {
61
+ if (view.expand.length)
62
+ diagnostics.push({
63
+ severity: "warning",
64
+ message: "`expand *` already opens every container — the explicit `expand` lines are redundant",
65
+ fix: "drop the explicit expand lines",
66
+ loc: view.loc,
67
+ });
68
+ // Recursively open everything. An empty container stays a card: a
69
+ // childless frame is the 0×0 ELK failure the one-level rule was built
70
+ // against, and the "everything" view must never silently drop an element.
71
+ const opened = [];
72
+ const open = (path, parent) => {
73
+ const c = model.containers.get(path);
74
+ if (!c || c.children.length === 0) {
75
+ opened.push(path);
76
+ return;
77
+ }
78
+ frames.push({ path, label: c.label ?? c.name, frame: parent, color: c.color });
79
+ for (const child of c.children) {
80
+ frameOf.set(child, path);
81
+ open(child, path);
82
+ }
83
+ };
84
+ for (const p of visible)
85
+ open(p);
86
+ visible = opened;
87
+ if (frames.length === 0)
88
+ diagnostics.push({
89
+ severity: "warning",
90
+ message: "`expand *` opened nothing — no containers are visible here",
91
+ fix: "drop the line; every element is already at full depth",
92
+ loc: view.loc,
93
+ });
94
+ }
95
+ else {
96
+ const expandSet = new Set(view.expand);
97
+ for (const ex of view.expand) {
98
+ let outer;
99
+ for (let p = parentOf(ex); p; p = parentOf(p))
100
+ if (expandSet.has(p))
101
+ outer = p;
102
+ if (outer) {
103
+ diagnostics.push({
104
+ severity: "error",
105
+ message: `expand \`${ex}\` sits inside \`${outer}\`, which this view also expands — a view opens one level of depth`,
106
+ fix: `give the inner container its own view: \`scope ${outer}\` + \`expand ${ex.slice(outer.length + 1)}\` — its card here dives there. Or open every level at once with \`expand *\``,
107
+ loc: view.loc,
108
+ });
109
+ continue;
110
+ }
111
+ const i = visible.indexOf(ex);
112
+ const c = model.containers.get(ex);
113
+ if (i >= 0 && c) {
114
+ frames.push({ path: ex, label: c.label ?? c.name, color: c.color });
115
+ for (const child of c.children)
116
+ frameOf.set(child, ex);
117
+ visible.splice(i, 1, ...c.children);
118
+ }
119
+ else if (!c && model.nodes.has(ex)) {
120
+ diagnostics.push({
121
+ severity: "warning",
122
+ message: `expand \`${ex}\` targets a leaf — only containers open`,
123
+ fix: `drop the line; a leaf is already drawn at full depth`,
124
+ loc: view.loc,
125
+ });
126
+ }
127
+ else if (c) {
128
+ diagnostics.push({
129
+ severity: "warning",
130
+ message: `expand \`${ex}\` is not among the scope's direct children — nothing opens`,
131
+ fix: `\`expand\` opens the scope's own children; an outside element is drawn at depth with \`detail ${ex}\``,
132
+ loc: view.loc,
133
+ });
134
+ }
135
+ }
136
+ }
137
+ const visSet = () => new Set(visible);
138
+ /** Lift targets: the visible set plus expanded frames that still hold at
139
+ * least one visible member. An expanded container is spliced out of
140
+ * `visible`, but its frame is a real drawable entity — an edge naming the
141
+ * container attaches to the frame border (layout hands ELK the compound
142
+ * id, layout.ts's "ports live on leaves" comment). A frame `only` or
143
+ * `exclude` has emptied is dead: it must neither render nor catch lifts. */
144
+ const liveFramePaths = () => frames.filter((f) => visible.some((p) => p.startsWith(`${f.path}.`))).map((f) => f.path);
145
+ // ── 3. only: the view's filter ───────────────────────────────────────────
146
+ // `scope` answers *where I stand*; `only` answers *which of that I keep*.
147
+ // The language had no second axis, so a cross-cutting concern — the entire
148
+ // reason tags exist — could not be selected at all: `include #pci` adds to a
149
+ // set that already contains it, `highlight` decorates without removing, and
150
+ // an auditor was left enumerating the complement by id.
151
+ //
152
+ // It runs after `expand` so it filters an expanded interior too, and before
153
+ // context so neighbours are earned against the *reduced* interior. That
154
+ // second ordering is not a special case — it is the existing rule that
155
+ // derived content follows visibility. Shrink the interior and fewer edges
156
+ // cross, so fewer neighbours qualify, automatically.
157
+ //
158
+ // A container survives if it or anything beneath it matches: at a high
159
+ // altitude the tagged things are usually leaves inside the cards, and a
160
+ // filter that dropped every card because the card itself is untagged would
161
+ // render an empty diagram for the most natural way to ask the question.
162
+ if (view.only?.length) {
163
+ const keep = new Set();
164
+ for (const on of view.only) {
165
+ const targets = typeof on === "string" ? [on] : tagMatches(on.tag);
166
+ if (targets.length === 0)
167
+ diagnostics.push({
168
+ severity: "warning",
169
+ message: typeof on === "string"
170
+ ? `only \`${on}\`: no such element`
171
+ : `only #${on.tag}: nothing is tagged #${on.tag}`,
172
+ loc: view.loc,
173
+ });
174
+ // a match keeps itself, every visible ancestor holding it, and every
175
+ // visible descendant inside it — naming a container in an expanded view
176
+ // must keep its opened interior, not strand an empty frame
177
+ for (const t of targets)
178
+ for (const p of visible)
179
+ if (p === t || t.startsWith(`${p}.`) || p.startsWith(`${t}.`))
180
+ keep.add(p);
181
+ }
182
+ const dropped = visible.filter((p) => !keep.has(p));
183
+ visible = visible.filter((p) => keep.has(p));
184
+ if (!visible.length)
185
+ diagnostics.push({
186
+ severity: "warning",
187
+ message: "`only` filtered out everything — this view renders empty",
188
+ fix: "check the ids and tags; `only` keeps matches, it does not add them",
189
+ loc: view.loc,
190
+ });
191
+ else if (!dropped.length)
192
+ diagnostics.push({
193
+ severity: "warning",
194
+ message: "`only` changed nothing — everything here already matches",
195
+ fix: "drop the line, or narrow it further",
196
+ loc: view.loc,
197
+ });
198
+ }
199
+ /** Nearest visible ancestor-or-self, given the current visible set. */
200
+ const liftIn = (path, v) => {
201
+ let p = path;
202
+ while (p) {
203
+ if (v.has(p))
204
+ return p;
205
+ p = parentOf(p);
206
+ }
207
+ return undefined;
208
+ };
209
+ // ── 4. context neighbors (top-level lift, earned against the filtered interior) ──────────────────
210
+ const contextSet = new Set();
211
+ if (view.context === "auto") {
212
+ // frames count as inside: `client -> cluster` with the cluster expanded
213
+ // must still earn `client` its context card, exactly as a leaf target would
214
+ const v = new Set([...visible, ...liveFramePaths()]);
215
+ for (const e of model.edges) {
216
+ const fIn = liftIn(e.from, v);
217
+ const tIn = liftIn(e.to, v);
218
+ if (!!fIn === !!tIn)
219
+ continue; // both in or both out
220
+ const outside = fIn ? e.to : e.from;
221
+ const candidate = liftIn(outside, v) ?? topOf(outside);
222
+ // Context shows how the scope connects *outward*, so the scope itself can
223
+ // never be its own neighbour. Before `only` this was unreachable: the one
224
+ // way to lose a sibling was `exclude`, which runs after this. Filtering
225
+ // the interior makes it reachable — an edge to a filtered-out sibling
226
+ // lifts to the container we are standing in, and the view would draw a
227
+ // muted card of itself. A sibling removed on purpose is simply gone.
228
+ if (scope && (candidate === scope || scope.startsWith(`${candidate}.`)))
229
+ continue;
230
+ if (!v.has(candidate))
231
+ contextSet.add(candidate);
232
+ }
233
+ }
234
+ visible.push(...contextSet);
235
+ // Explicitly named elements are exempt from the context rules below: they
236
+ // never have to EARN their spot, and edges among them render even though both
237
+ // endpoints are context-styled — the user asked for them.
238
+ const explicitSet = new Set();
239
+ // ── 5. detail: draw an outside element at its own depth ──────────────────
240
+ // This was `include`'s second, unadvertised job. One verb meaning both "add
241
+ // this element" and "…and redraw its whole branch at a different altitude" is
242
+ // why `include` could never be redefined to narrow: flipping it would have
243
+ // turned every altitude override into "delete the rest of the diagram". Split
244
+ // out, each verb has exactly one job and `only` above became possible.
245
+ for (const d of view.detail ?? []) {
246
+ if (!visible.includes(d)) {
247
+ visible.push(d);
248
+ if (!scope || !d.startsWith(`${scope}.`))
249
+ contextSet.add(d);
250
+ }
251
+ explicitSet.add(d);
252
+ const top = topOf(d);
253
+ if (contextSet.has(top) && top !== d) {
254
+ contextSet.delete(top);
255
+ visible = visible.filter((p) => p !== top);
256
+ }
257
+ }
258
+ // ── 6. include: purely additive ──────────────────────────────────────────
259
+ for (const inc of view.include) {
260
+ const targets = typeof inc === "string" ? [inc] : tagMatches(inc.tag);
261
+ if (typeof inc !== "string" && targets.length === 0)
262
+ diagnostics.push({
263
+ severity: "warning",
264
+ message: `include #${inc.tag}: nothing is tagged #${inc.tag}`,
265
+ loc: view.loc,
266
+ });
267
+ let added = 0;
268
+ for (const target of targets) {
269
+ explicitSet.add(target);
270
+ if (visible.includes(target))
271
+ continue;
272
+ added++;
273
+ visible.push(target);
274
+ if (!scope || !target.startsWith(`${scope}.`))
275
+ contextSet.add(target);
276
+ // The element is now drawn *and* so is the top-level card standing in for
277
+ // its branch — two cards for one thing. That is what `detail` is for.
278
+ const top = topOf(target);
279
+ if (contextSet.has(top) && top !== target)
280
+ diagnostics.push({
281
+ severity: "warning",
282
+ message: `\`${target}\` is included, but \`${top}\` is already here as a context card`,
283
+ fix: `use \`detail ${target}\` to draw it at that depth instead of \`${top}\``,
284
+ loc: view.loc,
285
+ });
286
+ }
287
+ // `include` ADDS to a view (SPEC §5 rule stack); it cannot narrow one.
288
+ // Reading it as a filter is the natural mistake — a cold-run agent wrote
289
+ // `include #pci` for "show only the PCI parts", got a clean check and an
290
+ // unfiltered diagram. Silence there is the bug.
291
+ if (added === 0 && targets.length > 0)
292
+ diagnostics.push({
293
+ severity: "warning",
294
+ message: typeof inc === "string"
295
+ ? `include \`${inc}\` changed nothing — it is already visible here`
296
+ : `include #${inc.tag} changed nothing — every match is already visible here`,
297
+ fix: typeof inc === "string"
298
+ ? "`include` adds elements to a view; drop the line, or did you mean `exclude`?"
299
+ : `\`include\` adds elements, it cannot narrow a view. For only the ` +
300
+ `#${inc.tag} parts use \`only #${inc.tag}\`; to keep the whole picture with ` +
301
+ `those emphasised use \`highlight #${inc.tag}\``,
302
+ loc: view.loc,
303
+ });
304
+ }
305
+ // ── 7. exclude wins last (removes whole subtrees) ─────────────────────────
306
+ for (const exc of view.exclude) {
307
+ const targets = typeof exc === "string" ? [exc] : tagMatches(exc.tag);
308
+ if (typeof exc !== "string" && targets.length === 0)
309
+ diagnostics.push({
310
+ severity: "warning",
311
+ message: `exclude #${exc.tag}: nothing is tagged #${exc.tag}`,
312
+ loc: view.loc,
313
+ });
314
+ for (const target of targets) {
315
+ visible = visible.filter((p) => p !== target && !p.startsWith(`${target}.`));
316
+ contextSet.delete(target);
317
+ }
318
+ }
319
+ // ── 5. edge lifting + aggregation over the final visible set ─────────────
320
+ // Native edges (endpoints unchanged by lifting) always render individually —
321
+ // parallel edges are legal and distinct at their own altitude. Only LIFTED
322
+ // edges aggregate into count-badged neutrals.
323
+ const liveFrames = frames.filter((f) => visible.some((p) => p.startsWith(`${f.path}.`)));
324
+ const frameSet = new Set(liveFrames.map((f) => f.path));
325
+ const v = new Set([...visible, ...frameSet]);
326
+ const groups = new Map();
327
+ const edges = [];
328
+ const groupSlot = new Map(); // key → index in edges[]
329
+ for (const e of model.edges) {
330
+ const f = liftIn(e.from, v);
331
+ const t = liftIn(e.to, v);
332
+ // `f === t` covers two different things. An edge whose ends both lift into
333
+ // one card is genuinely internal at this altitude and drops on purpose. An
334
+ // edge a node draws to *itself* is not: it parsed, validated, sits in the
335
+ // model, and then vanished with nothing said — and a self-edge is the one
336
+ // shape where "from and to landed in the same place" is what the author
337
+ // wrote rather than a consequence of altitude.
338
+ if (f && t && f === t && e.from === e.to) {
339
+ diagnostics.push({
340
+ severity: "warning",
341
+ message: `\`${e.from}\` connects to itself — a self-edge is not drawn`,
342
+ fix: `say it on the node instead: a \`note right-of ${e.from} "${e.label ?? "…"}"\`, `
343
+ + `or fold it into the label`,
344
+ loc: view.loc,
345
+ });
346
+ continue;
347
+ }
348
+ if (!f || !t || f === t)
349
+ continue;
350
+ // One end is an expanded frame and the other lifted to something inside
351
+ // it: internal at this altitude — the same silent drop as both ends
352
+ // lifting into one card (SPEC §5 lifting), just with the card held open.
353
+ if (frameSet.has(f) && t.startsWith(`${f}.`))
354
+ continue;
355
+ if (frameSet.has(t) && f.startsWith(`${t}.`))
356
+ continue;
357
+ const lifted = f !== e.from || t !== e.to;
358
+ if (!lifted) {
359
+ edges.push({
360
+ id: e.id, from: f, to: t, label: e.label, async: e.arrow === "~>",
361
+ animate: animateOf(e), style: styleOf(e), count: 1,
362
+ tags: e.tags, color: e.color, heads: headsOf(e.arrow),
363
+ });
364
+ continue;
365
+ }
366
+ const key = `${f}|${t}`;
367
+ if (!groups.has(key)) {
368
+ groups.set(key, { edges: [], from: f, to: t });
369
+ groupSlot.set(key, edges.push(null) - 1); // reserve slot in declaration order
370
+ }
371
+ groups.get(key).edges.push(e);
372
+ }
373
+ for (const [key, g] of groups) {
374
+ const slot = groupSlot.get(key);
375
+ if (g.edges.length === 1) {
376
+ const e = g.edges[0];
377
+ edges[slot] = {
378
+ id: e.id, from: g.from, to: g.to, label: e.label, async: e.arrow === "~>",
379
+ animate: animateOf(e), style: styleOf(e), count: 1,
380
+ tags: e.tags, color: e.color, heads: headsOf(e.arrow),
381
+ };
382
+ }
383
+ else {
384
+ // A trunk only claims styling every member agrees on (same rule as
385
+ // `heads` below): all-agree keeps the animation and pattern, any
386
+ // disagreement falls back to neutral rather than asserting something
387
+ // only some constituents said. SPEC §lifting rule 4 states this.
388
+ const agreedAnimate = animateOf(g.edges[0]);
389
+ const agreedStyle = styleOf(g.edges[0]);
390
+ edges[slot] = {
391
+ id: `agg:${g.from}|${g.to}`,
392
+ from: g.from, to: g.to,
393
+ label: `×${g.edges.length}`,
394
+ async: g.edges.every((e) => e.arrow === "~>"),
395
+ animate: g.edges.every((e) => animateOf(e) === agreedAnimate) ? agreedAnimate : undefined,
396
+ style: g.edges.every((e) => styleOf(e) === agreedStyle) ? agreedStyle : undefined,
397
+ count: g.edges.length,
398
+ tags: [...new Set(g.edges.flatMap((e) => e.tags))],
399
+ color: g.edges.every((e) => e.color === g.edges[0].color) ? g.edges[0].color : undefined,
400
+ // A trunk only claims a shape every member agrees on; a mixed bundle
401
+ // falls back to the plain arrow rather than asserting something false.
402
+ heads: g.edges.every((e) => e.arrow === "<->") ? "both"
403
+ : g.edges.every((e) => e.arrow === "--") ? "none" : "one",
404
+ };
405
+ }
406
+ }
407
+ // Context exists to show how the scope connects outward. An edge between two
408
+ // *outsiders* is their business, not this view's — suppress it, or zooming
409
+ // into one service drags in the whole neighbourhood's wiring.
410
+ const scopeEdges = edges.filter((e) => !(contextSet.has(e.from) && contextSet.has(e.to) &&
411
+ !explicitSet.has(e.from) && !explicitSet.has(e.to)));
412
+ // context cards must earn their spot: drop any without a surviving edge —
413
+ // except explicit includes, which were asked for by name
414
+ for (const c of [...contextSet]) {
415
+ if (explicitSet.has(c))
416
+ continue;
417
+ if (!scopeEdges.some((e) => e.from === c || e.to === c)) {
418
+ contextSet.delete(c);
419
+ visible = visible.filter((p) => p !== c);
420
+ }
421
+ }
422
+ // frames are drawable endpoints too — an edge lifted to an expanded
423
+ // container attaches to its frame border
424
+ const drawable = new Set([...visible, ...frameSet]);
425
+ const finalEdges = scopeEdges.filter((e) => drawable.has(e.from) && drawable.has(e.to));
426
+ // ── 6. materialize nodes ──────────────────────────────────────────────────
427
+ const leafDescendants = (path) => {
428
+ const c = model.containers.get(path);
429
+ if (!c)
430
+ return [path];
431
+ return c.children.flatMap(leafDescendants);
432
+ };
433
+ const nodes = visible.map((path) => {
434
+ const isContext = contextSet.has(path);
435
+ const container = model.containers.get(path);
436
+ if (container) {
437
+ const leaves = leafDescendants(path);
438
+ const previewMode = container.attrs["preview"] ?? "auto";
439
+ const icons = leaves
440
+ .map((l) => model.nodes.get(l)?.icon)
441
+ .filter((i) => !!i);
442
+ const preview = previewMode === "none" ? [] : icons.slice(0, 3);
443
+ const glyphRef = container.attrs["glyph"];
444
+ const glyph = glyphRef?.includes("/")
445
+ ? { pack: glyphRef.split("/")[0], id: glyphRef.split("/")[1] }
446
+ : undefined;
447
+ // The card's own mark. Authored `icon:` wins; otherwise the first leaf
448
+ // icon stands in, so a system that never named one still gets a plate
449
+ // instead of a bare label — the same icon the preview strip leads with,
450
+ // which is what a reader already associates with that card.
451
+ const iconRef = container.attrs["icon"];
452
+ const icon = iconRef?.includes("/")
453
+ ? { pack: iconRef.split("/")[0], id: iconRef.split("/")[1] }
454
+ : icons[0];
455
+ return {
456
+ path,
457
+ kind: isContext ? "context-card" : "card",
458
+ label: container.label ?? container.name,
459
+ glyph,
460
+ icon,
461
+ more: icons.length - preview.length || undefined,
462
+ domain: container.attrs["domain"],
463
+ tagline: container.attrs["description"] ??
464
+ `${leaves.length} component${leaves.length === 1 ? "" : "s"}`,
465
+ preview,
466
+ tags: effectiveTags(path),
467
+ external: container.kinds.includes("external") || undefined,
468
+ frame: frameOf.get(path),
469
+ color: container.color,
470
+ };
471
+ }
472
+ const n = model.nodes.get(path);
473
+ // Lenient like the container glyph split: validation happened at check
474
+ // time, and a half-typed value in the editor must not throw here.
475
+ const badgeRef = n.attrs["badge"];
476
+ const badge = badgeRef?.includes("/")
477
+ ? { pack: badgeRef.split("/")[0], id: badgeRef.split("/")[1] }
478
+ : undefined;
479
+ return {
480
+ path,
481
+ kind: isContext ? "context-leaf" : n.kinds.includes("person") ? "person" : "leaf",
482
+ label: n.label,
483
+ icon: n.icon,
484
+ badge,
485
+ preview: [],
486
+ tags: effectiveTags(path),
487
+ external: n.kinds.includes("external") || undefined,
488
+ description: n.description,
489
+ frame: frameOf.get(path),
490
+ color: n.color,
491
+ };
492
+ });
493
+ // ── view colour lens (`color #tag hue`) ───────────────────────────────────
494
+ // A lens over the model, so it wins over an element's own `color:`; among
495
+ // statements, declaration order and the last one wins — but two different
496
+ // hues landing on one element is almost always two tags that were meant to
497
+ // be disjoint, so it is said once per element rather than resolved silently.
498
+ // tolerant of a hand-built SView with no `colors` (tests, older callers)
499
+ const colorStmts = view.colors ?? [];
500
+ const viewHue = (tags, subject) => {
501
+ const hits = colorStmts.filter((c) => tags.includes(c.tag));
502
+ if (!hits.length)
503
+ return undefined;
504
+ const hues = new Set(hits.map((h) => h.hue));
505
+ if (hues.size > 1) {
506
+ const last = hits[hits.length - 1];
507
+ const other = hits.find((h) => h.hue !== last.hue);
508
+ diagnostics.push({
509
+ severity: "warning",
510
+ message: `${subject} is tagged #${other.tag} (${other.hue}) and #${last.tag} (${last.hue}) — #${last.tag} wins`,
511
+ fix: `give both tags the same hue, or narrow one of them`,
512
+ loc: last.loc,
513
+ });
514
+ }
515
+ return hits[hits.length - 1].hue;
516
+ };
517
+ if (colorStmts.length) {
518
+ for (const n of nodes)
519
+ n.color = viewHue(n.tags, `\`${n.path}\``) ?? n.color;
520
+ for (const e of finalEdges)
521
+ e.color = viewHue(e.tags, `${e.from} → ${e.to}`) ?? e.color;
522
+ for (const f of frames)
523
+ f.color = viewHue(effectiveTags(f.path), `\`${f.path}\``) ?? f.color;
524
+ for (const c of colorStmts)
525
+ if (!nodes.some((n) => n.tags.includes(c.tag))
526
+ && !finalEdges.some((e) => e.tags.includes(c.tag))
527
+ && !frames.some((f) => effectiveTags(f.path).includes(c.tag)))
528
+ diagnostics.push({
529
+ severity: "warning",
530
+ message: `color #${c.tag}: nothing visible here is tagged #${c.tag}`,
531
+ fix: `nothing would take the colour — check the tag, or the view's \`include\`/\`only\``,
532
+ loc: c.loc,
533
+ });
534
+ }
535
+ // ── flow badges (SPEC §Flows): map each step onto the edge that renders
536
+ // it at this altitude — steps whose endpoints lift into the same card
537
+ // simply don't appear here.
538
+ let flow;
539
+ if (view.showFlow) {
540
+ const f = model.flows.find((fl) => fl.id === view.showFlow);
541
+ if (f) {
542
+ const v = visSet();
543
+ const byEdge = {};
544
+ f.steps.forEach((step, i) => {
545
+ const from = liftIn(step.from, v);
546
+ const to = liftIn(step.to, v);
547
+ if (!from || !to || from === to)
548
+ return;
549
+ const edge = finalEdges.find((e) => (e.from === from && e.to === to) || (e.from === to && e.to === from));
550
+ if (!edge)
551
+ return;
552
+ (byEdge[edge.id] ??= []).push(i + 1);
553
+ });
554
+ flow = { label: f.label ?? f.id, byEdge };
555
+ }
556
+ }
557
+ // `highlight` never came through here. `include`, `exclude` and `only` each
558
+ // warn when a tag matches nothing — the same typo, caught three times out of
559
+ // four — but `highlight` is collected by the parser and handed straight to
560
+ // the renderer, so `highlight #pcii` dimmed the whole diagram and emphasised
561
+ // nothing, silently. Match against what this view can actually see: a tag
562
+ // that exists elsewhere in the model is still useless here.
563
+ // Edges count. `highlight #hot-path` on an edge-only tag is a documented and
564
+ // working idiom — `api -> create { tags: #hot-path }` — and the first cut of
565
+ // this warning checked nodes alone, so it fired on a correct diagram the very
566
+ // next gauntlet round. A guard that is narrower than the thing it guards is
567
+ // worse than none.
568
+ for (const tag of view.highlight)
569
+ if (!nodes.some((n) => n.tags.includes(tag))
570
+ && !finalEdges.some((e) => e.tags.includes(tag)))
571
+ diagnostics.push({
572
+ severity: "warning",
573
+ message: `highlight #${tag}: nothing visible here is tagged #${tag}`,
574
+ fix: `everything would dim and nothing would stand out — check the tag, or the view's \`include\`/\`only\``,
575
+ loc: view.loc,
576
+ });
577
+ // A view that resolves to nothing renders a blank canvas. The empty-container
578
+ // case is caught at build time, but that is one cause of many: excluding
579
+ // everything, scoping to something that isn't there, or a filter that keeps
580
+ // nothing all land here too.
581
+ if (!nodes.length)
582
+ diagnostics.push({
583
+ severity: "warning",
584
+ message: `view \`${view.name}\` has nothing to draw`,
585
+ fix: "every element is filtered out — check `scope`, `include`, `only` and `exclude`",
586
+ loc: view.loc,
587
+ });
588
+ // liveFrames, not frames: a frame `exclude`/`only` emptied has no members
589
+ // to size it and would reach ELK as a childless compound — a 0×0 rect.
590
+ return { nodes, edges: finalEdges, frames: liveFrames, flow, diagnostics };
591
+ }
Binary file
Binary file
Binary file
Binary file