@issuegraph/viewer 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 (67) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +156 -0
  3. package/dist/clusters.d.ts +34 -0
  4. package/dist/clusters.d.ts.map +1 -0
  5. package/dist/clusters.js +195 -0
  6. package/dist/clusters.js.map +1 -0
  7. package/dist/document.d.ts +190 -0
  8. package/dist/document.d.ts.map +1 -0
  9. package/dist/document.js +308 -0
  10. package/dist/document.js.map +1 -0
  11. package/dist/element.d.ts +95 -0
  12. package/dist/element.d.ts.map +1 -0
  13. package/dist/element.js +174 -0
  14. package/dist/element.js.map +1 -0
  15. package/dist/index.d.ts +40 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +33 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/layout.d.ts +122 -0
  20. package/dist/layout.d.ts.map +1 -0
  21. package/dist/layout.js +297 -0
  22. package/dist/layout.js.map +1 -0
  23. package/dist/mount.d.ts +64 -0
  24. package/dist/mount.d.ts.map +1 -0
  25. package/dist/mount.js +402 -0
  26. package/dist/mount.js.map +1 -0
  27. package/dist/navigation.d.ts +56 -0
  28. package/dist/navigation.d.ts.map +1 -0
  29. package/dist/navigation.js +121 -0
  30. package/dist/navigation.js.map +1 -0
  31. package/dist/parts.d.ts +89 -0
  32. package/dist/parts.d.ts.map +1 -0
  33. package/dist/parts.js +214 -0
  34. package/dist/parts.js.map +1 -0
  35. package/dist/projections/graph.d.ts +30 -0
  36. package/dist/projections/graph.d.ts.map +1 -0
  37. package/dist/projections/graph.js +644 -0
  38. package/dist/projections/graph.js.map +1 -0
  39. package/dist/projections/linear.d.ts +35 -0
  40. package/dist/projections/linear.d.ts.map +1 -0
  41. package/dist/projections/linear.js +157 -0
  42. package/dist/projections/linear.js.map +1 -0
  43. package/dist/projections/tree.d.ts +17 -0
  44. package/dist/projections/tree.d.ts.map +1 -0
  45. package/dist/projections/tree.js +209 -0
  46. package/dist/projections/tree.js.map +1 -0
  47. package/dist/render.d.ts +35 -0
  48. package/dist/render.d.ts.map +1 -0
  49. package/dist/render.js +45 -0
  50. package/dist/render.js.map +1 -0
  51. package/dist/scene.d.ts +94 -0
  52. package/dist/scene.d.ts.map +1 -0
  53. package/dist/scene.js +48 -0
  54. package/dist/scene.js.map +1 -0
  55. package/dist/styles.d.ts +17 -0
  56. package/dist/styles.d.ts.map +1 -0
  57. package/dist/styles.js +389 -0
  58. package/dist/styles.js.map +1 -0
  59. package/dist/theme.d.ts +71 -0
  60. package/dist/theme.d.ts.map +1 -0
  61. package/dist/theme.js +169 -0
  62. package/dist/theme.js.map +1 -0
  63. package/dist/vocabulary.d.ts +107 -0
  64. package/dist/vocabulary.d.ts.map +1 -0
  65. package/dist/vocabulary.js +98 -0
  66. package/dist/vocabulary.js.map +1 -0
  67. package/package.json +56 -0
@@ -0,0 +1,644 @@
1
+ /**
2
+ * The graph projection: the ordered spine with gutters, and arcs for everything
3
+ * off it.
4
+ *
5
+ * Sequence is vertical position on the spine. Dependency is the arcs. Both
6
+ * readings work at once because they use different visual channels, which is
7
+ * the whole reason this shape was chosen over a force-directed or layered
8
+ * drawing — those make dependency legible and sequence a hunt, and their layout
9
+ * shifts between refreshes, which destroys trust in a panel whose job is to be
10
+ * authoritative.
11
+ *
12
+ * IT REFUSES RATHER THAN DEGRADES. The list scales; the canvas is a local
13
+ * instrument answering "what surrounds this issue", so past its budget it says
14
+ * so and offers the next move instead of drawing a hairball.
15
+ */
16
+ import { clustersOf } from "../clusters.js";
17
+ import { element, svg } from "../element.js";
18
+ import { edgeGeometry, enclosureBounds, fitLabel, layoutGraph, } from "../layout.js";
19
+ import { emptyState, legend, slotLabel, slotTitle, station, stationFill, stationsOf, atStations, } from "../parts.js";
20
+ import { resolveFocusKey } from "../scene.js";
21
+ import { defaultTheme } from "../theme.js";
22
+ import { dashArrayFor, treatmentFor } from "../vocabulary.js";
23
+ import { excludedRow, isFooterSlot } from "./linear.js";
24
+ /**
25
+ * The node budget, from the design's scale table. Above the first threshold the
26
+ * canvas shows component capsules; above the second, clusters only.
27
+ */
28
+ export const GRAPH_NODE_BUDGET = 60;
29
+ export const CLUSTER_ONLY_BUDGET = 300;
30
+ function terminalMarker(terminal, geometry, field, theme) {
31
+ const { end, endAngle } = geometry;
32
+ const degrees = (endAngle * 180) / Math.PI;
33
+ const transform = `translate(${end.x.toFixed(2)} ${end.y.toFixed(2)}) rotate(${degrees.toFixed(2)})`;
34
+ const common = { class: 'ig-terminal', 'data-edge': field, transform };
35
+ // Every dimension is theme data. A marker sized by a literal would stay put
36
+ // while a host scaled the type around it, and the shape channel the
37
+ // colour-blind-safety claim leans on is exactly what would stop reading.
38
+ const length = theme.metrics['--ig-terminal-length'];
39
+ const half = theme.metrics['--ig-terminal-width'] / 2;
40
+ switch (terminal) {
41
+ case 'arrow':
42
+ return svg('path', {
43
+ ...common,
44
+ d: `M 0 0 L ${String(-length)} ${String(-half)} L ${String(-length)} ${String(half)} Z`,
45
+ fill: 'currentColor',
46
+ });
47
+ case 'hollow-circle':
48
+ return svg('circle', {
49
+ ...common,
50
+ cx: -half,
51
+ cy: 0,
52
+ r: half,
53
+ fill: 'none',
54
+ stroke: 'currentColor',
55
+ });
56
+ case 'tee':
57
+ return svg('path', {
58
+ ...common,
59
+ d: `M 0 ${String(-half)} L 0 ${String(half)}`,
60
+ stroke: 'currentColor',
61
+ fill: 'none',
62
+ });
63
+ case 'none':
64
+ case 'enclosure':
65
+ return null;
66
+ }
67
+ }
68
+ /**
69
+ * One edge, drawn on all four channels: the path carries dash and hue, the
70
+ * marker carries the terminal, and the badge grammar carries the glyph.
71
+ *
72
+ * `serialize-with` is drawn as two parallel strokes rather than one dashed
73
+ * line — the "double" pattern is structural, so it cannot be confused with a
74
+ * dash under any theme.
75
+ */
76
+ function edgePaths(edge, geometry, theme) {
77
+ const treatment = treatmentFor(edge.field);
78
+ const dash = dashArrayFor(treatment.dash);
79
+ const label = `${edge.from} ${treatment.label} ${edge.to}`;
80
+ const base = {
81
+ class: 'ig-edge',
82
+ 'data-edge': edge.field,
83
+ d: geometry.d,
84
+ 'stroke-dasharray': dash,
85
+ role: 'img',
86
+ 'aria-label': label,
87
+ };
88
+ if (treatment.dash === 'double') {
89
+ // Separated by one stroke width, so the pair reads as two lines at any
90
+ // scale rather than merging once a host thickens the stroke.
91
+ const offset = theme.metrics['--ig-stroke'];
92
+ return [
93
+ svg('path', { ...base, transform: `translate(0 ${String(-offset)})` }),
94
+ svg('path', {
95
+ ...base,
96
+ transform: `translate(0 ${String(offset)})`,
97
+ 'aria-hidden': 'true',
98
+ role: null,
99
+ }),
100
+ ];
101
+ }
102
+ return [svg('path', base)];
103
+ }
104
+ /**
105
+ * One node on the canvas.
106
+ *
107
+ * A NODE THE SCENE PUBLISHES AS A NAVIGATION TARGET HAS TO BE FOCUSABLE. The
108
+ * rail draws only the ranked spine slots, so a tracker-held slot, a duplicate
109
+ * and every gutter node exist ONLY as this group — and an SVG group with no
110
+ * `tabindex` cannot take focus, so navigating to one called `focus()` on
111
+ * nothing and the visible tab stop vanished.
112
+ *
113
+ * `railed` names the keys the rail already draws, so exactly one element per
114
+ * key is tabbable; a second tab stop for the same issue would be worse than
115
+ * none. A focusable element also needs a name, hence `role`/`aria-label`.
116
+ */
117
+ function nodeShape(document, layout, key, options, theme, navigable) {
118
+ const box = layout.nodes.get(key);
119
+ if (box === undefined)
120
+ return null;
121
+ const issue = document.byKey.get(key);
122
+ const selected = options.selected === key;
123
+ const ownsTabStop = navigable.keys.includes(key) && !navigable.railed.has(key);
124
+ const full = issue?.title ?? key;
125
+ const drawn = fitLabel(theme, full, box.width);
126
+ // A HOLD THE RAIL DOES NOT CARRY HAS TO BE CARRIED HERE. The rail draws only
127
+ // the non-footer slots, so a tracker-held slot is filtered out of it — and the
128
+ // reason last round put on the rail row therefore never reached the graph at
129
+ // all for exactly those slots, while `ViewerHold` says the viewer renders the
130
+ // reason verbatim. Measured on the fixture: `claimed by another run` appeared
131
+ // nowhere in graph markup.
132
+ // I DEFERRED THIS ONCE ON A REASON THAT WAS WRONG, and it is worth recording:
133
+ // I said `nodeShape` "takes a key and an issue and knows nothing about slots",
134
+ // so carrying the reason would be a signature change touching every node. It
135
+ // takes the whole `document`, and the slots are on it. There was no signature
136
+ // change to make. See issue #41.
137
+ // ONLY FOR A NODE THE RAIL DOES NOT LABEL — a railed slot already carries its
138
+ // reason on its row, and repeating it here would announce the same sentence
139
+ // twice for one slot.
140
+ const heldBecause = navigable.railed.has(key)
141
+ ? ''
142
+ : document.order.slots
143
+ .filter((slot) => slot.lead === key || slot.members.includes(key))
144
+ .flatMap((slot) => slot.holds.map((hold) => hold.reason))
145
+ .join(' · ');
146
+ // THE POINTER MUST NOT NAME AN IDENTITY THE KEYBOARD CANNOT REACH. A together
147
+ // unit is ONE station with one focus key, so its non-lead members are absent
148
+ // from `navigable` deliberately — and this published each of them as its own
149
+ // `data-ig-key` anyway. Measured on the fixture: clicking `104` emitted `104`,
150
+ // selected `104`, and threw focus to `102` — not even the unit that was
151
+ // clicked, because `resolveFocusKey` found neither the selection nor the
152
+ // requested key in the order and fell back to its first entry. No keyboard can
153
+ // produce that state.
154
+ // ROUTED TO THE STATION, NOT MADE A STATION. Giving the partner its own focus
155
+ // key is the other repair codex offered and it is the one round ten already
156
+ // rejected: it splits the unit into two stations, which `navigation.test.ts`
157
+ // forbids in as many words. So the member keeps its node and loses only its
158
+ // FOCUS identity, which it never legitimately had.
159
+ // `GROUP_ATTRIBUTE` IS EXACTLY THIS CHANNEL — the enclosure and the connector
160
+ // already answer a pointer with their unit's lead through it, and a member's
161
+ // node is the same question about the same unit. `keyAt` reads it as the
162
+ // fallback the focus index never sees.
163
+ // A KEY NO SLOT REPRESENTS FALLS BACK TO ITSELF, which is no worse than what
164
+ // it published before; the orphan pass above is what makes such a key
165
+ // navigable, and the invariant test is what proves it did.
166
+ const published = navigable.keys.includes(key);
167
+ const station = document.order.slots.find((slot) => slot.members.includes(key))?.lead ?? key;
168
+ return svg('g', {
169
+ class: 'ig-node-group',
170
+ 'data-ig-key': published ? key : null,
171
+ 'data-ig-group': published ? null : station,
172
+ 'data-column': box.column,
173
+ 'aria-current': selected ? 'true' : 'false',
174
+ role: ownsTabStop ? 'img' : null,
175
+ // THE REASON RIDES THE NAME WHEN THERE IS ONE, on the same channels the
176
+ // rail row uses, so one hold reads the same whichever surface drew it.
177
+ 'aria-label': ownsTabStop
178
+ ? heldBecause === ''
179
+ ? `${full} — ${key}`
180
+ : `${full} — ${key} — ${heldBecause}`
181
+ : null,
182
+ tabindex: ownsTabStop ? (navigable.focused === key ? 0 : -1) : null,
183
+ }, [
184
+ // AND ON THE POINTER CHANNEL, which is also the only one left for a node
185
+ // that owns no tab stop and therefore carries no `aria-label` at all.
186
+ // First child, because that is where SVG looks for `<title>`.
187
+ heldBecause === '' ? null : svg('title', {}, [`${full} — ${heldBecause}`]),
188
+ svg('rect', {
189
+ class: 'ig-node',
190
+ 'data-held': box.held ? 'true' : 'false',
191
+ x: box.x,
192
+ y: box.y,
193
+ width: box.width,
194
+ height: box.height,
195
+ rx: theme.metrics['--ig-radius'],
196
+ }),
197
+ // A node is labelled by its RAIL ROW when it has one, so labelling it
198
+ // here too would print the title twice — once selectable, once not. But
199
+ // the rail draws only the ranked slots, and keying this on the COLUMN
200
+ // rather than on the rail left a tracker-held slot as a blank rectangle:
201
+ // no title, no hold reason, nothing. The question is whether this key is
202
+ // railed, not which column it sits in.
203
+ navigable.railed.has(key)
204
+ ? null
205
+ : svg('text', {
206
+ class: 'ig-node-label',
207
+ x: box.x + theme.metrics['--ig-space'],
208
+ // `dominant-baseline` centres the glyphs on the line rather than
209
+ // a remembered offset, so the label stays centred at any scale.
210
+ y: box.y + box.height / 2,
211
+ 'dominant-baseline': 'middle',
212
+ }, [
213
+ // THE FULL TITLE IS NOT LOST WHEN IT DOES NOT FIT — and it has to
214
+ // be a CHILD ELEMENT to do that job. This was written as a `title`
215
+ // ATTRIBUTE, which SVG ignores entirely: no tooltip, no accessible
216
+ // description, nothing. The markup contained the string, so a test
217
+ // asserting the full title was "somewhere in the markup" passed
218
+ // while a reader hovering the shortened label recovered nothing.
219
+ // FIRST CHILD, because that is where SVG looks for it, and only on
220
+ // truncation — an untruncated label already reads in full, and a
221
+ // `<title>` echoing it would announce it twice.
222
+ // A railed node never reaches here at all: the rail row is its
223
+ // label, and its own CSS ellipsis handles the same overflow.
224
+ drawn === full ? null : svg('title', {}, [full]),
225
+ drawn,
226
+ ]),
227
+ ]);
228
+ }
229
+ /**
230
+ * The spine's ranks and readiness stations, drawn as HTML ON the canvas.
231
+ *
232
+ * Text in SVG is not selectable, not reflowable and announces poorly, so the
233
+ * spine's rows stay HTML — but they are the labels FOR the spine nodes, and
234
+ * emitting them as a sibling block put them above the drawing instead of on it.
235
+ * Ranks and stations then described a picture the reader had to hold in their
236
+ * head, which is the opposite of the design's claim that the spine IS the
237
+ * order.
238
+ *
239
+ * So each row is positioned at the coordinates the LAYOUT computed for its own
240
+ * node — same source of truth as every edge endpoint. Hand-authoring these
241
+ * against remembered positions is the failure mode the design's implementation
242
+ * note names; the numbers ride custom properties so the theme still owns them.
243
+ */
244
+ /**
245
+ * What the rail draws — ONE rule, because two callers read it.
246
+ *
247
+ * `spineRail` renders these rows and the focus index has to publish exactly the
248
+ * same set, or the projection draws a keyed row nothing can reach. That is not
249
+ * hypothetical: widening the rail for the refusal without widening the index
250
+ * left the footer slot and the exclusion drawn with `data-ig-key` and absent
251
+ * from `navigable` — the identical defect the canvas had two rounds ago,
252
+ * reintroduced by fixing its sibling. Deriving both from here is what makes the
253
+ * two provably agree rather than agree by inspection.
254
+ *
255
+ * `positioned` is the refusal signal inverted: with a canvas, a footer slot is
256
+ * drawn as a canvas node and an exclusion sits off the spine entirely, so the
257
+ * rail carries neither. Without one, the rail IS the order.
258
+ */
259
+ function railContents(document, positioned) {
260
+ return positioned
261
+ ? { slots: document.order.slots.filter((slot) => !isFooterSlot(slot)), excluded: [] }
262
+ : { slots: document.order.slots, excluded: document.order.excluded };
263
+ }
264
+ function spineRail(document, layout, options, focused, positioned) {
265
+ // A REFUSAL'S RAIL IS THE WHOLE ORDER UI, so it must carry what the canvas
266
+ // would otherwise have drawn. `positioned` is exactly the refusal signal — the
267
+ // only `false` call site is the refusal arm — and when the canvas is absent, a
268
+ // footer slot has nothing to draw it and an exclusion has no row at all.
269
+ // Measured on a refused document: the tracker-held slot's title, its hold
270
+ // reason, and the excluded key were all absent from the markup, while the
271
+ // refusal's own text said "The order list is complete at any size". That claim
272
+ // was written into this file and it was false.
273
+ // FILTERED ONLY WHEN THE CANVAS DRAWS THEM. In ordinary graph mode a footer
274
+ // slot IS drawn, as a canvas node, so keeping it out of the rail is what stops
275
+ // one slot appearing twice — the filter is right there and wrong here.
276
+ const { slots, excluded } = railContents(document, positioned);
277
+ return element('ol',
278
+ // A plain list for the reason `linear.ts` gives: an interactive descendant
279
+ // may not live inside `role="option"`.
280
+ // WHEN THERE IS NO CANVAS THERE IS NOTHING TO SIT ON. A refusal draws no
281
+ // spine nodes, so the rail returns to ordinary flow rather than positioning
282
+ // itself against coordinates nothing rendered.
283
+ { class: positioned ? 'ig-list ig-rail' : 'ig-list', 'aria-label': 'work order' }, [
284
+ ...slots.map((slot) => {
285
+ const box = layout.nodes.get(slot.lead);
286
+ // THE REASON A SLOT IS HELD IS PART OF WHAT THIS ROW MEANS. `ViewerHold`
287
+ // says the viewer renders the reason verbatim, and the linear projection
288
+ // does — this one rendered rank, station and title, so a graph reader was
289
+ // told THAT a slot is held and never WHY. `data-held` styles the row, so
290
+ // the holding was visible and the host's sentence was not, on either the
291
+ // visible or the accessible channel.
292
+ // ON THE LABEL AND THE TOOLTIP, NOT AS A BLOCK IN THE ROW. This row is
293
+ // positioned onto its node's box — `--ig-row-h` IS the node height — so a
294
+ // paragraph per hold would overflow geometry the layout computed for a
295
+ // node, which is a worse defect than the one being fixed. `title` plus
296
+ // `aria-label` is how this package already carries text that cannot take
297
+ // space (see the edge badges): sighted readers hover, screen readers hear
298
+ // it, and the layout is untouched.
299
+ // ONLY HERE, not in `slotLabel`. That helper is shared with the linear
300
+ // projection, which renders the same holds as visible paragraphs — adding
301
+ // them there would announce every linear hold twice.
302
+ const heldBecause = slot.holds.map((hold) => hold.reason).join(' · ');
303
+ return element('li', {
304
+ class: positioned ? 'ig-slot ig-rail-row' : 'ig-slot',
305
+ 'data-ig-key': slot.lead,
306
+ 'data-held': slot.ready ? 'false' : 'true',
307
+ 'aria-current': options.selected === slot.lead ? 'true' : 'false',
308
+ 'aria-label': heldBecause === ''
309
+ ? slotLabel(document, slot)
310
+ : `${slotLabel(document, slot)} — ${heldBecause}`,
311
+ title: heldBecause === '' ? null : heldBecause,
312
+ tabindex: focused === slot.lead ? 0 : -1,
313
+ // Positioned from the layout, not from the flow, so a row sits on the
314
+ // node it names however the theme scales the geometry.
315
+ style: positioned && box !== undefined
316
+ ? `--ig-row-x:${String(box.x)}px;--ig-row-y:${String(box.y)}px;--ig-row-w:${String(box.width)}px;--ig-row-h:${String(box.height)}px`
317
+ : null,
318
+ }, [
319
+ element('span', { class: 'ig-rank', 'data-held': slot.ready ? 'false' : 'true', 'aria-hidden': 'true' }, [slot.rank === null ? '—' : String(slot.rank)]),
320
+ station(stationFill(slot)),
321
+ element('span', { class: 'ig-title' }, [slotTitle(document, slot)]),
322
+ ]);
323
+ }),
324
+ // AND THE EXCLUSIONS, on the same terms: they have no canvas node in any
325
+ // mode, so ordinary graph mode simply never showed them — which is correct
326
+ // there, because the spine rail sits ON the canvas and an exclusion is not
327
+ // on it. In a refusal there is no canvas, the rail is the entire order, and
328
+ // an exclusion left out is a row of the order that is missing.
329
+ ...excluded.map((exclusion) => excludedRow(document, exclusion.key, exclusion.canonical, { ...options, focused })),
330
+ ]);
331
+ }
332
+ /**
333
+ * The refusal.
334
+ *
335
+ * A refusal with a route forward reads as competence — but only if the route is
336
+ * one the reader can actually take. "Select one component to draw it" was not:
337
+ * the capsules carried no identity, nothing dispatched from them, and this
338
+ * package never narrows to a component. Advertising an action nobody can
339
+ * perform is worse than a plain refusal, so each capsule now carries its
340
+ * component's lead as a pointer identity (a host receives it through
341
+ * `onSelect`) and the instruction names what actually happens next — the host
342
+ * narrows the document, because narrowing IS the host's job in a package that
343
+ * renders exactly what it is given.
344
+ */
345
+ function refusal(document, layout, nodeCount, mode) {
346
+ // THE SAME KEY SET THE COUNT ABOVE CAME FROM. `nodeCount` is `layout.nodes`,
347
+ // so the component list has to partition `layout.nodes` or the two disagree —
348
+ // which is how an edge-free over-budget document listed no components at all
349
+ // under a sentence telling the reader to choose one.
350
+ const clusters = clustersOf(document, new Set(layout.nodes.keys()));
351
+ const heading = mode === 'capsules'
352
+ ? `${String(nodeCount)} related issues is past this canvas's budget of ${String(GRAPH_NODE_BUDGET)}, so it is not drawing them.`
353
+ : `${String(nodeCount)} related issues is far past this canvas's budget, so it is showing clusters only.`;
354
+ // THE REFUSAL IS INFORMATIONAL. IT PUBLISHES NO CONTROL — and that is a
355
+ // RESTRUCTURE, not a regression of the round-five finding it answers.
356
+ // That finding offered two remedies: expose an actionable component target,
357
+ // OR replace the instruction with an action the rendered API actually
358
+ // supports. The first was taken, and it drew a defect in every round since —
359
+ // the capsule was pointer-only, then it was a button outside the focus index
360
+ // whose activation redraws and destroys itself, leaving a keyboard reader
361
+ // with focus on nothing. Each patch was correct and each bought another.
362
+ // The cause underneath them is that this package DOES NOT NARROW. It renders
363
+ // exactly what it is given, so a control here can never complete the action
364
+ // it advertises; only the host can, by narrowing the document and rendering
365
+ // again. A control that cannot finish its own job is the surface generating
366
+ // the findings, so it goes rather than gets a fourth fix — and with it go the
367
+ // focus index it never belonged to, the synthesized click, and the group
368
+ // identity nothing could act on.
369
+ // The order list remains, and IS complete at any size: that is the action a
370
+ // reader can actually take inside this package, so it is the one named below.
371
+ const LIMIT = 12;
372
+ const shown = mode === 'capsules' ? clusters : clusters.slice(0, LIMIT);
373
+ // SAY WHAT WAS OMITTED. A silent slice left a reader looking at twelve
374
+ // components and no indication that 139 others existed — under a heading
375
+ // announcing it was showing clusters. Refusing to draw is defensible;
376
+ // under-reporting the shape without saying so is not, because the reader
377
+ // cannot tell a complete list from a truncated one.
378
+ const omitted = clusters.length - shown.length;
379
+ return element('section', { class: 'ig-refusal', role: 'note' }, [
380
+ element('p', {}, [heading]),
381
+ element('ol', { class: 'ig-list', 'aria-label': 'connected components' }, shown.map((cluster) => element('li', { class: 'ig-capsule' }, [
382
+ element('span', { class: 'ig-count' }, [`${String(cluster.members.length)} issues`]),
383
+ element('span', { class: 'ig-count' }, [`${String(cluster.blockedByEdges)} blocking`]),
384
+ element('span', { class: 'ig-count' }, [`depth ${String(cluster.chainDepth)}`]),
385
+ cluster.hasCycle
386
+ ? element('span', { class: 'ig-badge', 'data-edge': 'blocked-by' }, ['cycle'])
387
+ : null,
388
+ element('span', { class: 'ig-id' }, [cluster.members.slice(0, 3).join(', ')]),
389
+ ]))),
390
+ omitted > 0
391
+ ? element('p', { class: 'ig-refusal-omitted' }, [
392
+ `${String(omitted)} further ${omitted === 1 ? 'component is' : 'components are'} not listed; ${String(clusters.length)} were found in total.`,
393
+ ])
394
+ : null,
395
+ element('p', { class: 'ig-refusal-next' }, [
396
+ 'Narrow the document to one neighbourhood and render again — narrowing is the host\'s, because this package draws exactly what it is given. The order list is complete at any size.',
397
+ ]),
398
+ ]);
399
+ }
400
+ export function graphScene(document, rawOptions = {}) {
401
+ // See `linearScene` — the same rule, applied before the canvas is laid out.
402
+ const stations = stationsOf(document);
403
+ const options = atStations(rawOptions, stations);
404
+ const theme = options.theme ?? defaultTheme;
405
+ const layout = layoutGraph(document, theme);
406
+ const nodeCount = layout.nodes.size;
407
+ const inline = document.order.slots.filter((slot) => !isFooterSlot(slot));
408
+ const footerSlots = document.order.slots.filter(isFooterSlot);
409
+ // ── WHICH KEYS CAN HOLD FOCUS IS DERIVED FROM WHAT THIS SCENE WILL DRAW ────
410
+ //
411
+ // Three rounds of review found the same class here — a key published as a
412
+ // navigation target with no focusable element behind it — in three different
413
+ // places: gutter nodes with no `tabindex`, then refusal mode replacing the
414
+ // whole canvas while the published sets still named its nodes. Patching each
415
+ // site kept the invariant true by maintenance, which is why it kept coming
416
+ // back.
417
+ //
418
+ // So the sets are FILTERED BY WHAT RENDERS instead of declared beside it. The
419
+ // rail always draws the ranked slots; the canvas draws every laid-out node,
420
+ // and draws NOTHING keyed when it refuses or is empty. Everything downstream
421
+ // is a subset of that, so "every published target is focusable" holds by
422
+ // construction rather than by remembering.
423
+ const refused = nodeCount === 0 || nodeCount > GRAPH_NODE_BUDGET;
424
+ // FROM THE SAME RULE THE RAIL RENDERS FROM — see `railContents`. Hard-coding
425
+ // `inline` here was correct only while the rail rendered exactly `inline`, and
426
+ // it silently stopped being correct the moment the refusal's rail widened.
427
+ const shown = railContents(document, !refused);
428
+ const railed = new Set([
429
+ ...shown.slots.map((slot) => slot.lead),
430
+ ...shown.excluded.map((exclusion) => exclusion.key),
431
+ ]);
432
+ const focusable = new Set([
433
+ ...railed,
434
+ ...(refused ? [] : layout.nodes.keys()),
435
+ ]);
436
+ const focusOrder = [
437
+ ...inline.map((slot) => slot.lead),
438
+ ...footerSlots.map((slot) => slot.lead),
439
+ ...document.order.excluded.map((exclusion) => exclusion.key),
440
+ ].filter((key) => focusable.has(key));
441
+ // ── the lateral axis: ONLY PAIRS WHOSE REVERSE HOLDS ──────────────────────
442
+ //
443
+ // A one-way mapping is not a traversal — focus went out to a gutter node and
444
+ // the opposite arrow answered `none`. Recording both ends fixed that, and
445
+ // then broke on the case one gutter node is related to TWO spine slots: each
446
+ // slot overwrote the gutter's single reverse entry, so `A.left = G` while
447
+ // `G.right = B`, and left-then-right did not come back.
448
+ //
449
+ // A node has ONE neighbour per side, so a shared gutter cannot point back to
450
+ // both — no amount of care makes it. So the invariant is the thing published:
451
+ // a pair is written only when its reverse is still free, and a forward link
452
+ // whose reverse could not be kept is not published either. Every pair in this
453
+ // map is reversible, which `graph.test.ts` asserts over the whole map rather
454
+ // than for one example.
455
+ const lateral = new Map();
456
+ const opposite = (side) => side === 'left' ? 'right' : 'left';
457
+ const linkable = (key, side) => (lateral.get(key) ?? {})[side] === undefined;
458
+ const link = (key, side, target) => {
459
+ lateral.set(key, { ...(lateral.get(key) ?? {}), [side]: target });
460
+ };
461
+ for (const slot of document.order.slots) {
462
+ if (!focusable.has(slot.lead))
463
+ continue;
464
+ // Every MEMBER's edges, not just the lead's: a together unit is one station
465
+ // with one focus key, so a gutter neighbour reachable only through its
466
+ // second member would otherwise be unreachable by keyboard entirely.
467
+ const touching = slot.members.flatMap((member) => (document.edgesOf.get(member) ?? []).map((edge) => edge.from === member ? edge.to : edge.from));
468
+ for (const side of ['left', 'right']) {
469
+ const target = touching.find((other) => focusable.has(other) &&
470
+ layout.nodes.get(other)?.column === side &&
471
+ // Both directions have to be free, or the pair is not reversible.
472
+ linkable(slot.lead, side) &&
473
+ linkable(other, opposite(side)));
474
+ if (target === undefined)
475
+ continue;
476
+ link(slot.lead, side, target);
477
+ link(target, opposite(side), slot.lead);
478
+ }
479
+ }
480
+ // A NODE NO STATION REPRESENTS HAS NO KEYBOARD EXISTENCE AT ALL. The canvas
481
+ // draws it with a key, so a pointer can select what a keyboard cannot reach —
482
+ // and where NOTHING is ordered, every list is empty and the canvas offers no
483
+ // keyboard entry whatsoever. Measured: a document with edges and no order
484
+ // slots renders two keyed nodes and not one `tabindex`.
485
+ // THE TEST IS "REPRESENTED BY A STATION", NOT "IN A LIST", and that distinction
486
+ // is the whole correctness of this. A together unit is ONE station with one
487
+ // focus key, so its non-lead members are absent from the order DELIBERATELY —
488
+ // `104` in this package's fixture is exactly that, and an earlier version of
489
+ // this fix gave it a station of its own, splitting the unit and breaking the
490
+ // rule `navigation.test.ts` states in as many words. A member is represented
491
+ // by its lead; only a key belonging to no slot at all is unrepresented.
492
+ // THEY JOIN THE VERTICAL ORDER rather than the membership set alone, because
493
+ // with a roving tabindex only the FOCUSED key is tabbable — a key focus can
494
+ // never ARRIVE at is unreachable however wide the set of things that "can hold
495
+ // focus" is. The arrows are the only way in.
496
+ // APPENDED, so every ranked position keeps its rank and the first entry is
497
+ // unchanged; in the layout's own node order, so the result is deterministic.
498
+ const represented = new Set();
499
+ for (const slot of document.order.slots)
500
+ for (const member of slot.members)
501
+ represented.add(member);
502
+ if (!refused) {
503
+ for (const key of layout.nodes.keys()) {
504
+ if (!focusOrder.includes(key) && !lateral.has(key) && !represented.has(key))
505
+ focusOrder.push(key);
506
+ }
507
+ }
508
+ // A GUTTER NODE IS REACHABLE SIDEWAYS WITHOUT BEING A POSITION IN THE ORDER,
509
+ // so the set that can hold focus is wider than the order that walks it.
510
+ // `focusOrder` LEADS, so the first entry is the same under either — which is
511
+ // why this is built AFTER the pass above rather than before it. Built first,
512
+ // the lateral keys landed between the ranked entries and the appended ones and
513
+ // `navigable` stopped leading with `focusOrder`, which the scene contract
514
+ // requires and `graph.test.ts` asserts. Anything derived from the ORDER has to
515
+ // come after a pass that changes the order.
516
+ const navigableKeys = [...focusOrder];
517
+ for (const key of lateral.keys()) {
518
+ if (!navigableKeys.includes(key))
519
+ navigableKeys.push(key);
520
+ }
521
+ // One rule, shared with `reconcile` and the other projections, so the element
522
+ // that renders `tabindex="0"` and the state a host reads cannot disagree.
523
+ const navigable = {
524
+ keys: navigableKeys,
525
+ focused: resolveFocusKey(navigableKeys, options.focused, options.selected),
526
+ railed,
527
+ };
528
+ const diagnostics = [];
529
+ let canvas;
530
+ if (nodeCount === 0) {
531
+ canvas = emptyState('No issue in this document declares a relationship, so the canvas is empty.');
532
+ }
533
+ else if (nodeCount > CLUSTER_ONLY_BUDGET) {
534
+ diagnostics.push(`graph refused: ${String(nodeCount)} nodes is past the cluster-only budget of ${String(CLUSTER_ONLY_BUDGET)}`);
535
+ canvas = refusal(document, layout, nodeCount, 'clusters');
536
+ }
537
+ else if (nodeCount > GRAPH_NODE_BUDGET) {
538
+ diagnostics.push(`graph refused: ${String(nodeCount)} nodes is past the node budget of ${String(GRAPH_NODE_BUDGET)}`);
539
+ canvas = refusal(document, layout, nodeCount, 'capsules');
540
+ }
541
+ else {
542
+ const edgeLayers = [];
543
+ for (const edge of document.edges) {
544
+ // `together-with` is drawn as an enclosure plus its connector, not as an
545
+ // arc: it shares a rank rather than ordering anything.
546
+ if (edge.field === 'together-with')
547
+ continue;
548
+ const geometry = edgeGeometry(layout, edge);
549
+ if (geometry === null)
550
+ continue;
551
+ edgeLayers.push(...edgePaths(edge, geometry, theme));
552
+ const marker = terminalMarker(treatmentFor(edge.field).terminal, geometry, edge.field, theme);
553
+ if (marker !== null)
554
+ edgeLayers.push(marker);
555
+ }
556
+ // The one declared seam crossing: the connector lives in this layer,
557
+ // because a click target cannot be added from outside without the viewer
558
+ // knowing where members are.
559
+ const enclosures = [];
560
+ for (const [lead, members] of layout.slotMembers) {
561
+ const bounds = enclosureBounds(layout, members, theme);
562
+ if (bounds === null)
563
+ continue;
564
+ enclosures.push(
565
+ // `data-ig-GROUP`, not `data-ig-key`. The enclosure is painted BEFORE
566
+ // the nodes so it sits behind them, and `mountViewer` indexes the first
567
+ // element it sees for a key — so sharing the key made keyboard movement
568
+ // to a canvas-owned lead call `focus()` on this non-tabbable rect
569
+ // instead of its `<g tabindex="0">`. Decoration does not compete with
570
+ // the thing it decorates for an identity.
571
+ svg('rect', {
572
+ class: 'ig-enclosure',
573
+ 'data-ig-group': lead,
574
+ 'stroke-dasharray': dashArrayFor('enclosure'),
575
+ x: bounds.x,
576
+ y: bounds.y,
577
+ width: bounds.width,
578
+ height: bounds.height,
579
+ rx: theme.metrics['--ig-radius'],
580
+ role: 'img',
581
+ 'aria-label': `${members.join(' and ')} share one rank`,
582
+ }));
583
+ for (let index = 1; index < members.length; index += 1) {
584
+ const previous = layout.nodes.get(members[index - 1]);
585
+ const current = layout.nodes.get(members[index]);
586
+ if (previous === undefined || current === undefined)
587
+ continue;
588
+ enclosures.push(svg('line', {
589
+ class: 'ig-connector',
590
+ 'data-ig-group': lead,
591
+ x1: previous.x + previous.width / 2,
592
+ y1: previous.y + previous.height,
593
+ x2: current.x + current.width / 2,
594
+ y2: current.y,
595
+ }));
596
+ }
597
+ }
598
+ const nodeShapes = [...layout.nodes.keys()]
599
+ .map((key) => nodeShape(document, layout, key, options, theme, navigable))
600
+ .filter((node) => node !== null);
601
+ canvas = svg('svg', {
602
+ class: 'ig-canvas',
603
+ viewBox: `0 0 ${String(Math.round(layout.width))} ${String(Math.round(layout.height))}`,
604
+ // `role="img"` FLATTENS every descendant into a single image, which
605
+ // would hide the very node roles and labels that make gutter and
606
+ // held nodes reachable. A container holding separately focusable
607
+ // semantic children is a group, not a picture.
608
+ role: 'group',
609
+ 'aria-label': `${String(nodeCount)} issues and ${String(document.edges.length)} relationships`,
610
+ }, [...enclosures, ...edgeLayers, ...nodeShapes]);
611
+ }
612
+ const root = element('section', { class: 'ig-viewer ig-graph', 'data-projection': 'graph', 'aria-label': 'issue order and relationships' }, [
613
+ legend(),
614
+ // ONE STAGE, sized in the layout's own units, so an absolutely-positioned
615
+ // rail row and an SVG coordinate mean the same thing. A percentage-width
616
+ // canvas would rescale under the rail and the two would drift apart.
617
+ // A refusal draws no nodes, so it needs no stage and the rail stays in
618
+ // ordinary flow — a fixed-height stage would clip it.
619
+ refused
620
+ ? canvas
621
+ : element('div', {
622
+ class: 'ig-stage',
623
+ style: `--ig-stage-w:${String(Math.round(layout.width))}px;--ig-stage-h:${String(Math.round(layout.height))}px`,
624
+ }, [canvas, spineRail(document, layout, options, navigable.focused, true)]),
625
+ refused ? spineRail(document, layout, options, navigable.focused, false) : null,
626
+ document.isolated.length === 0
627
+ ? null
628
+ : element('p', { class: 'ig-count' }, [
629
+ `${String(document.isolated.length)} isolated ${document.isolated.length === 1 ? 'issue' : 'issues'} not drawn`,
630
+ ]),
631
+ ]);
632
+ return {
633
+ projection: 'graph',
634
+ root,
635
+ focusOrder,
636
+ navigable: navigableKeys,
637
+ lateral,
638
+ // The same stations the canvas keys its member nodes to through
639
+ // `GROUP_ATTRIBUTE` — one rule, so the markup and the published state agree.
640
+ stationOf: stations,
641
+ diagnostics,
642
+ };
643
+ }
644
+ //# sourceMappingURL=graph.js.map