@issuegraph/viewer 0.3.0 → 0.9.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.
Files changed (59) hide show
  1. package/dist/clusters.d.ts +116 -0
  2. package/dist/clusters.d.ts.map +1 -1
  3. package/dist/clusters.js +85 -2
  4. package/dist/clusters.js.map +1 -1
  5. package/dist/document.d.ts +228 -5
  6. package/dist/document.d.ts.map +1 -1
  7. package/dist/document.js +204 -3
  8. package/dist/document.js.map +1 -1
  9. package/dist/element.d.ts +11 -0
  10. package/dist/element.d.ts.map +1 -1
  11. package/dist/element.js.map +1 -1
  12. package/dist/index.d.ts +17 -6
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +31 -4
  15. package/dist/index.js.map +1 -1
  16. package/dist/layout.d.ts +79 -50
  17. package/dist/layout.d.ts.map +1 -1
  18. package/dist/layout.js +371 -170
  19. package/dist/layout.js.map +1 -1
  20. package/dist/marks.d.ts +105 -0
  21. package/dist/marks.d.ts.map +1 -0
  22. package/dist/marks.js +430 -0
  23. package/dist/marks.js.map +1 -0
  24. package/dist/mount.d.ts +13 -0
  25. package/dist/mount.d.ts.map +1 -1
  26. package/dist/mount.js +69 -1
  27. package/dist/mount.js.map +1 -1
  28. package/dist/navigation.d.ts +14 -0
  29. package/dist/navigation.d.ts.map +1 -1
  30. package/dist/navigation.js +51 -0
  31. package/dist/navigation.js.map +1 -1
  32. package/dist/parts.d.ts +308 -2
  33. package/dist/parts.d.ts.map +1 -1
  34. package/dist/parts.js +845 -65
  35. package/dist/parts.js.map +1 -1
  36. package/dist/projections/graph.d.ts +25 -0
  37. package/dist/projections/graph.d.ts.map +1 -1
  38. package/dist/projections/graph.js +476 -335
  39. package/dist/projections/graph.js.map +1 -1
  40. package/dist/projections/linear.d.ts +135 -0
  41. package/dist/projections/linear.d.ts.map +1 -1
  42. package/dist/projections/linear.js +266 -31
  43. package/dist/projections/linear.js.map +1 -1
  44. package/dist/projections/tree.d.ts.map +1 -1
  45. package/dist/projections/tree.js +29 -3
  46. package/dist/projections/tree.js.map +1 -1
  47. package/dist/styles.d.ts +10 -1
  48. package/dist/styles.d.ts.map +1 -1
  49. package/dist/styles.js +1275 -132
  50. package/dist/styles.js.map +1 -1
  51. package/dist/theme.d.ts +5 -6
  52. package/dist/theme.d.ts.map +1 -1
  53. package/dist/theme.js +119 -7
  54. package/dist/theme.js.map +1 -1
  55. package/dist/vocabulary.d.ts +39 -0
  56. package/dist/vocabulary.d.ts.map +1 -1
  57. package/dist/vocabulary.js +27 -0
  58. package/dist/vocabulary.js.map +1 -1
  59. package/package.json +2 -2
@@ -14,14 +14,15 @@
14
14
  * so and offers the next move instead of drawing a hairball.
15
15
  */
16
16
  import { edgeIdentity } from '@issuegraph/core';
17
- import { clustersOf } from "../clusters.js";
17
+ import { clusterReach, clusterReachLabel, clustersOf } from "../clusters.js";
18
18
  import { element, svg } from "../element.js";
19
- import { edgeGeometry, connectorPath, enclosureBounds, fitLabel, layoutGraph, } from "../layout.js";
20
- import { emptyState, legend, slotLabel, slotTitle, station, stationFill, stationsOf, atStations, caveatText, hostHeader, nowRows, } from "../parts.js";
19
+ import { edgeGeometry, layoutGraph, } from "../layout.js";
20
+ import { edgeMarkSpecs } from "../marks.js";
21
+ import { adoptionNote, atStations, cardBlocks, caveatBadges, conditionKind, conditionNotice, footerLabels, nowRows, caveatText, edgeBadgeList, emptyState, evidenceBadge, hostHeader, statesItsOwnCause, identity, legend, notReadyBadge, priorityBadge, slotLabel, slotPosition, stationFill, stationsOf, unitBlock, } from "../parts.js";
21
22
  import { resolveFocusKey } from "../scene.js";
22
23
  import { resolveTheme } from "../theme.js";
23
24
  import { dashArrayFor, treatmentFor } from "../vocabulary.js";
24
- import { excludedRow, isFooterSlot } from "./linear.js";
25
+ import { asideRow, excludedRow, footerHeading, footerRow, isFooterSlot, slotRow, } from "./linear.js";
25
26
  /**
26
27
  * The node budget, from the design's scale table. Above the first threshold the
27
28
  * canvas shows component capsules; above the second, clusters only.
@@ -127,241 +128,237 @@ function edgePaths(edge, geometry, theme) {
127
128
  return [svg('path', base)];
128
129
  }
129
130
  /**
130
- * One node on the canvas.
131
+ * One node, drawn as an HTML card positioned on the coordinates the layout gave
132
+ * it.
131
133
  *
132
- * A NODE THE SCENE PUBLISHES AS A NAVIGATION TARGET HAS TO BE FOCUSABLE. The
133
- * rail draws only the ranked spine slots, so a tracker-held slot, a duplicate
134
- * and every gutter node exist ONLY as this group — and an SVG group with no
135
- * `tabindex` cannot take focus, so navigating to one called `focus()` on
136
- * nothing and the visible tab stop vanished.
134
+ * A CARD, NOT A LABELLED RECTANGLE, and that is the change this pass exists to
135
+ * make. Drawing a node's text in SVG means the text neither wraps nor clips, so
136
+ * every title had to be run through a width fit and came out truncated — the
137
+ * "Retype the ca…" the issue names. §16b's node is three lines deep: a title
138
+ * that wraps, an identity, and a badge row, and none of that is expressible as
139
+ * one centred `<text>`.
137
140
  *
138
- * `railed` names the keys the rail already draws, so exactly one element per
139
- * key is tabbable; a second tab stop for the same issue would be worse than
140
- * none. A focusable element also needs a name, hence `role`/`aria-label`.
141
+ * IT ALSO SETTLES WHERE FOCUS LIVES. There used to be two kinds of node — a
142
+ * rail row for a ranked slot and an SVG group for everything else — and three
143
+ * rounds of review found the same class of defect at the seam between them: a
144
+ * key published as a navigation target with no focusable element behind it. One
145
+ * kind of node cannot have that seam.
141
146
  */
142
- function nodeShape(document, layout, key, options, theme, navigable) {
147
+ function nodeCard(document, layout, key, options, focused) {
143
148
  const box = layout.nodes.get(key);
144
149
  if (box === undefined)
145
150
  return null;
146
151
  const issue = document.byKey.get(key);
147
- const selected = options.selected === key;
148
- const ownsTabStop = navigable.keys.includes(key) && !navigable.railed.has(key);
149
- const full = issue?.title ?? key;
150
- const drawn = fitLabel(theme, full, box.width);
151
- // A HOLD THE RAIL DOES NOT CARRY HAS TO BE CARRIED HERE. The rail draws only
152
- // the non-footer slots, so a tracker-held slot is filtered out of it — and the
153
- // reason last round put on the rail row therefore never reached the graph at
154
- // all for exactly those slots, while `ViewerHold` says the viewer renders the
155
- // reason verbatim. Measured on the fixture: `claimed by another run` appeared
156
- // nowhere in graph markup.
157
- // I DEFERRED THIS ONCE ON A REASON THAT WAS WRONG, and it is worth recording:
158
- // I said `nodeShape` "takes a key and an issue and knows nothing about slots",
159
- // so carrying the reason would be a signature change touching every node. It
160
- // takes the whole `document`, and the slots are on it. There was no signature
161
- // change to make. See issue #41.
162
- // ONLY FOR A NODE THE RAIL DOES NOT LABEL — a railed slot already carries its
163
- // reason on its row, and repeating it here would announce the same sentence
164
- // twice for one slot.
165
- // AND THE HOST'S CAVEATS, on the same terms: a gutter or footer node is the
166
- // only mark this projection draws for its issue, so a preview-only note or a
167
- // disagreement that the linear and tree rows print would otherwise be absent
168
- // from the graph entirely for exactly the issues that have no rail row.
169
- const caveats = navigable.railed.has(key) ? '' : caveatText(issue);
170
- const heldBecause = navigable.railed.has(key)
171
- ? ''
172
- : [
173
- ...document.order.slots
174
- .filter((slot) => slot.lead === key || slot.members.includes(key))
175
- .flatMap((slot) => slot.holds.map((hold) => hold.reason)),
176
- ...(caveats === '' ? [] : [caveats]),
177
- ].join(' · ');
178
- // THE POINTER MUST NOT NAME AN IDENTITY THE KEYBOARD CANNOT REACH. A together
179
- // unit is ONE station with one focus key, so its non-lead members are absent
180
- // from `navigable` deliberately — and this published each of them as its own
181
- // `data-ig-key` anyway. Measured on the fixture: clicking `104` emitted `104`,
182
- // selected `104`, and threw focus to `102` — not even the unit that was
183
- // clicked, because `resolveFocusKey` found neither the selection nor the
184
- // requested key in the order and fell back to its first entry. No keyboard can
185
- // produce that state.
186
- // ROUTED TO THE STATION, NOT MADE A STATION. Giving the partner its own focus
187
- // key is the other repair codex offered and it is the one round ten already
188
- // rejected: it splits the unit into two stations, which `navigation.test.ts`
189
- // forbids in as many words. So the member keeps its node and loses only its
190
- // FOCUS identity, which it never legitimately had.
191
- // `GROUP_ATTRIBUTE` IS EXACTLY THIS CHANNEL — the enclosure and the connector
192
- // already answer a pointer with their unit's lead through it, and a member's
193
- // node is the same question about the same unit. `keyAt` reads it as the
194
- // fallback the focus index never sees.
195
- // A KEY NO SLOT REPRESENTS FALLS BACK TO ITSELF, which is no worse than what
196
- // it published before; the orphan pass above is what makes such a key
197
- // navigable, and the invariant test is what proves it did.
198
- const published = navigable.keys.includes(key);
199
- const station = document.order.slots.find((slot) => slot.members.includes(key))?.lead ?? key;
200
- return svg('g', {
201
- class: 'ig-node-group',
202
- 'data-ig-key': published ? key : null,
203
- 'data-ig-group': published ? null : station,
204
- 'data-column': box.column,
205
- 'aria-current': selected ? 'true' : 'false',
206
- role: ownsTabStop ? 'img' : null,
207
- // THE REASON RIDES THE NAME WHEN THERE IS ONE, on the same channels the
208
- // rail row uses, so one hold reads the same whichever surface drew it.
209
- 'aria-label': ownsTabStop
210
- ? heldBecause === ''
211
- ? `${full} — ${key}`
212
- : `${full} — ${key} — ${heldBecause}`
213
- : null,
214
- tabindex: ownsTabStop ? (navigable.focused === key ? 0 : -1) : null,
152
+ const slot = document.order.slots.find((candidate) => candidate.lead === key);
153
+ const members = layout.slotMembers.get(key) ?? [key];
154
+ const exclusion = document.order.excluded.find((candidate) => candidate.key === key);
155
+ const caveats = caveatText(issue);
156
+ const heldBecause = [
157
+ ...(slot?.holds ?? []).map((hold) => hold.reason),
158
+ ...(caveats === '' ? [] : [caveats]),
159
+ ...(exclusion === undefined
160
+ ? []
161
+ : [`${treatmentFor('duplicate-of').label} ${exclusion.canonical} — never worked`]),
162
+ ].join(' · ');
163
+ // RENDERED FROM THE CARD'S OWN DESCRIPTION, which is also what the layout
164
+ // counted this card's height from — see `CardBlock`. Built from a second
165
+ // reading of what the card should hold, four blocks in a row were added to
166
+ // the drawing and not to the count, and every one of them overran the box the
167
+ // arcs were anchored to.
168
+ const drawn = cardBlocks(document, key, members, box.column === 'spine', box.now === true).map((block) => cardMark(document, block, key, members, slot));
169
+ const label = [
170
+ slot === undefined ? (issue?.title ?? key) : slotLabel(document, slot),
171
+ heldBecause === '' ? null : heldBecause,
172
+ ]
173
+ .filter((part) => part !== null)
174
+ .join(' — ');
175
+ return element('li', {
176
+ class: 'ig-rail-row',
177
+ 'data-ig-key': key,
178
+ 'aria-current': options.selected === key ? 'true' : 'false',
179
+ 'aria-label': label,
180
+ title: heldBecause === '' ? null : heldBecause,
181
+ tabindex: focused === key ? 0 : -1,
182
+ // Positioned from the layout, not from the flow, so a card sits where its
183
+ // edges terminate however the theme scales the geometry.
184
+ style: `--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`,
215
185
  }, [
216
- // AND ON THE POINTER CHANNEL, which is also the only one left for a node
217
- // that owns no tab stop and therefore carries no `aria-label` at all.
218
- // First child, because that is where SVG looks for `<title>`.
219
- heldBecause === '' ? null : svg('title', {}, [`${full} — ${heldBecause}`]),
220
- svg('rect', {
221
- class: 'ig-node',
186
+ element('div', {
187
+ class: 'ig-card',
188
+ 'data-column': box.column,
222
189
  'data-held': box.held ? 'true' : 'false',
223
- x: box.x,
224
- y: box.y,
225
- width: box.width,
226
- height: box.height,
227
- rx: theme.metrics['--ig-radius'],
228
- }),
229
- // A node is labelled by its RAIL ROW when it has one, so labelling it
230
- // here too would print the title twice — once selectable, once not. But
231
- // the rail draws only the ranked slots, and keying this on the COLUMN
232
- // rather than on the rail left a tracker-held slot as a blank rectangle:
233
- // no title, no hold reason, nothing. The question is whether this key is
234
- // railed, not which column it sits in.
235
- navigable.railed.has(key)
236
- ? null
237
- : svg('text', {
238
- class: 'ig-node-label',
239
- x: box.x + theme.metrics['--ig-space'],
240
- // `dominant-baseline` centres the glyphs on the line rather than
241
- // a remembered offset, so the label stays centred at any scale.
242
- y: box.y + box.height / 2,
243
- 'dominant-baseline': 'middle',
244
- }, [
245
- // THE FULL TITLE IS NOT LOST WHEN IT DOES NOT FIT — and it has to
246
- // be a CHILD ELEMENT to do that job. This was written as a `title`
247
- // ATTRIBUTE, which SVG ignores entirely: no tooltip, no accessible
248
- // description, nothing. The markup contained the string, so a test
249
- // asserting the full title was "somewhere in the markup" passed
250
- // while a reader hovering the shortened label recovered nothing.
251
- // FIRST CHILD, because that is where SVG looks for it, and only on
252
- // truncation — an untruncated label already reads in full, and a
253
- // `<title>` echoing it would announce it twice.
254
- // A railed node never reaches here at all: the rail row is its
255
- // label, and its own CSS ellipsis handles the same overflow.
256
- drawn === full ? null : svg('title', {}, [full]),
257
- drawn,
258
- ]),
190
+ 'data-now': box.now === true ? 'true' : 'false',
191
+ 'data-unit': members.length > 1 ? 'true' : 'false',
192
+ 'data-exclusion': exclusion === undefined ? null : 'true',
193
+ }, drawn),
259
194
  ]);
260
195
  }
196
+ /** One described block, drawn. The other half of `cardBlocks`. */
197
+ function cardMark(document, block, key, members, slot) {
198
+ switch (block.kind) {
199
+ case 'banner':
200
+ return element('div', { class: 'ig-unit-mark' }, [
201
+ element('span', { class: block.mark === 'now' ? 'ig-now-mark' : 'ig-unit-pill' }, [block.mark]),
202
+ block.note === '' ? null : element('span', { class: 'ig-unit-note' }, [block.note]),
203
+ ]);
204
+ case 'head': {
205
+ const issue = document.byKey.get(block.key);
206
+ return element('div', { class: 'ig-row-head' }, [
207
+ element('span', { class: 'ig-title' }, [issue?.title ?? block.key]),
208
+ issue === undefined ? null : identity(issue),
209
+ ]);
210
+ }
211
+ case 'unit':
212
+ // Through `unitBlock`, so the enclosure and its deep links are the ones
213
+ // the list draws rather than a second spelling of them.
214
+ return slot === undefined
215
+ ? element('div', { class: 'ig-row-head' }, [])
216
+ : (unitBlock(document, slot) ?? element('div', { class: 'ig-row-head' }, []));
217
+ case 'badges':
218
+ // THE BADGE ROW IS THE SAME ONE THE LIST DRAWS, which is what makes the
219
+ // two projections one grammar rather than two spellings. A gutter card
220
+ // carries it too: it is the only mark this projection draws for its
221
+ // issue, so a relation left off it is one the graph reader never sees.
222
+ return element('div', { class: 'ig-badges' },
223
+ // FROM THE SAME BUILDERS `badgeTexts` READS, so what the layout packed
224
+ // and what the card draws are one list of chips rather than two that
225
+ // can differ by one.
226
+ [
227
+ priorityBadge(document.byKey.get(key)?.provenance),
228
+ evidenceBadge(document.byKey.get(key)),
229
+ slot === undefined ? null : notReadyBadge(slot),
230
+ ...caveatBadges(document.byKey.get(key)),
231
+ ...edgeBadgeList(document, members),
232
+ ].filter((badge) => badge !== null));
233
+ case 'note':
234
+ // A SPINE CARD KEEPS ITS SENTENCES ON THE TOOLTIP, because the list
235
+ // projection prints them and §16b's spine card draws a chip. A gutter card
236
+ // is the only mark this projection makes for its issue, so its sentence
237
+ // has nowhere else to be — and §16b prints it there.
238
+ return element('p', { class: 'ig-hold', 'data-family': 'tracker' }, [
239
+ element('span', {}, [block.text]),
240
+ ]);
241
+ }
242
+ }
261
243
  /**
262
- * The spine's ranks and readiness stations, drawn as HTML ON the canvas.
244
+ * The spine: one line, and one station per card on it.
263
245
  *
264
- * Text in SVG is not selectable, not reflowable and announces poorly, so the
265
- * spine's rows stay HTML — but they are the labels FOR the spine nodes, and
266
- * emitting them as a sibling block put them above the drawing instead of on it.
267
- * Ranks and stations then described a picture the reader had to hold in their
268
- * head, which is the opposite of the design's claim that the spine IS the
269
- * order.
270
- *
271
- * So each row is positioned at the coordinates the LAYOUT computed for its own
272
- * node — same source of truth as every edge endpoint. Hand-authoring these
273
- * against remembered positions is the failure mode the design's implementation
274
- * note names; the numbers ride custom properties so the theme still owns them.
246
+ * THE LINE IS THE POINT. §16c's whole argument for this layout is that sequence
247
+ * gets a channel of its own — vertical position on a single line — so that
248
+ * nothing has to encode sequence and dependency at once. Without the line drawn
249
+ * there is no single line to read down, and the picture degrades into the
250
+ * column of rectangles the alternatives were rejected for.
275
251
  */
252
+ function spineStations(document, layout, theme) {
253
+ const size = theme.metrics['--ig-station-box'];
254
+ const stations = [];
255
+ for (const key of layout.spineOrder) {
256
+ const box = layout.nodes.get(key);
257
+ if (box === undefined)
258
+ continue;
259
+ const slot = document.order.slots.find((candidate) => candidate.lead === key);
260
+ const fill = slot === undefined ? 'filled' : stationFill(slot);
261
+ // A serialize hold waits on a PEER rather than on a rank above it, and the
262
+ // frame colours that station differently for exactly that reason.
263
+ const waiting = slot?.holds.some((hold) => hold.code === 'serialized') === true;
264
+ stations.push(element('span', {
265
+ class: 'ig-spine-station',
266
+ 'data-fill': box.now === true ? 'filled' : fill,
267
+ 'data-wait': waiting ? 'serialize' : null,
268
+ role: 'img',
269
+ // THE SAME TWO-FIELD PHRASE THE ROW SPEAKS — `slotPosition`, not a
270
+ // second reading of `rank`. Inferring held from a missing rank here
271
+ // dropped the word held from a held-but-ranked station exactly as it
272
+ // did from the row's own name; see `slotPosition`.
273
+ 'aria-label': box.now === true
274
+ ? 'working now'
275
+ : slot === undefined
276
+ ? 'held, no rank'
277
+ : slotPosition(slot),
278
+ style: `--ig-station-x:${String(box.x - theme.metrics['--ig-space'] - size)}px;--ig-station-y:${String(box.y + theme.metrics['--ig-space'])}px`,
279
+ }, [box.now === true ? '▸' : slot?.rank === null || slot === undefined ? '—' : String(slot.rank)]));
280
+ }
281
+ return stations;
282
+ }
283
+ /** The three column headings, set at the x of the column each names. */
284
+ function columnHeads(layout) {
285
+ const heads = [
286
+ ['left', 'Explains the order'],
287
+ ['spine', 'The work order ↓'],
288
+ ['right', 'Not worked'],
289
+ ];
290
+ const drawn = new Set();
291
+ for (const box of layout.nodes.values())
292
+ drawn.add(box.column);
293
+ return heads
294
+ .filter(([column]) => drawn.has(column))
295
+ .map(([column, text]) => element('span', {
296
+ class: 'ig-column-head',
297
+ 'data-column': column,
298
+ style: `--ig-col-x:${String(layout.columnX[column])}px`,
299
+ }, [text]));
300
+ }
276
301
  /**
277
- * What the rail draws — ONE rule, because two callers read it.
302
+ * What the ORDER UI draws when the canvas will not.
278
303
  *
279
- * `spineRail` renders these rows and the focus index has to publish exactly the
280
- * same set, or the projection draws a keyed row nothing can reach. That is not
281
- * hypothetical: widening the rail for the refusal without widening the index
282
- * left the footer slot and the exclusion drawn with `data-ig-key` and absent
283
- * from `navigable` — the identical defect the canvas had two rounds ago,
284
- * reintroduced by fixing its sibling. Deriving both from here is what makes the
285
- * two provably agree rather than agree by inspection.
304
+ * A refusal's list is the whole order UI — the refusal's own last sentence
305
+ * promises "the order list is complete at any size" — so it carries the footer
306
+ * slots and the exclusions the canvas would otherwise have drawn. With a
307
+ * canvas, every one of those is a card, so the list carries nothing and this
308
+ * answers empty.
286
309
  *
287
- * `positioned` is the refusal signal inverted: with a canvas, a footer slot is
288
- * drawn as a canvas node and an exclusion sits off the spine entirely, so the
289
- * rail carries neither. Without one, the rail IS the order.
310
+ * ONE RULE, TWO READERS. This decides what renders AND what the focus index
311
+ * publishes; declaring them separately is how a keyed row with nothing to focus
312
+ * kept coming back.
290
313
  */
291
- function railContents(document, positioned) {
292
- return positioned
293
- ? { slots: document.order.slots.filter((slot) => !isFooterSlot(slot)), excluded: [] }
294
- : { slots: document.order.slots, excluded: document.order.excluded };
314
+ function refusalContents(document, refused) {
315
+ return refused
316
+ ? { slots: document.order.slots, excluded: document.order.excluded }
317
+ : { slots: [], excluded: [] };
295
318
  }
296
- function spineRail(document, layout, options, focused, positioned) {
297
- // A REFUSAL'S RAIL IS THE WHOLE ORDER UI, so it must carry what the canvas
298
- // would otherwise have drawn. `positioned` is exactly the refusal signal — the
299
- // only `false` call site is the refusal arm — and when the canvas is absent, a
300
- // footer slot has nothing to draw it and an exclusion has no row at all.
301
- // Measured on a refused document: the tracker-held slot's title, its hold
302
- // reason, and the excluded key were all absent from the markup, while the
303
- // refusal's own text said "The order list is complete at any size". That claim
304
- // was written into this file and it was false.
305
- // FILTERED ONLY WHEN THE CANVAS DRAWS THEM. In ordinary graph mode a footer
306
- // slot IS drawn, as a canvas node, so keeping it out of the rail is what stops
307
- // one slot appearing twice — the filter is right there and wrong here.
308
- const { slots, excluded } = railContents(document, positioned);
309
- return element('ol',
310
- // A plain list for the reason `linear.ts` gives: an interactive descendant
311
- // may not live inside `role="option"`.
312
- // WHEN THERE IS NO CANVAS THERE IS NOTHING TO SIT ON. A refusal draws no
313
- // spine nodes, so the rail returns to ordinary flow rather than positioning
314
- // itself against coordinates nothing rendered.
315
- { class: positioned ? 'ig-list ig-rail' : 'ig-list', 'aria-label': 'work order' }, [
316
- ...slots.map((slot) => {
317
- const box = layout.nodes.get(slot.lead);
318
- // THE REASON A SLOT IS HELD IS PART OF WHAT THIS ROW MEANS. `ViewerHold`
319
- // says the viewer renders the reason verbatim, and the linear projection
320
- // does — this one rendered rank, station and title, so a graph reader was
321
- // told THAT a slot is held and never WHY. `data-held` styles the row, so
322
- // the holding was visible and the host's sentence was not, on either the
323
- // visible or the accessible channel.
324
- // ON THE LABEL AND THE TOOLTIP, NOT AS A BLOCK IN THE ROW. This row is
325
- // positioned onto its node's box — `--ig-row-h` IS the node height — so a
326
- // paragraph per hold would overflow geometry the layout computed for a
327
- // node, which is a worse defect than the one being fixed. `title` plus
328
- // `aria-label` is how this package already carries text that cannot take
329
- // space (see the edge badges): sighted readers hover, screen readers hear
330
- // it, and the layout is untouched.
331
- // ONLY HERE, not in `slotLabel`. That helper is shared with the linear
332
- // projection, which renders the same holds as visible paragraphs — adding
333
- // them there would announce every linear hold twice.
334
- // THE HOST'S CAVEATS RIDE THE SAME CHANNEL as the holds, for the same
335
- // reason: this row has the geometry of a node and no room for a block.
336
- const caveats = caveatText(document.byKey.get(slot.lead));
337
- const heldBecause = [...slot.holds.map((hold) => hold.reason), ...(caveats === '' ? [] : [caveats])].join(' · ');
338
- return element('li', {
339
- class: positioned ? 'ig-slot ig-rail-row' : 'ig-slot',
340
- 'data-ig-key': slot.lead,
341
- 'data-held': slot.ready ? 'false' : 'true',
342
- 'aria-current': options.selected === slot.lead ? 'true' : 'false',
343
- 'aria-label': heldBecause === ''
344
- ? slotLabel(document, slot)
345
- : `${slotLabel(document, slot)} — ${heldBecause}`,
346
- title: heldBecause === '' ? null : heldBecause,
347
- tabindex: focused === slot.lead ? 0 : -1,
348
- // Positioned from the layout, not from the flow, so a row sits on the
349
- // node it names however the theme scales the geometry.
350
- style: positioned && box !== undefined
351
- ? `--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`
352
- : null,
353
- }, [
354
- element('span', { class: 'ig-rank', 'data-held': slot.ready ? 'false' : 'true', 'aria-hidden': 'true' }, [slot.rank === null ? '—' : String(slot.rank)]),
355
- station(stationFill(slot)),
356
- element('span', { class: 'ig-title' }, [slotTitle(document, slot)]),
357
- ]);
358
- }),
359
- // AND THE EXCLUSIONS, on the same terms: they have no canvas node in any
360
- // mode, so ordinary graph mode simply never showed them — which is correct
361
- // there, because the spine rail sits ON the canvas and an exclusion is not
362
- // on it. In a refusal there is no canvas, the rail is the entire order, and
363
- // an exclusion left out is a row of the order that is missing.
364
- ...excluded.map((exclusion) => excludedRow(document, exclusion.key, exclusion.canonical, { ...options, focused })),
319
+ /**
320
+ * The order as a list, for a document the canvas refuses.
321
+ *
322
+ * IT IS THE LINEAR PROJECTION'S OWN ROW, not a second spelling of it. The rail
323
+ * that shipped had its own three-child row — rank, station, title — which drew
324
+ * neither the badges nor the provenance the list draws, so a refused document
325
+ * silently lost the explanation the unrefused one carried.
326
+ */
327
+ function refusalOrder(document, options, focused) {
328
+ const { slots, excluded } = refusalContents(document, true);
329
+ const withFocus = { ...options, focused };
330
+ return element('ol', { class: 'ig-list', 'aria-label': 'work order' }, [
331
+ ...slots.map((slot) => slotRow(document, slot, withFocus, true)),
332
+ ...excluded.map((exclusion) => excludedRow(document, exclusion.key, exclusion.canonical, withFocus)),
333
+ ]);
334
+ }
335
+ /**
336
+ * One component, as §16's refusal presents it.
337
+ *
338
+ * NOT A CONTROL, and that is settled rather than pending — `refusal`'s own
339
+ * note below records why: this package does not narrow, so a control here can
340
+ * never complete the action it advertises. Frame `17f`'s "click to enter one"
341
+ * is layer 2's to draw, on the layer that can.
342
+ *
343
+ * What it does carry is the same reach the editor's capsule carries, from the
344
+ * same function, so one component is never described two ways on one screen.
345
+ */
346
+ function capsuleSpec(cluster) {
347
+ const reach = clusterReach(cluster);
348
+ return element('li', { class: 'ig-capsule' }, [
349
+ element('span', { class: 'ig-count' }, [`${String(cluster.members.length)} issues`]),
350
+ // THE BLOCKING COUNT STAYS, unlike on §17f's card. That rule is the frame's,
351
+ // and the frame's constraint is a two-column card with ONE slot beside the
352
+ // count; this capsule is a flex row with room for both, and dropping the
353
+ // number would leave a §16 reader unable to tell a two-edge cycle component
354
+ // from a two-hundred-edge one. "§16 ships unchanged" is the standing rule;
355
+ // the only thing that changes here is the depth, which was untrue.
356
+ element('span', { class: 'ig-count' }, [`${String(cluster.blockedByEdges)} blocking`]),
357
+ reach.kind === 'cyclic'
358
+ ? element('span', { class: 'ig-badge', 'data-edge': 'blocked-by' }, ['cycle'])
359
+ : null,
360
+ element('span', { class: 'ig-count' }, [clusterReachLabel(reach)]),
361
+ element('span', { class: 'ig-id' }, [cluster.members.slice(0, 3).join(', ')]),
365
362
  ]);
366
363
  }
367
364
  /**
@@ -413,15 +410,7 @@ function refusal(document, layout, nodeCount, mode) {
413
410
  const omitted = clusters.length - shown.length;
414
411
  return element('section', { class: 'ig-refusal', role: 'note' }, [
415
412
  element('p', {}, [heading]),
416
- element('ol', { class: 'ig-list', 'aria-label': 'connected components' }, shown.map((cluster) => element('li', { class: 'ig-capsule' }, [
417
- element('span', { class: 'ig-count' }, [`${String(cluster.members.length)} issues`]),
418
- element('span', { class: 'ig-count' }, [`${String(cluster.blockedByEdges)} blocking`]),
419
- element('span', { class: 'ig-count' }, [`depth ${String(cluster.chainDepth)}`]),
420
- cluster.hasCycle
421
- ? element('span', { class: 'ig-badge', 'data-edge': 'blocked-by' }, ['cycle'])
422
- : null,
423
- element('span', { class: 'ig-id' }, [cluster.members.slice(0, 3).join(', ')]),
424
- ]))),
413
+ element('ol', { class: 'ig-list', 'aria-label': 'connected components' }, shown.map((cluster) => capsuleSpec(cluster))),
425
414
  omitted > 0
426
415
  ? element('p', { class: 'ig-refusal-omitted' }, [
427
416
  `${String(omitted)} further ${omitted === 1 ? 'component is' : 'components are'} not listed; ${String(clusters.length)} were found in total.`,
@@ -432,12 +421,71 @@ function refusal(document, layout, nodeCount, mode) {
432
421
  ]),
433
422
  ]);
434
423
  }
424
+ /**
425
+ * The runner-held slots the canvas draws nowhere, as §16a's footer group.
426
+ *
427
+ * THE GRAPH AND THE LIST MUST SHOW THE SAME ISSUES. A projection change is a
428
+ * change of representation, never of subject — so an issue the list carries in
429
+ * its footer cannot simply be absent from the graph, whatever the canvas has
430
+ * room for.
431
+ */
432
+ function footerGroup(document, layout, options, focused) {
433
+ const entries = footerKeys(document, layout, { ...options, focused });
434
+ if (entries.length === 0)
435
+ return null;
436
+ const slots = document.order.slots.filter((slot) => layout.footer.includes(slot.lead));
437
+ const excluded = document.order.excluded.filter((exclusion) => layout.footer.includes(exclusion.key));
438
+ // WHAT THIS GROUP ACTUALLY HOLDS. In the column it also holds every gutter
439
+ // endpoint the compact picture does not draw — an ordinary open blocker, say,
440
+ // which no runner holds and which is worked like anything else — so a heading
441
+ // that named only the runner and the never-worked said something untrue about
442
+ // them.
443
+ const undrawn = entries.length > slots.length + excluded.length;
444
+ const labels = footerLabels(slots);
445
+ return element('section', { class: 'ig-footer' }, [
446
+ element('div', { class: 'ig-footer-head' }, [
447
+ element('p', { class: 'ig-footer-title' }, [
448
+ footerHeading(entries.length, {
449
+ runner: slots.length > 0,
450
+ neverWorked: excluded.length > 0,
451
+ undrawn,
452
+ }),
453
+ ]),
454
+ labels === '' ? null : element('span', { class: 'ig-footer-labels' }, [labels]),
455
+ ]),
456
+ element('ol', { class: 'ig-list', 'aria-label': 'outside the order' }, entries),
457
+ ]);
458
+ }
459
+ /**
460
+ * A row for EVERY key the canvas drew nowhere, in the layout's own order.
461
+ *
462
+ * DERIVED FROM `layout.footer` AND NOTHING ELSE, which is the whole correction.
463
+ * A group built from `order.slots` covered the runner-held ones; adding
464
+ * `order.excluded` covered the duplicates; and an ordinary off-order
465
+ * relationship endpoint — an open blocker, a closed split origin — is neither,
466
+ * so in compact mode, where the gutters are not drawn, it was in
467
+ * `layout.footer` and rendered by nothing at all. Two partial rules chasing one
468
+ * set is how the key that belongs to neither goes missing, so there is one
469
+ * rule: the layout says what it did not draw, and every one of those gets a row.
470
+ */
471
+ function footerKeys(document, layout, options) {
472
+ return layout.footer.map((key) => {
473
+ const slot = document.order.slots.find((candidate) => candidate.lead === key);
474
+ if (slot !== undefined)
475
+ return footerRow(document, slot, options);
476
+ const exclusion = document.order.excluded.find((candidate) => candidate.key === key);
477
+ if (exclusion !== undefined) {
478
+ return excludedRow(document, key, exclusion.canonical, options);
479
+ }
480
+ return asideRow(document, key, options);
481
+ });
482
+ }
435
483
  export function graphScene(document, rawOptions = {}) {
436
484
  // See `linearScene` — the same rule, applied before the canvas is laid out.
437
485
  const stations = stationsOf(document);
438
486
  const options = atStations(rawOptions, stations);
439
487
  const theme = resolveTheme(options.theme);
440
- const layout = layoutGraph(document, theme);
488
+ const layout = layoutGraph(document, theme, options.compact === true);
441
489
  const nodeCount = layout.nodes.size;
442
490
  const inline = document.order.slots.filter((slot) => !isFooterSlot(slot));
443
491
  const footerSlots = document.order.slots.filter(isFooterSlot);
@@ -455,24 +503,44 @@ export function graphScene(document, rawOptions = {}) {
455
503
  // and draws NOTHING keyed when it refuses or is empty. Everything downstream
456
504
  // is a subset of that, so "every published target is focusable" holds by
457
505
  // construction rather than by remembering.
458
- const refused = nodeCount === 0 || nodeCount > GRAPH_NODE_BUDGET;
459
- // FROM THE SAME RULE THE RAIL RENDERS FROM — see `railContents`. Hard-coding
460
- // `inline` here was correct only while the rail rendered exactly `inline`, and
461
- // it silently stopped being correct the moment the refusal's rail widened.
462
- const shown = railContents(document, !refused);
506
+ // AN EMPTY NODE MAP IS NOT AN EMPTY PANEL. In the column the gutters are not
507
+ // drawn, so a document whose issues are all off the order — relationships and
508
+ // no ranked or running station — lays out NOTHING and puts every one of them
509
+ // in `layout.footer`. Read as a refusal, that said "no issue in this document
510
+ // declares a relationship" about a document full of them AND suppressed the
511
+ // footer group that was holding all of them, so the panel came back blank.
512
+ // The question is whether this scene draws anything, and the footer draws.
513
+ const drawsNothing = nodeCount === 0 && layout.footer.length === 0;
514
+ const refused = drawsNothing || nodeCount > GRAPH_NODE_BUDGET;
515
+ // FROM THE SAME RULE THE LIST RENDERS FROM — see `refusalContents`. With a
516
+ // canvas every node is a card and the card owns the tab stop; without one,
517
+ // the list is the whole order UI and its rows do.
518
+ const shown = refusalContents(document, refused);
463
519
  const railed = new Set([
464
520
  ...shown.slots.map((slot) => slot.lead),
465
521
  ...shown.excluded.map((exclusion) => exclusion.key),
466
522
  ]);
523
+ // THE FOOTER GROUP IS FOCUSABLE TOO, and it is off the canvas: its rows are
524
+ // drawn beneath the stage, so a set derived from the laid-out nodes alone
525
+ // published nothing for them and a keyboard could not reach half the issues
526
+ // the projection draws.
467
527
  const focusable = new Set([
468
528
  ...railed,
469
- ...(refused ? [] : layout.nodes.keys()),
529
+ ...(refused ? [] : [...layout.nodes.keys(), ...layout.footer]),
470
530
  ]);
531
+ // THE SPINE AS THE LAYOUT ORDERED IT, FIRST. §16e fixes that the graph walks
532
+ // its stations in RANK order, and the layout puts the running job at the top
533
+ // — above rank 1, which is where §16b draws it. Rebuilding the order from the
534
+ // slots instead reached that card at its old rank, or near the end when it
535
+ // held no slot, so the keyboard walked the drawing non-monotonically: down
536
+ // the spine, back up to the top, on again.
537
+ const spineFirst = refused ? [] : [...layout.spineOrder];
471
538
  const focusOrder = [
539
+ ...spineFirst,
472
540
  ...inline.map((slot) => slot.lead),
473
541
  ...footerSlots.map((slot) => slot.lead),
474
542
  ...document.order.excluded.map((exclusion) => exclusion.key),
475
- ].filter((key) => focusable.has(key));
543
+ ].filter((key, index, all) => focusable.has(key) && all.indexOf(key) === index);
476
544
  // ── the lateral axis: ONLY PAIRS WHOSE REVERSE HOLDS ──────────────────────
477
545
  //
478
546
  // A one-way mapping is not a traversal — focus went out to a gutter node and
@@ -496,6 +564,14 @@ export function graphScene(document, rawOptions = {}) {
496
564
  for (const slot of document.order.slots) {
497
565
  if (!focusable.has(slot.lead))
498
566
  continue;
567
+ // FROM THE SPINE OUTWARD, and only from there. §16f gives the lateral keys
568
+ // one job: leave the sequence for the gutter card that explains this rank,
569
+ // and come back. A gutter card linked to ANOTHER gutter card is a step that
570
+ // never touches the order at all — and it consumed the one neighbour-per-
571
+ // side each node has, so the reverse link back to the spine could not be
572
+ // written and the traversal stopped being reversible.
573
+ if (layout.nodes.get(slot.lead)?.column !== 'spine')
574
+ continue;
499
575
  // Every MEMBER's edges, not just the lead's: a together unit is one station
500
576
  // with one focus key, so a gutter neighbour reachable only through its
501
577
  // second member would otherwise be unreachable by keyboard entirely.
@@ -535,7 +611,11 @@ export function graphScene(document, rawOptions = {}) {
535
611
  for (const member of slot.members)
536
612
  represented.add(member);
537
613
  if (!refused) {
538
- for (const key of layout.nodes.keys()) {
614
+ // THE FOOTER GROUP'S KEYS TOO. Its rows are drawn beneath the stage rather
615
+ // than on a column, so a set derived from the laid-out nodes alone reached
616
+ // none of them — and in compact mode, where the gutters are not drawn, that
617
+ // is every off-order endpoint the panel still lists.
618
+ for (const key of [...layout.nodes.keys(), ...layout.footer]) {
539
619
  if (!focusOrder.includes(key) && !lateral.has(key) && !represented.has(key))
540
620
  focusOrder.push(key);
541
621
  }
@@ -562,9 +642,22 @@ export function graphScene(document, rawOptions = {}) {
562
642
  };
563
643
  const diagnostics = [];
564
644
  let canvas;
565
- if (nodeCount === 0) {
645
+ if (statesItsOwnCause(document) && (drawsNothing || nodeCount === 0)) {
646
+ // THE HOST'S NOTICE IS THE PANEL'S ONE CAUSE STATEMENT — see `linear.ts`.
647
+ // This arm's sentence is the worse of the two to leave standing: it names
648
+ // RELATIONSHIPS, so an unreadable index or a running import would be
649
+ // explained to the reader as a document that declares no edges, which is a
650
+ // claim about something else entirely and may simply be untrue.
651
+ canvas = null;
652
+ }
653
+ else if (drawsNothing) {
566
654
  canvas = emptyState('No issue in this document declares a relationship, so the canvas is empty.');
567
655
  }
656
+ else if (nodeCount === 0) {
657
+ // Nothing to draw ON the canvas, and a footer group beneath it that is the
658
+ // whole panel. No stage, no refusal: the group renders in ordinary flow.
659
+ canvas = emptyState('Nothing in this document is in the order, so the spine is empty.');
660
+ }
568
661
  else if (nodeCount > CLUSTER_ONLY_BUDGET) {
569
662
  diagnostics.push(`graph refused: ${String(nodeCount)} nodes is past the cluster-only budget of ${String(CLUSTER_ONLY_BUDGET)}`);
570
663
  canvas = refusal(document, layout, nodeCount, 'clusters');
@@ -575,126 +668,174 @@ export function graphScene(document, rawOptions = {}) {
575
668
  }
576
669
  else {
577
670
  const edgeLayers = [];
671
+ // ONE STATION PER SLOT MEANS ONE BOX PER SLOT, so an edge inside a unit has
672
+ // both ends on the same card and no arc to draw. It is not lost: the card
673
+ // lists its members, and the badge row names the relationship, which is the
674
+ // same treatment the list projection gives it. `edgeGeometry` answers null
675
+ // for exactly that case, and it resolves a member to its unit's card for
676
+ // every other — so this loop asks the geometry rather than deciding twice.
578
677
  for (const edge of document.edges) {
579
- // `together-with` is drawn as an enclosure plus its connector, not as an
580
- // arc: it shares a rank rather than ordering anything.
581
- if (edge.field === 'together-with')
582
- continue;
583
- const geometry = edgeGeometry(layout, edge);
678
+ // No arcs in the column: §16b says they cannot be drawn legibly there,
679
+ // and an arc drawn illegibly is worse than an arc a reader knows to
680
+ // expand for. The badge row on every card still names the relationship.
681
+ if (layout.compact)
682
+ break;
683
+ // THE ARROW POINTS AT WHAT IS HELD UP, not at what holds it. §16b draws
684
+ // every blocking arc arriving on the blocked card, and it is the reading
685
+ // that answers the question the panel exists for: a reader following an
686
+ // arrowhead is asking "what is waiting on this", and an arrow into the
687
+ // blocker answers the question nobody asked. The DECLARATION is untouched
688
+ // — the identity below is still the field's own orientation, so what a
689
+ // click reports is the edge as written.
690
+ const drawn = edge.field === 'blocked-by' ? { field: edge.field, from: edge.to, to: edge.from } : edge;
691
+ const geometry = edgeGeometry(layout, drawn);
584
692
  if (geometry === null)
585
693
  continue;
694
+ const identity = edgeIdentity(edge.field, edge.from, edge.to);
586
695
  edgeLayers.push(...edgePaths(edge, geometry, theme));
587
- const marker = terminalMarker(treatmentFor(edge.field).terminal, geometry, edge.field, edgeIdentity(edge.field, edge.from, edge.to), theme);
696
+ const marker = terminalMarker(treatmentFor(edge.field).terminal, geometry, edge.field, identity, theme);
588
697
  if (marker !== null)
589
698
  edgeLayers.push(marker);
590
- }
591
- // The one declared seam crossing: the connector lives in this layer,
592
- // because a click target cannot be added from outside without the viewer
593
- // knowing where members are.
594
- const enclosures = [];
595
- for (const [lead, members] of layout.slotMembers) {
596
- const bounds = enclosureBounds(layout, members, theme);
597
- if (bounds === null)
598
- continue;
599
- enclosures.push(
600
- // `data-ig-GROUP`, not `data-ig-key`. The enclosure is painted BEFORE
601
- // the nodes so it sits behind them, and `mountViewer` indexes the first
602
- // element it sees for a key — so sharing the key made keyboard movement
603
- // to a canvas-owned lead call `focus()` on this non-tabbable rect
604
- // instead of its `<g tabindex="0">`. Decoration does not compete with
605
- // the thing it decorates for an identity.
606
- svg('rect', {
607
- class: 'ig-enclosure',
608
- 'data-ig-group': lead,
609
- 'stroke-dasharray': dashArrayFor('enclosure'),
610
- x: bounds.x,
611
- y: bounds.y,
612
- width: bounds.width,
613
- height: bounds.height,
614
- rx: theme.metrics['--ig-radius'],
615
- role: 'img',
616
- 'aria-label': `${members.join(' and ')} share one rank`,
617
- }));
618
- const inUnit = new Set(members);
619
- for (const edge of document.edges) {
620
- // ONE CONNECTOR PER DECLARED EDGE, NOT ONE PER ADJACENT PAIR. Walking
621
- // the member list pairwise assumed a group is written as a chain — but
622
- // a writer joins one by pointing at ANY existing member (§4.3.7), so
623
- // `2 together-with 1` and `3 together-with 1` is an ordinary star. That
624
- // walk drew a `2`–`3` connector for a relationship the document does
625
- // not contain, so the identity it published resolved to no
626
- // `StoredEdge`, while the real `1`–`3` edge got no connector at all.
627
- // Reading the edges themselves cannot invent one and cannot miss one.
628
- if (edge.field !== 'together-with')
629
- continue;
630
- if (!inUnit.has(edge.from) || !inUnit.has(edge.to))
631
- continue;
632
- const d = connectorPath(layout, members, edge.from, edge.to, theme);
633
- if (d === null)
634
- continue;
635
- enclosures.push(svg('path', {
636
- class: 'ig-connector',
637
- fill: 'none',
638
- // THE PAIR'S OWN IDENTITY, NOT THE SLOT'S. Every connector in a
639
- // unit used to carry the lead, so a three-member unit drew two
640
- // marks a pointer could not tell apart and a click on either named
641
- // the unit — the one thing an editor cannot delete, retype or
642
- // report a write against. `edgeIdentity` is the SAME function the
643
- // store derives `StoredEdge.id` with, so what `onSelect` hands the
644
- // host is a key `findEdge` resolves rather than a shape it has to
645
- // be taught.
646
- //
647
- // STILL `data-ig-group`, and that is not an oversight about the
648
- // "addressable via the key scheme" wording. Decoration announces
649
- // itself on this attribute precisely so it stays OUT of the focus
650
- // index — one element per key, or `focus()` lands on a
651
- // non-tabbable mark instead of the node it decorates. `keyAt`
652
- // reads both attributes for POINTER identity, which is the
653
- // question a click asks, so the connector is addressable without
654
- // re-opening that.
655
- 'data-ig-group': edgeIdentity(edge.field, edge.from, edge.to),
656
- d,
699
+ // THE MARKS A CALLER ASKED FOR, LAST, so every one of them sits in front
700
+ // of the line it decorates rather than under it.
701
+ //
702
+ // Keyed on the DECLARED identity, like the terminal above, even though the
703
+ // geometry was solved from a possibly-swapped `drawn` edge — a caller
704
+ // holds the edge as written and has no way to know which way this
705
+ // projection chose to point the arrow.
706
+ const requested = options.edgeMarks?.get(identity);
707
+ if (requested !== undefined && requested.length > 0) {
708
+ const treatment = treatmentFor(edge.field);
709
+ edgeLayers.push(...edgeMarkSpecs(requested, geometry, theme, identity, {
710
+ doubled: treatment.dash === 'double',
711
+ // THE KIND'S OWN DASH AND HUE, HANDED OVER RATHER THAN LEFT TO CSS.
712
+ // Every mark drops `class`, so `.ig-edge[data-edge=…]` reaches none
713
+ // of them — a companion would draw solid beside a dotted line, and a
714
+ // chip would take the viewer's body text instead of the
715
+ // relationship's colour. Both are channels the type identity rests
716
+ // on, lost exactly where a reader is comparing two versions.
717
+ dashArray: dashArrayFor(treatment.dash),
718
+ hueToken: treatment.hueToken,
657
719
  }));
658
720
  }
659
721
  }
660
- const nodeShapes = [...layout.nodes.keys()]
661
- .map((key) => nodeShape(document, layout, key, options, theme, navigable))
662
- .filter((node) => node !== null);
722
+ // THE SPINE ITSELF, painted first so every arc bows off a line that is
723
+ // already there. It spans the stations and no further: a line running the
724
+ // full height of the canvas would imply order where the gutters sit.
725
+ const spine = layout.spineOrder.length === 0
726
+ ? null
727
+ : svg('line', {
728
+ class: 'ig-spine',
729
+ x1: layout.spineLineX,
730
+ y1: layout.spineTop,
731
+ x2: layout.spineLineX,
732
+ y2: layout.spineBottom,
733
+ });
663
734
  canvas = svg('svg', {
664
735
  class: 'ig-canvas',
665
736
  viewBox: `0 0 ${String(Math.round(layout.width))} ${String(Math.round(layout.height))}`,
666
- // `role="img"` FLATTENS every descendant into a single image, which
667
- // would hide the very node roles and labels that make gutter and
668
- // held nodes reachable. A container holding separately focusable
669
- // semantic children is a group, not a picture.
670
- role: 'group',
671
- 'aria-label': `${String(nodeCount)} issues and ${String(document.edges.length)} relationships`,
672
- }, [...enclosures, ...edgeLayers, ...nodeShapes]);
737
+ // The picture is decoration over an HTML rail that carries every node,
738
+ // every name and every tab stop, so it announces nothing of its own.
739
+ // `role="img"` with a label would put a second, flattened description of
740
+ // the same nodes into the accessibility tree.
741
+ 'aria-hidden': 'true',
742
+ }, [spine, ...edgeLayers]);
673
743
  }
674
- const root = element('section', { class: 'ig-viewer ig-graph', 'data-projection': 'graph', 'aria-label': 'issue order and relationships' }, [
675
- hostHeader(document),
676
- legend(),
677
- // ABOVE THE STAGE, NEVER IN THE RAIL. The rail's rows are positioned onto
678
- // the layout's node boxes, so an unpositioned row among them would sit on
679
- // nothing; the NOW list is ordinary flow, framing the canvas beneath it.
680
- nowRows(document),
744
+ // ONE LIST PER COLUMN, LABELLED THE WAY ITS HEADING IS. Every card in one
745
+ // list called "work order" told a screen reader that the left gutter's
746
+ // explanations and the right gutter's never-worked issues are ordered work —
747
+ // which is the opposite of what those two columns exist to say, and what
748
+ // their own visible headings say instead.
749
+ const columns = [
750
+ ['spine', 'work order'],
751
+ ['left', 'explains the order'],
752
+ ['right', 'not worked'],
753
+ ];
754
+ const cardLists = refused
755
+ ? []
756
+ : columns
757
+ .map(([column, label]) => {
758
+ const cards = [...layout.nodes.keys()]
759
+ .filter((key) => layout.nodes.get(key)?.column === column)
760
+ .map((key) => nodeCard(document, layout, key, options, navigable.focused))
761
+ .filter((card) => card !== null);
762
+ return cards.length === 0
763
+ ? null
764
+ : element('ol', { class: 'ig-list', 'aria-label': label }, cards);
765
+ })
766
+ .filter((list) => list !== null);
767
+ const root = element('section', {
768
+ class: 'ig-viewer ig-graph',
769
+ 'data-projection': 'graph',
770
+ // Stamped on all three roots rather than on the one that draws rows.
771
+ // A projection toggle must not silently drop a host's decision, and
772
+ // the graph carries a footer group of rows of its own. See
773
+ // `SceneOptions.density`.
774
+ 'data-density': options.density,
775
+ 'data-ig-condition': conditionKind(document, options.chrome),
776
+ 'aria-label': 'issue order and relationships',
777
+ }, [
778
+ options.chrome === false
779
+ ? null
780
+ : hostHeader(document, {
781
+ projection: 'graph',
782
+ compact: layout.compact,
783
+ switchable: options.switchable === true,
784
+ }),
785
+ // ABOVE THE STAGE, which is where this projection's order is drawn — the
786
+ // same position relative to the order that the list gives it. It is NOT
787
+ // the list's arrangement: the graph draws its NOW row only when the canvas
788
+ // refuses (otherwise the running job is a station on the spine), so there
789
+ // is usually no NOW row here for the notice to sit under, and in the
790
+ // refused case the band stands in for the spine the notice qualifies.
791
+ // Gated as chrome, for the reason `conditionNotice` states.
792
+ conditionNotice(document, options.chrome),
681
793
  // ONE STAGE, sized in the layout's own units, so an absolutely-positioned
682
- // rail row and an SVG coordinate mean the same thing. A percentage-width
683
- // canvas would rescale under the rail and the two would drift apart.
684
- // A refusal draws no nodes, so it needs no stage and the rail stays in
794
+ // card and an SVG coordinate mean the same thing. A percentage-width
795
+ // canvas would rescale under the cards and the two would drift apart.
796
+ // A refusal draws no nodes, so it needs no stage and the list stays in
685
797
  // ordinary flow — a fixed-height stage would clip it.
686
- refused
798
+ // NO STAGE WITHOUT A NODE TO SIT ON. A stage is sized in the layout's own
799
+ // units, and a layout with no boxes has none — so a document whose issues
800
+ // are all in the footer renders its message and its group in ordinary
801
+ // flow, exactly as a refusal does, without BEING a refusal.
802
+ refused || nodeCount === 0
687
803
  ? canvas
688
804
  : element('div', {
689
805
  class: 'ig-stage',
690
806
  style: `--ig-stage-w:${String(Math.round(layout.width))}px;--ig-stage-h:${String(Math.round(layout.height))}px`,
691
- }, [canvas, spineRail(document, layout, options, navigable.focused, true)]),
692
- refused ? spineRail(document, layout, options, navigable.focused, false) : null,
807
+ }, [
808
+ canvas,
809
+ element('div', { class: 'ig-rail' }, [
810
+ ...columnHeads(layout),
811
+ ...cardLists,
812
+ ...spineStations(document, layout, theme),
813
+ ]),
814
+ ]),
815
+ // THE RUNNING JOB SURVIVES A REFUSAL. A refused canvas draws no station,
816
+ // so the one issue the panel exists to say is in flight vanished
817
+ // completely when it held no slot, and was reduced to an ordinary order
818
+ // row — no phase, no elapsed time — when it did. The band is what §16a
819
+ // uses where there is no spine to put a station on, and a refusal is
820
+ // exactly that case.
821
+ refused ? nowRows(document) : null,
822
+ refused ? refusalOrder(document, options, navigable.focused) : null,
823
+ // §16a'S FOOTER GROUP, ON THE GRAPH. A runner-held slot that blocks
824
+ // nothing on the spine has no column to sit in — it is neither the order
825
+ // nor an explanation of it — and drawing it on the spine gave it a
826
+ // position in a sequence it is not part of. So it lands here, in the same
827
+ // one-line-each group the list draws, which is also what keeps the two
828
+ // projections showing the same set of issues.
829
+ refused ? null : footerGroup(document, layout, options, navigable.focused),
693
830
  document.isolated.length === 0
694
831
  ? null
695
832
  : element('p', { class: 'ig-count' }, [
696
833
  `${String(document.isolated.length)} isolated ${document.isolated.length === 1 ? 'issue' : 'issues'} not drawn`,
697
834
  ]),
835
+ legend(),
836
+ // LAST, because §16h calls it the panel FOOTER and the legend is a footer
837
+ // bar. Above it, the line was not the last thing on the panel.
838
+ adoptionNote(document, options.chrome),
698
839
  ]);
699
840
  return {
700
841
  projection: 'graph',